mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 08:06:56 +00:00
Compare commits
22 Commits
d8e18fb1d3
...
v0.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
385404ece2 | ||
|
|
9635d2a51b | ||
|
|
3b0cfd5a92 | ||
|
|
2f6d35c335 | ||
|
|
ac6aaa18ca | ||
|
|
72babc532f | ||
|
|
63163d6279 | ||
|
|
4797ccfd23 | ||
|
|
14693160b8 | ||
|
|
bcb57bb12d | ||
|
|
ff6ea3a0b9 | ||
|
|
420e30f677 | ||
|
|
befa535526 | ||
|
|
7b621d6e98 | ||
|
|
3906ec4371 | ||
|
|
90ac5ba4c3 | ||
|
|
dfffa004ac | ||
|
|
0cd55a413d | ||
|
|
361d966a59 | ||
|
|
aef43b8bfa | ||
|
|
7f128cccf6 | ||
|
|
a198e5f6dc |
@@ -9,8 +9,8 @@
|
||||
class?: ClassValue;
|
||||
onclick?: () => void;
|
||||
onRemoveClick?: () => void;
|
||||
selected?: boolean;
|
||||
removable?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -38,7 +38,7 @@
|
||||
<span>
|
||||
{@render children()}
|
||||
</span>
|
||||
{#if selected && removable}
|
||||
{#if removable && selected}
|
||||
<button
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { getFileThumbnail } from "$lib/modules/file";
|
||||
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||
|
||||
import IconFavorite from "~icons/material-symbols/favorite";
|
||||
|
||||
interface Props {
|
||||
info: SummarizedFileInfo;
|
||||
onclick?: (file: SummarizedFileInfo) => void;
|
||||
@@ -14,11 +16,19 @@
|
||||
|
||||
<button
|
||||
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
|
||||
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
|
||||
class="relative aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
|
||||
>
|
||||
{#if $thumbnail}
|
||||
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div class="h-full w-full bg-gray-100"></div>
|
||||
{/if}
|
||||
{#if info.isFavorite}
|
||||
<div class={["absolute bottom-0.5 right-0.5"]}>
|
||||
<IconFavorite
|
||||
class="text-sm text-red-500"
|
||||
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
{#if showBackButton}
|
||||
<button
|
||||
onclick={onBackClick ?? (() => history.back())}
|
||||
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
|
||||
class="w-full rounded-full p-1 text-2xl active:bg-black active:bg-opacity-[0.04]"
|
||||
>
|
||||
<IconArrowBack class="text-2xl" />
|
||||
<IconArrowBack />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
import IconFolder from "~icons/material-symbols/folder";
|
||||
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
import IconFavorite from "~icons/material-symbols/favorite";
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
isFavorite?: boolean;
|
||||
name: string;
|
||||
subtext?: string;
|
||||
textClass?: ClassValue;
|
||||
@@ -17,6 +19,7 @@
|
||||
|
||||
let {
|
||||
class: className,
|
||||
isFavorite = false,
|
||||
name,
|
||||
subtext,
|
||||
textClass: textClassName,
|
||||
@@ -26,7 +29,7 @@
|
||||
</script>
|
||||
|
||||
{#snippet iconSnippet()}
|
||||
<div class="flex h-10 w-10 items-center justify-center text-xl">
|
||||
<div class="relative flex h-10 w-10 items-center justify-center text-xl">
|
||||
{#if thumbnail}
|
||||
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
|
||||
{:else if type === "directory"}
|
||||
@@ -36,6 +39,14 @@
|
||||
{:else}
|
||||
<IconDraft class="text-blue-400" />
|
||||
{/if}
|
||||
{#if isFavorite}
|
||||
<div class={["absolute bottom-0 right-0", !thumbnail && "rounded-full bg-white p-0.5"]}>
|
||||
<IconFavorite
|
||||
class="text-xs text-red-500"
|
||||
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
|
||||
@@ -20,7 +20,12 @@ export const getFileCacheIndex = async () => {
|
||||
};
|
||||
|
||||
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
|
||||
await cacheIndex.fileCache.put(fileCacheIndex);
|
||||
await cacheIndex.fileCache.put({
|
||||
fileId: fileCacheIndex.fileId,
|
||||
cachedAt: fileCacheIndex.cachedAt,
|
||||
lastRetrievedAt: fileCacheIndex.lastRetrievedAt,
|
||||
size: fileCacheIndex.size,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteFileCacheIndex = async (fileId: number) => {
|
||||
|
||||
@@ -4,6 +4,7 @@ interface DirectoryInfo {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
name: string;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
@@ -14,6 +15,7 @@ interface FileInfo {
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
categoryIds?: number[];
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
interface CategoryInfo {
|
||||
@@ -46,6 +48,23 @@ filesystem
|
||||
});
|
||||
});
|
||||
|
||||
filesystem.version(4).upgrade(async (trx) => {
|
||||
await Promise.all([
|
||||
trx
|
||||
.table("directory")
|
||||
.toCollection()
|
||||
.modify((directory) => {
|
||||
directory.isFavorite = false;
|
||||
}),
|
||||
trx
|
||||
.table("file")
|
||||
.toCollection()
|
||||
.modify((file) => {
|
||||
file.isFavorite = false;
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
||||
return await filesystem.directory.where({ parentId }).toArray();
|
||||
};
|
||||
@@ -55,7 +74,11 @@ export const getDirectoryInfo = async (id: number) => {
|
||||
};
|
||||
|
||||
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
|
||||
await filesystem.directory.upsert(directoryInfo.id, { ...directoryInfo });
|
||||
await filesystem.directory.upsert(directoryInfo.id, {
|
||||
parentId: directoryInfo.parentId,
|
||||
name: directoryInfo.name,
|
||||
isFavorite: directoryInfo.isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteDirectoryInfo = async (id: number) => {
|
||||
@@ -89,7 +112,15 @@ export const bulkGetFileInfos = async (ids: number[]) => {
|
||||
};
|
||||
|
||||
export const storeFileInfo = async (fileInfo: FileInfo) => {
|
||||
await filesystem.file.upsert(fileInfo.id, { ...fileInfo });
|
||||
await filesystem.file.upsert(fileInfo.id, {
|
||||
parentId: fileInfo.parentId,
|
||||
name: fileInfo.name,
|
||||
contentType: fileInfo.contentType,
|
||||
createdAt: fileInfo.createdAt,
|
||||
lastModifiedAt: fileInfo.lastModifiedAt,
|
||||
categoryIds: fileInfo.categoryIds,
|
||||
isFavorite: fileInfo.isFavorite,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteFileInfo = async (id: number) => {
|
||||
@@ -116,7 +147,12 @@ export const getCategoryInfo = async (id: number) => {
|
||||
};
|
||||
|
||||
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
|
||||
await filesystem.category.upsert(categoryInfo.id, { ...categoryInfo });
|
||||
await filesystem.category.upsert(categoryInfo.id, {
|
||||
parentId: categoryInfo.parentId,
|
||||
name: categoryInfo.name,
|
||||
files: categoryInfo.files,
|
||||
isFileRecursive: categoryInfo.isFileRecursive,
|
||||
});
|
||||
};
|
||||
|
||||
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as IndexedDB from "$lib/indexedDB";
|
||||
import { trpc, isTRPCClientError } from "$trpc/client";
|
||||
import { decryptFileMetadata, decryptCategoryMetadata } from "./common";
|
||||
import { FilesystemCache } from "./FilesystemCache.svelte";
|
||||
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
|
||||
import type { CategoryInfo, MaybeCategoryInfo } from "./types";
|
||||
|
||||
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
|
||||
@@ -22,6 +21,7 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
|
||||
name: fileInfo.name,
|
||||
createdAt: fileInfo.createdAt,
|
||||
lastModifiedAt: fileInfo.lastModifiedAt,
|
||||
isFavorite: fileInfo.isFavorite,
|
||||
isRecursive: file.isRecursive,
|
||||
}
|
||||
: undefined;
|
||||
@@ -65,6 +65,7 @@ const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
|
||||
id: file.id,
|
||||
parentId: file.parent,
|
||||
contentType: file.contentType,
|
||||
isFavorite: file.isFavorite,
|
||||
isRecursive: file.isRecursive,
|
||||
...(await decryptFileMetadata(file, masterKey)),
|
||||
})),
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||
|
||||
export const decryptDirectoryMetadata = async (
|
||||
metadata: { dek: string; dekVersion: Date; name: string; nameIv: string },
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
};
|
||||
};
|
||||
|
||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||
};
|
||||
|
||||
export const decryptFileMetadata = async (
|
||||
metadata: {
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
createdAt?: string;
|
||||
createdAtIv?: string;
|
||||
lastModifiedAt: string;
|
||||
lastModifiedAtIv: string;
|
||||
},
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||
const [name, createdAt, lastModifiedAt] = await Promise.all([
|
||||
decryptString(metadata.name, metadata.nameIv, dataKey),
|
||||
metadata.createdAt
|
||||
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
|
||||
: undefined,
|
||||
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptCategoryMetadata = decryptDirectoryMetadata;
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as IndexedDB from "$lib/indexedDB";
|
||||
import { trpc, isTRPCClientError } from "$trpc/client";
|
||||
import { decryptDirectoryMetadata, decryptFileMetadata } from "./common";
|
||||
import { FilesystemCache, type FilesystemCacheOptions } from "./FilesystemCache.svelte";
|
||||
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
|
||||
import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
|
||||
|
||||
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
@@ -27,6 +26,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
name: directory.name,
|
||||
subDirectories,
|
||||
files,
|
||||
isFavorite: directory.isFavorite,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -39,6 +39,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
directory.subDirectories.map(async (directory) => ({
|
||||
id: directory.id,
|
||||
parentId: id,
|
||||
isFavorite: directory.isFavorite,
|
||||
...(await decryptDirectoryMetadata(directory, masterKey)),
|
||||
})),
|
||||
),
|
||||
@@ -47,6 +48,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
id: file.id,
|
||||
parentId: id,
|
||||
contentType: file.contentType,
|
||||
isFavorite: file.isFavorite,
|
||||
...(await decryptFileMetadata(file, masterKey)),
|
||||
})),
|
||||
),
|
||||
@@ -60,6 +62,7 @@ const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
|
||||
parentId: directory.metadata!.parent,
|
||||
subDirectories,
|
||||
files,
|
||||
isFavorite: directory.metadata!.isFavorite,
|
||||
...metadata!,
|
||||
}
|
||||
: { id, subDirectories, files },
|
||||
@@ -102,8 +105,30 @@ export const getDirectoryInfo = (
|
||||
id: DirectoryId,
|
||||
masterKey: CryptoKey,
|
||||
options?: {
|
||||
fetchFromServer?: FilesystemCacheOptions<DirectoryId, MaybeDirectoryInfo>["fetchFromServer"];
|
||||
serverResponse?: {
|
||||
parent: DirectoryId;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
isFavorite: boolean;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
return cache.get(id, masterKey, options);
|
||||
return cache.get(id, masterKey, {
|
||||
fetchFromServer:
|
||||
options?.serverResponse &&
|
||||
(async (cachedValue) => {
|
||||
const metadata = await decryptDirectoryMetadata(options!.serverResponse!, masterKey);
|
||||
return storeToIndexedDB({
|
||||
subDirectories: [],
|
||||
files: [],
|
||||
...cachedValue,
|
||||
id: id as number,
|
||||
parentId: options!.serverResponse!.parent,
|
||||
isFavorite: options!.serverResponse!.isFavorite,
|
||||
...metadata,
|
||||
});
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as IndexedDB from "$lib/indexedDB";
|
||||
import { trpc, isTRPCClientError } from "$trpc/client";
|
||||
import { decryptFileMetadata, decryptCategoryMetadata } from "./common";
|
||||
import { FilesystemCache, type FilesystemCacheOptions } from "./FilesystemCache.svelte";
|
||||
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
|
||||
import type { FileInfo, MaybeFileInfo } from "./types";
|
||||
|
||||
const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
@@ -28,6 +27,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
createdAt: file.createdAt,
|
||||
lastModifiedAt: file.lastModifiedAt,
|
||||
categories: categories?.filter((category) => !!category) ?? [],
|
||||
isFavorite: file.isFavorite,
|
||||
};
|
||||
}
|
||||
},
|
||||
@@ -56,6 +56,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
createdAt: metadata.createdAt,
|
||||
lastModifiedAt: metadata.lastModifiedAt,
|
||||
categories,
|
||||
isFavorite: file.isFavorite,
|
||||
});
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
@@ -121,6 +122,7 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
|
||||
parentId: metadataRaw.parent,
|
||||
contentType: metadataRaw.contentType,
|
||||
categories,
|
||||
isFavorite: metadataRaw.isFavorite,
|
||||
...metadata,
|
||||
};
|
||||
}),
|
||||
@@ -172,9 +174,38 @@ const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
|
||||
export const getFileInfo = (
|
||||
id: number,
|
||||
masterKey: CryptoKey,
|
||||
options?: { fetchFromServer?: FilesystemCacheOptions<number, MaybeFileInfo>["fetchFromServer"] },
|
||||
options?: {
|
||||
serverResponse?: {
|
||||
parent: DirectoryId;
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
contentType: string;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
createdAt?: string;
|
||||
createdAtIv?: string;
|
||||
lastModifiedAt: string;
|
||||
lastModifiedAtIv: string;
|
||||
isFavorite: boolean;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
return cache.get(id, masterKey, options);
|
||||
return cache.get(id, masterKey, {
|
||||
fetchFromServer:
|
||||
options?.serverResponse &&
|
||||
(async (cachedValue) => {
|
||||
const metadata = await decryptFileMetadata(options!.serverResponse!, masterKey);
|
||||
return storeToIndexedDB({
|
||||
categories: [],
|
||||
...cachedValue,
|
||||
id,
|
||||
parentId: options!.serverResponse!.parent,
|
||||
contentType: options!.serverResponse!.contentType,
|
||||
isFavorite: options!.serverResponse!.isFavorite,
|
||||
...metadata,
|
||||
});
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./category";
|
||||
export * from "./common";
|
||||
export * from "./directory";
|
||||
export * from "./file";
|
||||
export * from "./types";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { untrack } from "svelte";
|
||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||
|
||||
export interface FilesystemCacheOptions<K, V> {
|
||||
interface FilesystemCacheOptions<K, V> {
|
||||
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
|
||||
fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise<V>;
|
||||
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
|
||||
@@ -18,7 +19,7 @@ export class FilesystemCache<K, V extends object> {
|
||||
get(
|
||||
key: K,
|
||||
masterKey: CryptoKey,
|
||||
options?: { fetchFromServer?: FilesystemCacheOptions<K, V>["fetchFromServer"] },
|
||||
options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> },
|
||||
) {
|
||||
return untrack(() => {
|
||||
let state = this.map.get(key);
|
||||
@@ -33,7 +34,7 @@ export class FilesystemCache<K, V extends object> {
|
||||
}
|
||||
|
||||
(state.value
|
||||
? Promise.resolve(state.value)
|
||||
? Promise.resolve($state.snapshot(state.value) as V)
|
||||
: this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
|
||||
if (loadedInfo) {
|
||||
state.value = loadedInfo;
|
||||
@@ -42,8 +43,10 @@ export class FilesystemCache<K, V extends object> {
|
||||
return loadedInfo;
|
||||
})
|
||||
)
|
||||
.then((cachedInfo) =>
|
||||
(options?.fetchFromServer ?? this.options.fetchFromServer)(key, cachedInfo, masterKey),
|
||||
.then(
|
||||
(cachedInfo) =>
|
||||
options?.fetchFromServer?.(cachedInfo) ??
|
||||
this.options.fetchFromServer(key, cachedInfo, masterKey),
|
||||
)
|
||||
.then((loadedInfo) => {
|
||||
if (state.value) {
|
||||
@@ -126,3 +129,52 @@ export class FilesystemCache<K, V extends object> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const decryptDirectoryMetadata = async (
|
||||
metadata: { dek: string; dekVersion: Date; name: string; nameIv: string },
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
};
|
||||
};
|
||||
|
||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||
};
|
||||
|
||||
export const decryptFileMetadata = async (
|
||||
metadata: {
|
||||
dek: string;
|
||||
dekVersion: Date;
|
||||
name: string;
|
||||
nameIv: string;
|
||||
createdAt?: string;
|
||||
createdAtIv?: string;
|
||||
lastModifiedAt: string;
|
||||
lastModifiedAtIv: string;
|
||||
},
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||
const [name, createdAt, lastModifiedAt] = await Promise.all([
|
||||
decryptString(metadata.name, metadata.nameIv, dataKey),
|
||||
metadata.createdAt
|
||||
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
|
||||
: undefined,
|
||||
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
dataKey: { key: dataKey, version: metadata.dekVersion },
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
};
|
||||
};
|
||||
|
||||
export const decryptCategoryMetadata = decryptDirectoryMetadata;
|
||||
@@ -1,5 +1,4 @@
|
||||
export type DataKey = { key: CryptoKey; version: Date };
|
||||
type AllUndefined<T> = { [K in keyof T]?: undefined };
|
||||
|
||||
export interface LocalDirectoryInfo {
|
||||
id: number;
|
||||
@@ -8,6 +7,7 @@ export interface LocalDirectoryInfo {
|
||||
name: string;
|
||||
subDirectories: SubDirectoryInfo[];
|
||||
files: SummarizedFileInfo[];
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export interface RootDirectoryInfo {
|
||||
@@ -17,6 +17,7 @@ export interface RootDirectoryInfo {
|
||||
name?: undefined;
|
||||
subDirectories: SubDirectoryInfo[];
|
||||
files: SummarizedFileInfo[];
|
||||
isFavorite?: undefined;
|
||||
}
|
||||
|
||||
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
|
||||
@@ -36,6 +37,7 @@ export interface FileInfo {
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
categories: FileCategoryInfo[];
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export type MaybeFileInfo =
|
||||
|
||||
278
src/lib/server/db/directory.ts
Normal file
278
src/lib/server/db/directory.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import type { Selectable } from "kysely";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext, DirectoryTable } from "./schema";
|
||||
|
||||
interface Directory {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
userId: number;
|
||||
mekVersion: number;
|
||||
encDek: string;
|
||||
dekVersion: Date;
|
||||
encName: Ciphertext;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
const toDirectory = (row: Selectable<DirectoryTable>): Directory => ({
|
||||
id: row.id,
|
||||
parentId: row.parent_id ?? "root",
|
||||
userId: row.user_id,
|
||||
mekVersion: row.master_encryption_key_version,
|
||||
encDek: row.encrypted_data_encryption_key,
|
||||
dekVersion: row.data_encryption_key_version,
|
||||
encName: row.encrypted_name,
|
||||
isFavorite: row.is_favorite,
|
||||
});
|
||||
|
||||
export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const mek = await trx
|
||||
.selectFrom("master_encryption_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", params.userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (mek?.version !== params.mekVersion) {
|
||||
throw new IntegrityError("Inactive MEK version");
|
||||
}
|
||||
|
||||
const { directoryId } = await trx
|
||||
.insertInto("directory")
|
||||
.values({
|
||||
parent_id: params.parentId !== "root" ? params.parentId : null,
|
||||
user_id: params.userId,
|
||||
master_encryption_key_version: params.mekVersion,
|
||||
encrypted_data_encryption_key: params.encDek,
|
||||
data_encryption_key_version: params.dekVersion,
|
||||
encrypted_name: params.encName,
|
||||
})
|
||||
.returning("id as directoryId")
|
||||
.executeTakeFirstOrThrow();
|
||||
await trx
|
||||
.insertInto("directory_log")
|
||||
.values({
|
||||
directory_id: directoryId,
|
||||
timestamp: new Date(),
|
||||
action: "create",
|
||||
new_name: params.encName,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
|
||||
const directories = await db
|
||||
.selectFrom("directory")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.$if(parentId === "root", (qb) => qb.where("parent_id", "is", null))
|
||||
.$if(parentId !== "root", (qb) => qb.where("parent_id", "=", parentId as number))
|
||||
.execute();
|
||||
return directories.map(toDirectory);
|
||||
};
|
||||
|
||||
export const getAllFavoriteDirectories = async (userId: number) => {
|
||||
const directories = await db
|
||||
.selectFrom("directory")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.where("is_favorite", "=", true)
|
||||
.execute();
|
||||
return directories.map(toDirectory);
|
||||
};
|
||||
|
||||
export const getDirectory = async (userId: number, directoryId: number) => {
|
||||
const directory = await db
|
||||
.selectFrom("directory")
|
||||
.selectAll()
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return directory ? toDirectory(directory) : null;
|
||||
};
|
||||
|
||||
export const searchDirectories = async (
|
||||
userId: number,
|
||||
filters: {
|
||||
parentId: DirectoryId;
|
||||
inFavorites: boolean;
|
||||
},
|
||||
) => {
|
||||
const directories = await db
|
||||
.withRecursive("directory_tree", (db) =>
|
||||
db
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.$if(filters.parentId === "root", (qb) => qb.where((eb) => eb.lit(false))) // directory_tree will be empty if parentId is "root"
|
||||
.$if(filters.parentId !== "root", (qb) => qb.where("id", "=", filters.parentId as number))
|
||||
.unionAll(
|
||||
db
|
||||
.selectFrom("directory as d")
|
||||
.innerJoin("directory_tree as dt", "d.parent_id", "dt.id")
|
||||
.select("d.id"),
|
||||
),
|
||||
)
|
||||
.withRecursive("favorite_directory_tree", (db) =>
|
||||
db
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.$if(!filters.inFavorites, (qb) => qb.where((eb) => eb.lit(false))) // favorite_directory_tree will be empty if inFavorites is false
|
||||
.$if(filters.inFavorites, (qb) => qb.where("is_favorite", "=", true))
|
||||
.unionAll((db) =>
|
||||
db
|
||||
.selectFrom("directory as d")
|
||||
.innerJoin("favorite_directory_tree as dt", "d.parent_id", "dt.id")
|
||||
.select("d.id"),
|
||||
),
|
||||
)
|
||||
.selectFrom("directory")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.$if(filters.parentId !== "root", (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.exists(eb.selectFrom("directory_tree as dt").whereRef("dt.id", "=", "parent_id")),
|
||||
),
|
||||
)
|
||||
.$if(filters.inFavorites, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.exists(
|
||||
eb.selectFrom("favorite_directory_tree as dt").whereRef("dt.id", "=", "directory.id"),
|
||||
),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
return directories.map(toDirectory);
|
||||
};
|
||||
|
||||
export const setDirectoryEncName = async (
|
||||
userId: number,
|
||||
directoryId: number,
|
||||
dekVersion: Date,
|
||||
encName: Ciphertext,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const directory = await trx
|
||||
.selectFrom("directory")
|
||||
.select("data_encryption_key_version")
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!directory) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
} else if (directory.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
|
||||
throw new IntegrityError("Invalid DEK version");
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("directory")
|
||||
.set({ encrypted_name: encName })
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("directory_log")
|
||||
.values({
|
||||
directory_id: directoryId,
|
||||
timestamp: new Date(),
|
||||
action: "rename",
|
||||
new_name: encName,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const setDirectoryFavorite = async (
|
||||
userId: number,
|
||||
directoryId: number,
|
||||
isFavorite: boolean,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const directory = await trx
|
||||
.selectFrom("directory")
|
||||
.select("is_favorite")
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!directory) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
} else if (directory.is_favorite === isFavorite) {
|
||||
throw new IntegrityError(
|
||||
isFavorite ? "Directory already favorited" : "Directory not favorited",
|
||||
);
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("directory")
|
||||
.set({ is_favorite: isFavorite })
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("directory_log")
|
||||
.values({
|
||||
directory_id: directoryId,
|
||||
timestamp: new Date(),
|
||||
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const unregisterDirectory = async (userId: number, directoryId: number) => {
|
||||
return await db
|
||||
.transaction()
|
||||
.setIsolationLevel("repeatable read") // TODO: Sufficient?
|
||||
.execute(async (trx) => {
|
||||
const unregisterFiles = async (parentId: number) => {
|
||||
const files = await trx
|
||||
.selectFrom("file")
|
||||
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
|
||||
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
|
||||
.where("file.parent_id", "=", parentId)
|
||||
.where("file.user_id", "=", userId)
|
||||
.forUpdate("file")
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("file")
|
||||
.where("parent_id", "=", parentId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
return files;
|
||||
};
|
||||
const unregisterDirectoryRecursively = async (
|
||||
directoryId: number,
|
||||
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
|
||||
const files = await unregisterFiles(directoryId);
|
||||
const subDirectories = await trx
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("parent_id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
const subDirectoryFilePaths = await Promise.all(
|
||||
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
|
||||
);
|
||||
|
||||
const deleteRes = await trx
|
||||
.deleteFrom("directory")
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (deleteRes.numDeletedRows === 0n) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
}
|
||||
return files.concat(...subDirectoryFilePaths);
|
||||
};
|
||||
return await unregisterDirectoryRecursively(directoryId);
|
||||
});
|
||||
};
|
||||
@@ -8,10 +8,14 @@ type IntegrityErrorMessages =
|
||||
| "User client already exists"
|
||||
// File
|
||||
| "Directory not found"
|
||||
| "Directory already favorited"
|
||||
| "Directory not favorited"
|
||||
| "File not found"
|
||||
| "File is not legacy"
|
||||
| "File not found in category"
|
||||
| "File already added to category"
|
||||
| "File already favorited"
|
||||
| "File not favorited"
|
||||
| "Invalid DEK version"
|
||||
// HSK
|
||||
| "HSK already registered"
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import { sql } from "kysely";
|
||||
import { sql, type Selectable } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/postgres";
|
||||
import pg from "pg";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext } from "./schema";
|
||||
|
||||
interface Directory {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
userId: number;
|
||||
mekVersion: number;
|
||||
encDek: string;
|
||||
dekVersion: Date;
|
||||
encName: Ciphertext;
|
||||
}
|
||||
import type { Ciphertext, FileTable } from "./schema";
|
||||
|
||||
interface File {
|
||||
id: number;
|
||||
@@ -31,6 +21,7 @@ interface File {
|
||||
encName: Ciphertext;
|
||||
encCreatedAt: Ciphertext | null;
|
||||
encLastModifiedAt: Ciphertext;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
interface FileCategory {
|
||||
@@ -42,208 +33,26 @@ interface FileCategory {
|
||||
encName: Ciphertext;
|
||||
}
|
||||
|
||||
export const registerDirectory = async (params: Omit<Directory, "id">) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const mek = await trx
|
||||
.selectFrom("master_encryption_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", params.userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (mek?.version !== params.mekVersion) {
|
||||
throw new IntegrityError("Inactive MEK version");
|
||||
}
|
||||
const toFile = (row: Selectable<FileTable>): File => ({
|
||||
id: row.id,
|
||||
parentId: row.parent_id ?? "root",
|
||||
userId: row.user_id,
|
||||
path: row.path,
|
||||
mekVersion: row.master_encryption_key_version,
|
||||
encDek: row.encrypted_data_encryption_key,
|
||||
dekVersion: row.data_encryption_key_version,
|
||||
hskVersion: row.hmac_secret_key_version,
|
||||
contentHmac: row.content_hmac,
|
||||
contentType: row.content_type,
|
||||
encContentIv: row.encrypted_content_iv,
|
||||
encContentHash: row.encrypted_content_hash,
|
||||
encName: row.encrypted_name,
|
||||
encCreatedAt: row.encrypted_created_at,
|
||||
encLastModifiedAt: row.encrypted_last_modified_at,
|
||||
isFavorite: row.is_favorite,
|
||||
});
|
||||
|
||||
const { directoryId } = await trx
|
||||
.insertInto("directory")
|
||||
.values({
|
||||
parent_id: params.parentId !== "root" ? params.parentId : null,
|
||||
user_id: params.userId,
|
||||
master_encryption_key_version: params.mekVersion,
|
||||
encrypted_data_encryption_key: params.encDek,
|
||||
data_encryption_key_version: params.dekVersion,
|
||||
encrypted_name: params.encName,
|
||||
})
|
||||
.returning("id as directoryId")
|
||||
.executeTakeFirstOrThrow();
|
||||
await trx
|
||||
.insertInto("directory_log")
|
||||
.values({
|
||||
directory_id: directoryId,
|
||||
timestamp: new Date(),
|
||||
action: "create",
|
||||
new_name: params.encName,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
|
||||
let query = db.selectFrom("directory").selectAll().where("user_id", "=", userId);
|
||||
query =
|
||||
parentId === "root"
|
||||
? query.where("parent_id", "is", null)
|
||||
: query.where("parent_id", "=", parentId);
|
||||
const directories = await query.execute();
|
||||
return directories.map(
|
||||
(directory) =>
|
||||
({
|
||||
id: directory.id,
|
||||
parentId: directory.parent_id ?? "root",
|
||||
userId: directory.user_id,
|
||||
mekVersion: directory.master_encryption_key_version,
|
||||
encDek: directory.encrypted_data_encryption_key,
|
||||
dekVersion: directory.data_encryption_key_version,
|
||||
encName: directory.encrypted_name,
|
||||
}) satisfies Directory,
|
||||
);
|
||||
};
|
||||
|
||||
export const getAllRecursiveDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
|
||||
const directories = await db
|
||||
.withRecursive("directory_tree", (db) =>
|
||||
db
|
||||
.selectFrom("directory")
|
||||
.selectAll()
|
||||
.$if(parentId === "root", (qb) => qb.where("parent_id", "is", null))
|
||||
.$if(parentId !== "root", (qb) => qb.where("parent_id", "=", parentId as number))
|
||||
.where("user_id", "=", userId)
|
||||
.unionAll((db) =>
|
||||
db
|
||||
.selectFrom("directory")
|
||||
.innerJoin("directory_tree", "directory.parent_id", "directory_tree.id")
|
||||
.selectAll("directory"),
|
||||
),
|
||||
)
|
||||
.selectFrom("directory_tree")
|
||||
.selectAll()
|
||||
.execute();
|
||||
return directories.map(
|
||||
(directory) =>
|
||||
({
|
||||
id: directory.id,
|
||||
parentId: directory.parent_id ?? "root",
|
||||
userId: directory.user_id,
|
||||
mekVersion: directory.master_encryption_key_version,
|
||||
encDek: directory.encrypted_data_encryption_key,
|
||||
dekVersion: directory.data_encryption_key_version,
|
||||
encName: directory.encrypted_name,
|
||||
}) satisfies Directory,
|
||||
);
|
||||
};
|
||||
|
||||
export const getDirectory = async (userId: number, directoryId: number) => {
|
||||
const directory = await db
|
||||
.selectFrom("directory")
|
||||
.selectAll()
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return directory
|
||||
? ({
|
||||
id: directory.id,
|
||||
parentId: directory.parent_id ?? "root",
|
||||
userId: directory.user_id,
|
||||
mekVersion: directory.master_encryption_key_version,
|
||||
encDek: directory.encrypted_data_encryption_key,
|
||||
dekVersion: directory.data_encryption_key_version,
|
||||
encName: directory.encrypted_name,
|
||||
} satisfies Directory)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const setDirectoryEncName = async (
|
||||
userId: number,
|
||||
directoryId: number,
|
||||
dekVersion: Date,
|
||||
encName: Ciphertext,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const directory = await trx
|
||||
.selectFrom("directory")
|
||||
.select("data_encryption_key_version")
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!directory) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
} else if (directory.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
|
||||
throw new IntegrityError("Invalid DEK version");
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("directory")
|
||||
.set({ encrypted_name: encName })
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("directory_log")
|
||||
.values({
|
||||
directory_id: directoryId,
|
||||
timestamp: new Date(),
|
||||
action: "rename",
|
||||
new_name: encName,
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const unregisterDirectory = async (userId: number, directoryId: number) => {
|
||||
return await db
|
||||
.transaction()
|
||||
.setIsolationLevel("repeatable read") // TODO: Sufficient?
|
||||
.execute(async (trx) => {
|
||||
const unregisterFiles = async (parentId: number) => {
|
||||
const files = await trx
|
||||
.selectFrom("file")
|
||||
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
|
||||
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
|
||||
.where("file.parent_id", "=", parentId)
|
||||
.where("file.user_id", "=", userId)
|
||||
.forUpdate("file")
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("file")
|
||||
.where("parent_id", "=", parentId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
return files;
|
||||
};
|
||||
const unregisterDirectoryRecursively = async (
|
||||
directoryId: number,
|
||||
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
|
||||
const files = await unregisterFiles(directoryId);
|
||||
const subDirectories = await trx
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("parent_id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
const subDirectoryFilePaths = await Promise.all(
|
||||
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
|
||||
);
|
||||
|
||||
const deleteRes = await trx
|
||||
.deleteFrom("directory")
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (deleteRes.numDeletedRows === 0n) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
}
|
||||
return files.concat(...subDirectoryFilePaths);
|
||||
};
|
||||
return await unregisterDirectoryRecursively(directoryId);
|
||||
});
|
||||
};
|
||||
|
||||
export const registerFile = async (trx: typeof db, params: Omit<File, "id">) => {
|
||||
export const registerFile = async (trx: typeof db, params: Omit<File, "id" | "isFavorite">) => {
|
||||
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
|
||||
throw new Error("Invalid arguments");
|
||||
}
|
||||
@@ -281,32 +90,14 @@ export const registerFile = async (trx: typeof db, params: Omit<File, "id">) =>
|
||||
};
|
||||
|
||||
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
|
||||
let query = db.selectFrom("file").selectAll().where("user_id", "=", userId);
|
||||
query =
|
||||
parentId === "root"
|
||||
? query.where("parent_id", "is", null)
|
||||
: query.where("parent_id", "=", parentId);
|
||||
const files = await query.execute();
|
||||
return files.map(
|
||||
(file) =>
|
||||
({
|
||||
id: file.id,
|
||||
parentId: file.parent_id ?? "root",
|
||||
userId: file.user_id,
|
||||
path: file.path,
|
||||
mekVersion: file.master_encryption_key_version,
|
||||
encDek: file.encrypted_data_encryption_key,
|
||||
dekVersion: file.data_encryption_key_version,
|
||||
hskVersion: file.hmac_secret_key_version,
|
||||
contentHmac: file.content_hmac,
|
||||
contentType: file.content_type,
|
||||
encContentIv: file.encrypted_content_iv,
|
||||
encContentHash: file.encrypted_content_hash,
|
||||
encName: file.encrypted_name,
|
||||
encCreatedAt: file.encrypted_created_at,
|
||||
encLastModifiedAt: file.encrypted_last_modified_at,
|
||||
}) satisfies File,
|
||||
);
|
||||
const files = await db
|
||||
.selectFrom("file")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.$if(parentId === "root", (qb) => qb.where("parent_id", "is", null))
|
||||
.$if(parentId !== "root", (qb) => qb.where("parent_id", "=", parentId as number))
|
||||
.execute();
|
||||
return files.map(toFile);
|
||||
};
|
||||
|
||||
export const getAllFilesByCategory = async (
|
||||
@@ -339,27 +130,10 @@ export const getAllFilesByCategory = async (
|
||||
.orderBy("file_id")
|
||||
.orderBy("depth")
|
||||
.execute();
|
||||
return files.map(
|
||||
(file) =>
|
||||
({
|
||||
id: file.file_id,
|
||||
parentId: file.parent_id ?? "root",
|
||||
userId: file.user_id,
|
||||
path: file.path,
|
||||
mekVersion: file.master_encryption_key_version,
|
||||
encDek: file.encrypted_data_encryption_key,
|
||||
dekVersion: file.data_encryption_key_version,
|
||||
hskVersion: file.hmac_secret_key_version,
|
||||
contentHmac: file.content_hmac,
|
||||
contentType: file.content_type,
|
||||
encContentIv: file.encrypted_content_iv,
|
||||
encContentHash: file.encrypted_content_hash,
|
||||
encName: file.encrypted_name,
|
||||
encCreatedAt: file.encrypted_created_at,
|
||||
encLastModifiedAt: file.encrypted_last_modified_at,
|
||||
isRecursive: file.depth > 0,
|
||||
}) satisfies File & { isRecursive: boolean },
|
||||
);
|
||||
return files.map((file) => ({
|
||||
...toFile(file),
|
||||
isRecursive: file.depth > 0,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getAllFileIds = async (userId: number) => {
|
||||
@@ -375,26 +149,7 @@ export const getLegacyFiles = async (userId: number, limit: number = 100) => {
|
||||
.where("encrypted_content_iv", "is not", null)
|
||||
.limit(limit)
|
||||
.execute();
|
||||
return files.map(
|
||||
(file) =>
|
||||
({
|
||||
id: file.id,
|
||||
parentId: file.parent_id ?? "root",
|
||||
userId: file.user_id,
|
||||
path: file.path,
|
||||
mekVersion: file.master_encryption_key_version,
|
||||
encDek: file.encrypted_data_encryption_key,
|
||||
dekVersion: file.data_encryption_key_version,
|
||||
hskVersion: file.hmac_secret_key_version,
|
||||
contentHmac: file.content_hmac,
|
||||
contentType: file.content_type,
|
||||
encContentIv: file.encrypted_content_iv,
|
||||
encContentHash: file.encrypted_content_hash,
|
||||
encName: file.encrypted_name,
|
||||
encCreatedAt: file.encrypted_created_at,
|
||||
encLastModifiedAt: file.encrypted_last_modified_at,
|
||||
}) satisfies File,
|
||||
);
|
||||
return files.map(toFile);
|
||||
};
|
||||
|
||||
export const getFilesWithoutThumbnail = async (userId: number, limit: number = 100) => {
|
||||
@@ -418,26 +173,7 @@ export const getFilesWithoutThumbnail = async (userId: number, limit: number = 1
|
||||
)
|
||||
.limit(limit)
|
||||
.execute();
|
||||
return files.map(
|
||||
(file) =>
|
||||
({
|
||||
id: file.id,
|
||||
parentId: file.parent_id ?? "root",
|
||||
userId: file.user_id,
|
||||
path: file.path,
|
||||
mekVersion: file.master_encryption_key_version,
|
||||
encDek: file.encrypted_data_encryption_key,
|
||||
dekVersion: file.data_encryption_key_version,
|
||||
hskVersion: file.hmac_secret_key_version,
|
||||
contentHmac: file.content_hmac,
|
||||
contentType: file.content_type,
|
||||
encContentIv: file.encrypted_content_iv,
|
||||
encContentHash: file.encrypted_content_hash,
|
||||
encName: file.encrypted_name,
|
||||
encCreatedAt: file.encrypted_created_at,
|
||||
encLastModifiedAt: file.encrypted_last_modified_at,
|
||||
}) satisfies File,
|
||||
);
|
||||
return files.map(toFile);
|
||||
};
|
||||
|
||||
export const getAllFileIdsByContentHmac = async (
|
||||
@@ -463,25 +199,7 @@ export const getFile = async (userId: number, fileId: number) => {
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return file
|
||||
? ({
|
||||
id: file.id,
|
||||
parentId: file.parent_id ?? "root",
|
||||
userId: file.user_id,
|
||||
path: file.path,
|
||||
mekVersion: file.master_encryption_key_version,
|
||||
encDek: file.encrypted_data_encryption_key,
|
||||
dekVersion: file.data_encryption_key_version,
|
||||
hskVersion: file.hmac_secret_key_version,
|
||||
contentHmac: file.content_hmac,
|
||||
contentType: file.content_type,
|
||||
encContentIv: file.encrypted_content_iv,
|
||||
encContentHash: file.encrypted_content_hash,
|
||||
encName: file.encrypted_name,
|
||||
encCreatedAt: file.encrypted_created_at,
|
||||
encLastModifiedAt: file.encrypted_last_modified_at,
|
||||
} satisfies File)
|
||||
: null;
|
||||
return file ? toFile(file) : null;
|
||||
};
|
||||
|
||||
export const getFilesWithCategories = async (userId: number, fileIds: number[]) => {
|
||||
@@ -500,40 +218,37 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[])
|
||||
.where("id", "=", (eb) => eb.fn.any(eb.val(fileIds)))
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
return files.map(
|
||||
(file) =>
|
||||
({
|
||||
id: file.id,
|
||||
parentId: file.parent_id ?? "root",
|
||||
userId: file.user_id,
|
||||
path: file.path,
|
||||
mekVersion: file.master_encryption_key_version,
|
||||
encDek: file.encrypted_data_encryption_key,
|
||||
dekVersion: file.data_encryption_key_version,
|
||||
hskVersion: file.hmac_secret_key_version,
|
||||
contentHmac: file.content_hmac,
|
||||
contentType: file.content_type,
|
||||
encContentIv: file.encrypted_content_iv,
|
||||
encContentHash: file.encrypted_content_hash,
|
||||
encName: file.encrypted_name,
|
||||
encCreatedAt: file.encrypted_created_at,
|
||||
encLastModifiedAt: file.encrypted_last_modified_at,
|
||||
categories: file.categories.map((category) => ({
|
||||
return files.map((file) => ({
|
||||
...toFile(file),
|
||||
categories: file.categories.map(
|
||||
(category) =>
|
||||
({
|
||||
id: category.id,
|
||||
parentId: category.parent_id ?? "root",
|
||||
mekVersion: category.master_encryption_key_version,
|
||||
encDek: category.encrypted_data_encryption_key,
|
||||
dekVersion: new Date(category.data_encryption_key_version),
|
||||
encName: category.encrypted_name,
|
||||
})),
|
||||
}) satisfies File & { categories: FileCategory[] },
|
||||
);
|
||||
}) satisfies FileCategory,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
export const getAllFavoriteFiles = async (userId: number) => {
|
||||
const files = await db
|
||||
.selectFrom("file")
|
||||
.selectAll()
|
||||
.where("user_id", "=", userId)
|
||||
.where("is_favorite", "=", true)
|
||||
.execute();
|
||||
return files.map(toFile);
|
||||
};
|
||||
|
||||
export const searchFiles = async (
|
||||
userId: number,
|
||||
filters: {
|
||||
parentId: DirectoryId;
|
||||
inFavorites: boolean;
|
||||
includeCategoryIds: number[];
|
||||
excludeCategoryIds: number[];
|
||||
},
|
||||
@@ -544,7 +259,7 @@ export const searchFiles = async (
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.where((eb) => eb.val(filters.parentId !== "root")) // directory_tree will be empty if parentId is "root"
|
||||
.$if(filters.parentId === "root", (qb) => qb.where((eb) => eb.lit(false))) // directory_tree will be empty if parentId is "root"
|
||||
.$if(filters.parentId !== "root", (qb) => qb.where("id", "=", filters.parentId as number))
|
||||
.unionAll(
|
||||
db
|
||||
@@ -553,6 +268,20 @@ export const searchFiles = async (
|
||||
.select("d.id"),
|
||||
),
|
||||
)
|
||||
.withRecursive("favorite_directory_tree", (db) =>
|
||||
db
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.$if(!filters.inFavorites, (qb) => qb.where((eb) => eb.lit(false))) // favorite_directory_tree will be empty if inFavorites is false
|
||||
.$if(filters.inFavorites, (qb) => qb.where("is_favorite", "=", true))
|
||||
.unionAll((db) =>
|
||||
db
|
||||
.selectFrom("directory as d")
|
||||
.innerJoin("favorite_directory_tree as dt", "d.parent_id", "dt.id")
|
||||
.select("d.id"),
|
||||
),
|
||||
)
|
||||
.withRecursive("include_category_tree", (db) =>
|
||||
db
|
||||
.selectFrom("category")
|
||||
@@ -581,19 +310,31 @@ export const searchFiles = async (
|
||||
)
|
||||
.selectFrom("file")
|
||||
.selectAll("file")
|
||||
.$if(filters.parentId === "root", (qb) => qb.where("user_id", "=", userId)) // directory_tree isn't used if parentId is "root"
|
||||
.where("user_id", "=", userId)
|
||||
.$if(filters.parentId !== "root", (qb) =>
|
||||
qb.where("parent_id", "in", (eb) => eb.selectFrom("directory_tree").select("id")),
|
||||
qb.where((eb) =>
|
||||
eb.exists(eb.selectFrom("directory_tree as dt").whereRef("dt.id", "=", "file.parent_id")),
|
||||
),
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("file_category")
|
||||
.whereRef("file_id", "=", "file.id")
|
||||
.where("category_id", "in", (eb) =>
|
||||
eb.selectFrom("exclude_category_tree").select("id"),
|
||||
),
|
||||
.$if(filters.inFavorites, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.or([
|
||||
eb("is_favorite", "=", true),
|
||||
eb.exists(
|
||||
eb.selectFrom("favorite_directory_tree as dt").whereRef("dt.id", "=", "file.parent_id"),
|
||||
),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.$if(filters.excludeCategoryIds.length > 0, (qb) =>
|
||||
qb.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("file_category")
|
||||
.innerJoin("exclude_category_tree", "category_id", "exclude_category_tree.id")
|
||||
.whereRef("file_id", "=", "file.id"),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -614,23 +355,7 @@ export const searchFiles = async (
|
||||
)
|
||||
.execute()
|
||||
: await baseQuery.execute();
|
||||
return files.map((file) => ({
|
||||
id: file.id,
|
||||
parentId: file.parent_id ?? ("root" as const),
|
||||
userId: file.user_id,
|
||||
path: file.path,
|
||||
mekVersion: file.master_encryption_key_version,
|
||||
encDek: file.encrypted_data_encryption_key,
|
||||
dekVersion: file.data_encryption_key_version,
|
||||
hskVersion: file.hmac_secret_key_version,
|
||||
contentHmac: file.content_hmac,
|
||||
contentType: file.content_type,
|
||||
encContentIv: file.encrypted_content_iv,
|
||||
encContentHash: file.encrypted_content_hash,
|
||||
encName: file.encrypted_name,
|
||||
encCreatedAt: file.encrypted_created_at,
|
||||
encLastModifiedAt: file.encrypted_last_modified_at,
|
||||
}));
|
||||
return files.map(toFile);
|
||||
};
|
||||
|
||||
export const setFileEncName = async (
|
||||
@@ -803,3 +528,36 @@ export const removeFileFromCategory = async (fileId: number, categoryId: number)
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
export const setFileFavorite = async (userId: number, fileId: number, isFavorite: boolean) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const file = await trx
|
||||
.selectFrom("file")
|
||||
.select("is_favorite")
|
||||
.where("id", "=", fileId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!file) {
|
||||
throw new IntegrityError("File not found");
|
||||
} else if (file.is_favorite === isFavorite) {
|
||||
throw new IntegrityError(isFavorite ? "File already favorited" : "File not favorited");
|
||||
}
|
||||
|
||||
await trx
|
||||
.updateTable("file")
|
||||
.set({ is_favorite: isFavorite })
|
||||
.where("id", "=", fileId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
await trx
|
||||
.insertInto("file_log")
|
||||
.values({
|
||||
file_id: fileId,
|
||||
timestamp: new Date(),
|
||||
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
|
||||
})
|
||||
.execute();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as CategoryRepo from "./category";
|
||||
export * as ClientRepo from "./client";
|
||||
export * as DirectoryRepo from "./directory";
|
||||
export * as FileRepo from "./file";
|
||||
export * as HskRepo from "./hsk";
|
||||
export * as MediaRepo from "./media";
|
||||
|
||||
@@ -135,7 +135,7 @@ export const up = async (db: Kysely<any>) => {
|
||||
)
|
||||
.execute();
|
||||
|
||||
// file.ts
|
||||
// directory.ts
|
||||
await db.schema
|
||||
.createTable("directory")
|
||||
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
|
||||
@@ -162,6 +162,8 @@ export const up = async (db: Kysely<any>) => {
|
||||
.addColumn("action", "text", (col) => col.notNull())
|
||||
.addColumn("new_name", "json")
|
||||
.execute();
|
||||
|
||||
// file.ts
|
||||
await db.schema
|
||||
.createTable("file")
|
||||
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
|
||||
|
||||
31
src/lib/server/db/migrations/1768643000-AddFavorites.ts
Normal file
31
src/lib/server/db/migrations/1768643000-AddFavorites.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Kysely } from "kysely";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const up = async (db: Kysely<any>) => {
|
||||
// directory.ts
|
||||
await db.schema
|
||||
.alterTable("directory")
|
||||
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
|
||||
.execute();
|
||||
|
||||
// file.ts
|
||||
await db.schema
|
||||
.alterTable("file")
|
||||
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
|
||||
.execute();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const down = async (db: Kysely<any>) => {
|
||||
await db
|
||||
.deleteFrom("file_log")
|
||||
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
|
||||
.execute();
|
||||
await db
|
||||
.deleteFrom("directory_log")
|
||||
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
|
||||
.execute();
|
||||
|
||||
await db.schema.alterTable("file").dropColumn("is_favorite").execute();
|
||||
await db.schema.alterTable("directory").dropColumn("is_favorite").execute();
|
||||
};
|
||||
@@ -2,10 +2,12 @@ import * as Initial1737357000 from "./1737357000-Initial";
|
||||
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
|
||||
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
|
||||
import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload";
|
||||
import * as AddFavorites1768643000 from "./1768643000-AddFavorites";
|
||||
|
||||
export default {
|
||||
"1737357000-Initial": Initial1737357000,
|
||||
"1737422340-AddFileCategory": AddFileCategory1737422340,
|
||||
"1738409340-AddThumbnail": AddThumbnail1738409340,
|
||||
"1768062380-AddChunkedUpload": AddChunkedUpload1768062380,
|
||||
"1768643000-AddFavorites": AddFavorites1768643000,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Generated } from "kysely";
|
||||
import type { Ciphertext } from "./utils";
|
||||
|
||||
interface CategoryTable {
|
||||
export interface CategoryTable {
|
||||
id: Generated<number>;
|
||||
parent_id: number | null;
|
||||
user_id: number;
|
||||
@@ -11,7 +11,7 @@ interface CategoryTable {
|
||||
encrypted_name: Ciphertext;
|
||||
}
|
||||
|
||||
interface CategoryLogTable {
|
||||
export interface CategoryLogTable {
|
||||
id: Generated<number>;
|
||||
category_id: number;
|
||||
timestamp: Date;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnType, Generated } from "kysely";
|
||||
|
||||
interface ClientTable {
|
||||
export interface ClientTable {
|
||||
id: Generated<number>;
|
||||
encryption_public_key: string; // Base64
|
||||
signature_public_key: string; // Base64
|
||||
@@ -8,13 +8,13 @@ interface ClientTable {
|
||||
|
||||
export type UserClientState = "challenging" | "pending" | "active";
|
||||
|
||||
interface UserClientTable {
|
||||
export interface UserClientTable {
|
||||
user_id: number;
|
||||
client_id: number;
|
||||
state: ColumnType<UserClientState, UserClientState | undefined>;
|
||||
}
|
||||
|
||||
interface UserClientChallengeTable {
|
||||
export interface UserClientChallengeTable {
|
||||
id: Generated<number>;
|
||||
user_id: number;
|
||||
client_id: number;
|
||||
|
||||
28
src/lib/server/db/schema/directory.ts
Normal file
28
src/lib/server/db/schema/directory.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ColumnType, Generated } from "kysely";
|
||||
import type { Ciphertext } from "./utils";
|
||||
|
||||
export interface DirectoryTable {
|
||||
id: Generated<number>;
|
||||
parent_id: number | null;
|
||||
user_id: number;
|
||||
master_encryption_key_version: number;
|
||||
encrypted_data_encryption_key: string; // Base64
|
||||
data_encryption_key_version: Date;
|
||||
encrypted_name: Ciphertext;
|
||||
is_favorite: Generated<boolean>;
|
||||
}
|
||||
|
||||
export interface DirectoryLogTable {
|
||||
id: Generated<number>;
|
||||
directory_id: number;
|
||||
timestamp: ColumnType<Date, Date, never>;
|
||||
action: "create" | "rename" | "add-to-favorites" | "remove-from-favorites";
|
||||
new_name: Ciphertext | null;
|
||||
}
|
||||
|
||||
declare module "./index" {
|
||||
interface Database {
|
||||
directory: DirectoryTable;
|
||||
directory_log: DirectoryLogTable;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,7 @@
|
||||
import type { ColumnType, Generated } from "kysely";
|
||||
import type { Ciphertext } from "./utils";
|
||||
|
||||
interface DirectoryTable {
|
||||
id: Generated<number>;
|
||||
parent_id: number | null;
|
||||
user_id: number;
|
||||
master_encryption_key_version: number;
|
||||
encrypted_data_encryption_key: string; // Base64
|
||||
data_encryption_key_version: Date;
|
||||
encrypted_name: Ciphertext;
|
||||
}
|
||||
|
||||
interface DirectoryLogTable {
|
||||
id: Generated<number>;
|
||||
directory_id: number;
|
||||
timestamp: ColumnType<Date, Date, never>;
|
||||
action: "create" | "rename";
|
||||
new_name: Ciphertext | null;
|
||||
}
|
||||
|
||||
interface FileTable {
|
||||
export interface FileTable {
|
||||
id: Generated<number>;
|
||||
parent_id: number | null;
|
||||
user_id: number;
|
||||
@@ -35,26 +17,32 @@ interface FileTable {
|
||||
encrypted_name: Ciphertext;
|
||||
encrypted_created_at: Ciphertext | null;
|
||||
encrypted_last_modified_at: Ciphertext;
|
||||
is_favorite: Generated<boolean>;
|
||||
}
|
||||
|
||||
interface FileLogTable {
|
||||
export interface FileLogTable {
|
||||
id: Generated<number>;
|
||||
file_id: number;
|
||||
timestamp: ColumnType<Date, Date, never>;
|
||||
action: "create" | "rename" | "migrate" | "add-to-category" | "remove-from-category";
|
||||
action:
|
||||
| "create"
|
||||
| "rename"
|
||||
| "migrate"
|
||||
| "add-to-category"
|
||||
| "remove-from-category"
|
||||
| "add-to-favorites"
|
||||
| "remove-from-favorites";
|
||||
new_name: Ciphertext | null;
|
||||
category_id: number | null;
|
||||
}
|
||||
|
||||
interface FileCategoryTable {
|
||||
export interface FileCategoryTable {
|
||||
file_id: number;
|
||||
category_id: number;
|
||||
}
|
||||
|
||||
declare module "./index" {
|
||||
interface Database {
|
||||
directory: DirectoryTable;
|
||||
directory_log: DirectoryLogTable;
|
||||
file: FileTable;
|
||||
file_log: FileLogTable;
|
||||
file_category: FileCategoryTable;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ColumnType, Generated } from "kysely";
|
||||
|
||||
export type HskState = "active";
|
||||
|
||||
interface HskTable {
|
||||
export interface HskTable {
|
||||
user_id: number;
|
||||
version: number;
|
||||
state: HskState;
|
||||
@@ -10,7 +10,7 @@ interface HskTable {
|
||||
encrypted_key: string; // Base64
|
||||
}
|
||||
|
||||
interface HskLogTable {
|
||||
export interface HskLogTable {
|
||||
id: Generated<number>;
|
||||
user_id: number;
|
||||
hmac_secret_key_version: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./category";
|
||||
export * from "./client";
|
||||
export * from "./directory";
|
||||
export * from "./file";
|
||||
export * from "./hsk";
|
||||
export * from "./media";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
interface ThumbnailTable {
|
||||
export interface ThumbnailTable {
|
||||
id: Generated<number>;
|
||||
directory_id: number | null;
|
||||
file_id: number | null;
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { ColumnType, Generated } from "kysely";
|
||||
|
||||
export type MekState = "active" | "retired" | "dead";
|
||||
|
||||
interface MekTable {
|
||||
export interface MekTable {
|
||||
user_id: number;
|
||||
version: number;
|
||||
state: MekState;
|
||||
}
|
||||
|
||||
interface MekLogTable {
|
||||
export interface MekLogTable {
|
||||
id: Generated<number>;
|
||||
user_id: number;
|
||||
master_encryption_key_version: number;
|
||||
@@ -17,7 +17,7 @@ interface MekLogTable {
|
||||
action_by: number | null;
|
||||
}
|
||||
|
||||
interface ClientMekTable {
|
||||
export interface ClientMekTable {
|
||||
user_id: number;
|
||||
client_id: number;
|
||||
version: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ColumnType, Generated } from "kysely";
|
||||
|
||||
interface SessionTable {
|
||||
export interface SessionTable {
|
||||
id: string;
|
||||
user_id: number;
|
||||
client_id: number | null;
|
||||
@@ -10,7 +10,7 @@ interface SessionTable {
|
||||
last_used_by_agent: string | null;
|
||||
}
|
||||
|
||||
interface SessionUpgradeChallengeTable {
|
||||
export interface SessionUpgradeChallengeTable {
|
||||
id: Generated<number>;
|
||||
session_id: string;
|
||||
client_id: number;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Generated } from "kysely";
|
||||
import type { Ciphertext } from "./utils";
|
||||
|
||||
interface UploadSessionTable {
|
||||
export interface UploadSessionTable {
|
||||
id: string;
|
||||
type: "file" | "thumbnail" | "migration";
|
||||
user_id: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
interface UserTable {
|
||||
export interface UserTable {
|
||||
id: Generated<number>;
|
||||
email: string;
|
||||
nickname: string;
|
||||
|
||||
1
src/lib/types/utils.d.ts
vendored
Normal file
1
src/lib/types/utils.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
type AllUndefined<T> = { [K in keyof T]?: undefined };
|
||||
@@ -18,6 +18,7 @@
|
||||
requestThumbnailUpload,
|
||||
requestFileAdditionToCategory,
|
||||
requestVideoStream,
|
||||
requestFavoriteToggle,
|
||||
} from "./service";
|
||||
import TopBarMenu from "./TopBarMenu.svelte";
|
||||
|
||||
@@ -75,6 +76,11 @@
|
||||
void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
};
|
||||
|
||||
const toggleFavorite = async () => {
|
||||
await requestFavoriteToggle(data.id, !!info?.isFavorite);
|
||||
void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => {
|
||||
if (data.id === result.id) {
|
||||
@@ -144,13 +150,13 @@
|
||||
<div onclick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onclick={() => (isMenuOpen = !isMenuOpen)}
|
||||
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
|
||||
class="w-full rounded-full p-1 text-2xl active:bg-black active:bg-opacity-[0.04]"
|
||||
>
|
||||
<IconMoreVert class="text-2xl" />
|
||||
<IconMoreVert />
|
||||
</button>
|
||||
<TopBarMenu
|
||||
bind:isOpen={isMenuOpen}
|
||||
directoryId={["category", "gallery", "search"].includes(
|
||||
directoryId={["category", "gallery", "search", "favorite"].includes(
|
||||
page.url.searchParams.get("from") ?? "",
|
||||
)
|
||||
? info?.parentId
|
||||
@@ -158,6 +164,8 @@
|
||||
{fileBlob}
|
||||
downloadUrl={videoStreamUrl}
|
||||
filename={info?.name}
|
||||
isFavorite={info?.isFavorite}
|
||||
onToggleFavorite={toggleFavorite}
|
||||
/>
|
||||
</div>
|
||||
</TopBar>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
import { fly } from "svelte/transition";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import IconFavorite from "~icons/material-symbols/favorite";
|
||||
import IconFavoriteOutline from "~icons/material-symbols/favorite-outline";
|
||||
import IconFolderOpen from "~icons/material-symbols/folder-open";
|
||||
import IconCloudDownload from "~icons/material-symbols/cloud-download";
|
||||
|
||||
@@ -13,10 +15,20 @@
|
||||
downloadUrl?: string;
|
||||
fileBlob?: Blob;
|
||||
filename?: string;
|
||||
isFavorite?: boolean;
|
||||
isOpen: boolean;
|
||||
onToggleFavorite?: () => void;
|
||||
}
|
||||
|
||||
let { directoryId, downloadUrl, fileBlob, filename, isOpen = $bindable() }: Props = $props();
|
||||
let {
|
||||
directoryId,
|
||||
downloadUrl,
|
||||
fileBlob,
|
||||
filename,
|
||||
isFavorite,
|
||||
isOpen = $bindable(),
|
||||
onToggleFavorite,
|
||||
}: Props = $props();
|
||||
|
||||
const handleDownload = () => {
|
||||
if (fileBlob && filename) {
|
||||
@@ -34,7 +46,7 @@
|
||||
|
||||
{#if isOpen && (directoryId || downloadUrl || fileBlob)}
|
||||
<div
|
||||
class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
|
||||
class="absolute right-2 top-full z-20 min-w-44 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
|
||||
transition:fly={{ y: -8, duration: 200 }}
|
||||
>
|
||||
<p class="px-3 pt-2 text-sm font-semibold text-gray-600">더보기</p>
|
||||
@@ -54,6 +66,13 @@
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if isFavorite !== undefined}
|
||||
{@render menuButton(
|
||||
isFavorite ? IconFavorite : IconFavoriteOutline,
|
||||
isFavorite ? "즐겨찾기 해제" : "즐겨찾기",
|
||||
onToggleFavorite ?? (() => {}),
|
||||
)}
|
||||
{/if}
|
||||
{#if directoryId}
|
||||
{@render menuButton(IconFolderOpen, "폴더에서 보기", () =>
|
||||
goto(
|
||||
|
||||
@@ -48,3 +48,17 @@ export const requestFileAdditionToCategory = async (fileId: number, categoryId:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFavoriteToggle = async (fileId: number, isFavorite: boolean) => {
|
||||
try {
|
||||
if (isFavorite) {
|
||||
await trpc().favorites.removeFile.mutate({ id: fileId });
|
||||
} else {
|
||||
await trpc().favorites.addFile.mutate({ id: fileId });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
includeImages: boolean;
|
||||
includeVideos: boolean;
|
||||
includeDirectories: boolean;
|
||||
searchInFavorites: boolean;
|
||||
searchInDirectory: boolean;
|
||||
categories: SearchFilter["categories"];
|
||||
}
|
||||
@@ -37,6 +38,7 @@
|
||||
includeVideos: false,
|
||||
includeDirectories: false,
|
||||
searchInDirectory: false,
|
||||
searchInFavorites: false,
|
||||
categories: [],
|
||||
});
|
||||
let hasCategoryFilter = $derived(filters.categories.length > 0);
|
||||
@@ -81,7 +83,7 @@
|
||||
|
||||
return sortEntries(
|
||||
[...directories, ...files].filter(
|
||||
({ name }) => !nameFilter || searchString(name, nameFilter),
|
||||
(entry) => !nameFilter || searchString(entry.name, nameFilter),
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -118,7 +120,7 @@
|
||||
},
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
$effect.pre(() => {
|
||||
if (data.directoryId) {
|
||||
HybridPromise.resolve(getDirectoryInfo(data.directoryId, $masterKeyStore?.get(1)?.key!)).then(
|
||||
(res) => {
|
||||
@@ -132,10 +134,15 @@
|
||||
}
|
||||
});
|
||||
|
||||
$effect.pre(() => {
|
||||
filters.searchInFavorites = data.fromFavorites;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Svelte sucks
|
||||
hasAnyFilter;
|
||||
filters.searchInDirectory;
|
||||
filters.searchInFavorites;
|
||||
filters.categories.length;
|
||||
|
||||
if (untrack(() => isRestoredFromSnapshot)) {
|
||||
@@ -147,6 +154,7 @@
|
||||
requestSearch(
|
||||
{
|
||||
ancestorId: filters.searchInDirectory ? data.directoryId! : "root",
|
||||
inFavorites: filters.searchInFavorites,
|
||||
categories: filters.categories,
|
||||
},
|
||||
$masterKeyStore?.get(1)?.key!,
|
||||
@@ -172,6 +180,7 @@
|
||||
{#if !hasCategoryFilter}
|
||||
<Chip bind:selected={filters.includeDirectories}>폴더</Chip>
|
||||
{/if}
|
||||
<Chip bind:selected={filters.searchInFavorites}>즐겨찾기</Chip>
|
||||
{#if directoryInfo?.exists}
|
||||
<Chip bind:selected={filters.searchInDirectory}>
|
||||
위치: {directoryInfo.name}
|
||||
@@ -206,7 +215,12 @@
|
||||
</div>
|
||||
{#if hasAnyFilter}
|
||||
<div class="flex flex-grow flex-col space-y-2 bg-white p-4">
|
||||
<p class="text-lg font-bold text-gray-800">검색 결과</p>
|
||||
<p class="text-lg font-bold text-gray-800">
|
||||
검색 결과
|
||||
{#if result.length > 0}
|
||||
{" "}({result.length}개)
|
||||
{/if}
|
||||
</p>
|
||||
{#if result.length > 0}
|
||||
<RowVirtualizer
|
||||
count={result.length}
|
||||
|
||||
@@ -4,15 +4,18 @@ import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = ({ url }) => {
|
||||
const directoryId = url.searchParams.get("directoryId");
|
||||
const from = url.searchParams.get("from");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
directoryId: z.coerce.number().int().positive().nullable(),
|
||||
from: z.enum(["favorites"]).nullable(),
|
||||
})
|
||||
.safeParse({ directoryId });
|
||||
.safeParse({ directoryId, from });
|
||||
if (!zodRes.success) error(400, "Invalid query parameters");
|
||||
|
||||
return {
|
||||
directoryId: zodRes.data.directoryId,
|
||||
fromFavorites: zodRes.data.from === "favorites",
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
</script>
|
||||
|
||||
<ActionEntryButton class="h-14" {onclick}>
|
||||
<DirectoryEntryLabel type="directory" name={info.name} />
|
||||
<DirectoryEntryLabel type="directory" name={info.name} isFavorite={info.isFavorite} />
|
||||
</ActionEntryButton>
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
thumbnail={$thumbnail}
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
isFavorite={info.isFavorite}
|
||||
/>
|
||||
</ActionEntryButton>
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
|
||||
import IconArrowBack from "~icons/material-symbols/arrow-back";
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
value: string;
|
||||
}
|
||||
|
||||
let { class: className, value = $bindable() }: Props = $props();
|
||||
let { value = $bindable() }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={["sticky top-0 z-10 flex items-center gap-x-2 px-2 py-3 backdrop-blur-2xl", className]}>
|
||||
<div class={"sticky top-0 z-10 flex items-center gap-x-2 px-2 py-3 backdrop-blur-2xl"}>
|
||||
<button
|
||||
onclick={() => history.back()}
|
||||
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
|
||||
@@ -22,6 +19,6 @@
|
||||
bind:value
|
||||
type="text"
|
||||
placeholder="검색"
|
||||
class="mr-1 h-[2.3rem] flex-grow rounded-lg bg-gray-100 px-3 py-1.5 placeholder-gray-600 focus:outline-none"
|
||||
class="mr-1 h-[2.3rem] min-w-0 flex-grow rounded-lg bg-gray-100 px-3 py-1.5 placeholder-gray-600 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
decryptDirectoryMetadata,
|
||||
decryptFileMetadata,
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
type LocalDirectoryInfo,
|
||||
@@ -12,6 +10,7 @@ import { trpc } from "$trpc/client";
|
||||
|
||||
export interface SearchFilter {
|
||||
ancestorId: DirectoryId;
|
||||
inFavorites: boolean;
|
||||
categories: { info: LocalCategoryInfo; type: "include" | "exclude" }[];
|
||||
}
|
||||
|
||||
@@ -23,6 +22,7 @@ export interface SearchResult {
|
||||
export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey) => {
|
||||
const { directories: directoriesRaw, files: filesRaw } = await trpc().search.search.query({
|
||||
ancestor: filter.ancestorId,
|
||||
inFavorites: filter.inFavorites,
|
||||
includeCategories: filter.categories
|
||||
.filter(({ type }) => type === "include")
|
||||
.map(({ info }) => info.id),
|
||||
@@ -30,47 +30,14 @@ export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey)
|
||||
.filter(({ type }) => type === "exclude")
|
||||
.map(({ info }) => info.id),
|
||||
});
|
||||
|
||||
const [directories, files] = await HybridPromise.all([
|
||||
HybridPromise.all(
|
||||
directoriesRaw.map((directory) =>
|
||||
HybridPromise.resolve(
|
||||
getDirectoryInfo(directory.id, masterKey, {
|
||||
async fetchFromServer(id, cachedInfo, masterKey) {
|
||||
const metadata = await decryptDirectoryMetadata(directory, masterKey);
|
||||
return {
|
||||
subDirectories: [],
|
||||
files: [],
|
||||
...cachedInfo,
|
||||
id: id as number,
|
||||
exists: true,
|
||||
parentId: directory.parent,
|
||||
...metadata,
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
getDirectoryInfo(directory.id, masterKey, { serverResponse: directory }),
|
||||
),
|
||||
),
|
||||
HybridPromise.all(
|
||||
filesRaw.map((file) =>
|
||||
HybridPromise.resolve(
|
||||
getFileInfo(file.id, masterKey, {
|
||||
async fetchFromServer(id, cachedInfo, masterKey) {
|
||||
const metadata = await decryptFileMetadata(file, masterKey);
|
||||
return {
|
||||
categories: [],
|
||||
...cachedInfo,
|
||||
id: id as number,
|
||||
exists: true,
|
||||
parentId: file.parent,
|
||||
contentType: file.contentType,
|
||||
...metadata,
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
filesRaw.map((file) => getFileInfo(file.id, masterKey, { serverResponse: file })),
|
||||
),
|
||||
]);
|
||||
return { directories, files } as SearchResult;
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { limitFunction } from "p-limit";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { CHUNK_SIZE } from "$lib/constants";
|
||||
import {
|
||||
decryptFileMetadata,
|
||||
getFileInfo,
|
||||
type FileInfo,
|
||||
type MaybeFileInfo,
|
||||
} from "$lib/modules/filesystem";
|
||||
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||
import { uploadBlob } from "$lib/modules/upload";
|
||||
import { requestFileDownload } from "$lib/services/file";
|
||||
import { HybridPromise, Scheduler } from "$lib/utils";
|
||||
@@ -35,28 +30,9 @@ export const requestLegacyFiles = async (
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const files = await HybridPromise.all(
|
||||
filesRaw.map((file) =>
|
||||
HybridPromise.resolve(
|
||||
getFileInfo(file.id, masterKey, {
|
||||
async fetchFromServer(id, cachedInfo, masterKey) {
|
||||
const metadata = await decryptFileMetadata(file, masterKey);
|
||||
return {
|
||||
categories: [],
|
||||
...cachedInfo,
|
||||
id: id as number,
|
||||
exists: true,
|
||||
isLegacy: file.isLegacy,
|
||||
parentId: file.parent,
|
||||
contentType: file.contentType,
|
||||
...metadata,
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
filesRaw.map((file) => getFileInfo(file.id, masterKey, { serverResponse: file })),
|
||||
);
|
||||
|
||||
return files as MaybeFileInfo[];
|
||||
return files;
|
||||
};
|
||||
|
||||
const createState = (status: MigrationStatus): MigrationState => {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { limitFunction } from "p-limit";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||
import {
|
||||
decryptFileMetadata,
|
||||
getFileInfo,
|
||||
type FileInfo,
|
||||
type MaybeFileInfo,
|
||||
} from "$lib/modules/filesystem";
|
||||
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||
import { generateThumbnail } from "$lib/modules/thumbnail";
|
||||
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
|
||||
import { HybridPromise, Scheduler } from "$lib/utils";
|
||||
@@ -40,28 +35,9 @@ export const requestMissingThumbnailFiles = async (
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const files = await HybridPromise.all(
|
||||
filesRaw.map((file) =>
|
||||
HybridPromise.resolve(
|
||||
getFileInfo(file.id, masterKey, {
|
||||
async fetchFromServer(id, cachedInfo, masterKey) {
|
||||
const metadata = await decryptFileMetadata(file, masterKey);
|
||||
return {
|
||||
categories: [],
|
||||
...cachedInfo,
|
||||
id: id as number,
|
||||
exists: true,
|
||||
isLegacy: file.isLegacy,
|
||||
parentId: file.parent,
|
||||
contentType: file.contentType,
|
||||
...metadata,
|
||||
};
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
filesRaw.map((file) => getFileInfo(file.id, masterKey, { serverResponse: file })),
|
||||
);
|
||||
|
||||
return files as MaybeFileInfo[];
|
||||
return files;
|
||||
};
|
||||
|
||||
const requestThumbnailUpload = limitFunction(
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
const pages = [
|
||||
{ path: "/home", label: "홈", icon: IconHome },
|
||||
{ path: "/directory", label: "파일", icon: IconFolder },
|
||||
{ path: "/favorite", label: "즐겨찾기", icon: IconFavorite },
|
||||
{ path: "/favorites", label: "즐겨찾기", icon: IconFavorite },
|
||||
{ path: "/category", label: "카테고리", icon: IconCategory },
|
||||
{ path: "/menu", label: "전체", icon: IconMenu },
|
||||
];
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import { getFileThumbnail } from "$lib/modules/file";
|
||||
import type { CategoryFileInfo } from "$lib/modules/filesystem";
|
||||
import { formatDateTime } from "$lib/utils";
|
||||
import type { SelectedFile } from "./service.svelte";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
@@ -19,10 +20,16 @@
|
||||
</script>
|
||||
|
||||
<ActionEntryButton
|
||||
class="h-12"
|
||||
class="h-14"
|
||||
onclick={() => onclick(info)}
|
||||
actionButtonIcon={onRemoveClick && IconClose}
|
||||
onActionButtonClick={() => onRemoveClick?.(info)}
|
||||
>
|
||||
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
|
||||
<DirectoryEntryLabel
|
||||
type="file"
|
||||
thumbnail={$thumbnail}
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
isFavorite={info.isFavorite}
|
||||
/>
|
||||
</ActionEntryButton>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
requestFileUpload,
|
||||
requestEntryRename,
|
||||
requestEntryDeletion,
|
||||
requestFavoriteToggle,
|
||||
} from "./service.svelte";
|
||||
|
||||
import IconSearch from "~icons/material-symbols/search";
|
||||
@@ -45,7 +46,7 @@
|
||||
let isEntryDeleteModalOpen = $state(false);
|
||||
|
||||
let showParentEntry = $derived(
|
||||
["file", "search"].includes(page.url.searchParams.get("from") ?? ""),
|
||||
["file", "search", "favorite"].includes(page.url.searchParams.get("from") ?? ""),
|
||||
);
|
||||
let showBackButton = $derived(data.id !== "root" || showParentEntry);
|
||||
|
||||
@@ -102,7 +103,7 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>파일</title>
|
||||
<title>내 파일</title>
|
||||
</svelte:head>
|
||||
|
||||
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
|
||||
@@ -194,6 +195,12 @@
|
||||
isEntryMenuBottomSheetOpen = false;
|
||||
isEntryDeleteModalOpen = true;
|
||||
}}
|
||||
onFavoriteClick={async () => {
|
||||
if (await requestFavoriteToggle(context.selectedEntry!)) {
|
||||
isEntryMenuBottomSheetOpen = false;
|
||||
void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<EntryRenameModal
|
||||
bind:isOpen={isEntryRenameModalOpen}
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
let thumbnail = $derived(getFileThumbnail(info));
|
||||
|
||||
const action = (callback: typeof onclick) => {
|
||||
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
|
||||
callback({
|
||||
type: "file",
|
||||
id: info.id,
|
||||
dataKey: info.dataKey,
|
||||
name: info.name,
|
||||
isFavorite: info.isFavorite,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -34,5 +40,6 @@
|
||||
thumbnail={$thumbnail}
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
isFavorite={info.isFavorite}
|
||||
/>
|
||||
</ActionEntryButton>
|
||||
|
||||
@@ -15,7 +15,13 @@
|
||||
let { info, onclick, onOpenMenuClick }: Props = $props();
|
||||
|
||||
const action = (callback: typeof onclick) => {
|
||||
callback({ type: "directory", id: info.id, dataKey: info.dataKey, name: info.name });
|
||||
callback({
|
||||
type: "directory",
|
||||
id: info.id,
|
||||
dataKey: info.dataKey,
|
||||
name: info.name,
|
||||
isFavorite: info.isFavorite,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -25,5 +31,5 @@
|
||||
actionButtonIcon={IconMoreVert}
|
||||
onActionButtonClick={() => action(onOpenMenuClick)}
|
||||
>
|
||||
<DirectoryEntryLabel type="directory" name={info.name} />
|
||||
<DirectoryEntryLabel type="directory" name={info.name} isFavorite={info.isFavorite} />
|
||||
</ActionEntryButton>
|
||||
|
||||
@@ -3,24 +3,35 @@
|
||||
import { DirectoryEntryLabel, IconEntryButton } from "$lib/components/molecules";
|
||||
import { useContext } from "./service.svelte";
|
||||
|
||||
import IconFavorite from "~icons/material-symbols/favorite";
|
||||
import IconFavoriteBorder from "~icons/material-symbols/favorite-outline";
|
||||
import IconEdit from "~icons/material-symbols/edit";
|
||||
import IconDelete from "~icons/material-symbols/delete";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onDeleteClick: () => void;
|
||||
onFavoriteClick: () => void;
|
||||
onRenameClick: () => void;
|
||||
}
|
||||
|
||||
let { isOpen = $bindable(), onDeleteClick, onRenameClick }: Props = $props();
|
||||
let { isOpen = $bindable(), onDeleteClick, onFavoriteClick, onRenameClick }: Props = $props();
|
||||
let context = useContext();
|
||||
</script>
|
||||
|
||||
{#if context.selectedEntry}
|
||||
{@const { name, type } = context.selectedEntry}
|
||||
{@const { name, type, isFavorite } = context.selectedEntry}
|
||||
<BottomSheet bind:isOpen class="p-4">
|
||||
<DirectoryEntryLabel {type} {name} class="h-12 p-2" textClass="!font-semibold" />
|
||||
<div class="my-2 h-px w-full bg-gray-200"></div>
|
||||
<IconEntryButton
|
||||
icon={isFavorite ? IconFavorite : IconFavoriteBorder}
|
||||
onclick={onFavoriteClick}
|
||||
class="h-12 w-full"
|
||||
iconClass={isFavorite ? "text-red-500" : ""}
|
||||
>
|
||||
{isFavorite ? "즐겨찾기에서 해제하기" : "즐겨찾기에 추가하기"}
|
||||
</IconEntryButton>
|
||||
<IconEntryButton icon={IconEdit} onclick={onRenameClick} class="h-12 w-full">
|
||||
이름 바꾸기
|
||||
</IconEntryButton>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface SelectedEntry {
|
||||
id: number;
|
||||
dataKey: DataKey | undefined;
|
||||
name: string;
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
export const createContext = () => {
|
||||
@@ -149,3 +150,25 @@ export const requestEntryDeletion = async (entry: SelectedEntry) => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFavoriteToggle = async (entry: SelectedEntry) => {
|
||||
try {
|
||||
if (entry.type === "directory") {
|
||||
if (entry.isFavorite) {
|
||||
await trpc().favorites.removeDirectory.mutate({ id: entry.id });
|
||||
} else {
|
||||
await trpc().favorites.addDirectory.mutate({ id: entry.id });
|
||||
}
|
||||
} else {
|
||||
if (entry.isFavorite) {
|
||||
await trpc().favorites.removeFile.mutate({ id: entry.id });
|
||||
} else {
|
||||
await trpc().favorites.addFile.mutate({ id: entry.id });
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<div class="flex h-full items-center justify-center p-4">
|
||||
<p class="text-gray-500">아직 개발 중이에요.</p>
|
||||
</div>
|
||||
7
src/routes/(main)/favorites/+page.server.ts
Normal file
7
src/routes/(main)/favorites/+page.server.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createCaller } from "$trpc/router.server";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const favorites = await createCaller(event).favorites.get();
|
||||
return { favorites };
|
||||
};
|
||||
93
src/routes/(main)/favorites/+page.svelte
Normal file
93
src/routes/(main)/favorites/+page.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { RowVirtualizer } from "$lib/components/atoms";
|
||||
import { TopBar } from "$lib/components/molecules";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
import Directory from "./Directory.svelte";
|
||||
import File from "./File.svelte";
|
||||
import { requestFavoriteEntries, requestRemoveFavorite, type FavoriteEntry } from "./service";
|
||||
|
||||
import IconSearch from "~icons/material-symbols/search";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let entries: FavoriteEntry[] = $state([]);
|
||||
let isLoading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
const masterKey = $masterKeyStore?.get(1)?.key;
|
||||
if (masterKey) {
|
||||
entries = await requestFavoriteEntries(data.favorites, masterKey);
|
||||
}
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
const handleRemove = async (entry: FavoriteEntry) => {
|
||||
if (await requestRemoveFavorite(entry.type, entry.details.id)) {
|
||||
entries = entries.filter(
|
||||
(e) => !(e.type === entry.type && e.details.id === entry.details.id),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = (entry: FavoriteEntry) => {
|
||||
goto(
|
||||
entry.type === "file"
|
||||
? `/file/${entry.details.id}?from=favorite`
|
||||
: `/directory/${entry.details.id}?from=favorite`,
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>즐겨찾기</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<TopBar title="즐겨찾기" showBackButton={false}>
|
||||
<button
|
||||
onclick={() => goto("/search?from=favorites")}
|
||||
class="w-full rounded-full p-1 text-2xl active:bg-black active:bg-opacity-[0.04]"
|
||||
>
|
||||
<IconSearch />
|
||||
</button>
|
||||
</TopBar>
|
||||
<div class="flex flex-grow flex-col px-4 pb-4">
|
||||
{#if entries.length > 0}
|
||||
<RowVirtualizer
|
||||
count={entries.length}
|
||||
getItemKey={(index) => `${entries[index]!.type}-${entries[index]!.details.id}`}
|
||||
estimateItemHeight={() => 56}
|
||||
itemGap={4}
|
||||
>
|
||||
{#snippet item(index)}
|
||||
{@const entry = entries[index]!}
|
||||
{#if entry.type === "directory"}
|
||||
<Directory
|
||||
info={entry.details}
|
||||
onclick={() => handleClick(entry)}
|
||||
onRemoveClick={() => handleRemove(entry)}
|
||||
/>
|
||||
{:else}
|
||||
<File
|
||||
info={entry.details}
|
||||
onclick={() => handleClick(entry)}
|
||||
onRemoveClick={() => handleRemove(entry)}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</RowVirtualizer>
|
||||
{:else}
|
||||
<div class="flex flex-grow items-center justify-center">
|
||||
<p class="text-gray-500">
|
||||
{#if isLoading}
|
||||
즐겨찾기 목록을 불러오고 있어요.
|
||||
{:else}
|
||||
즐겨찾기한 항목이 없어요.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
24
src/routes/(main)/favorites/Directory.svelte
Normal file
24
src/routes/(main)/favorites/Directory.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "$lib/components/atoms";
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import type { SubDirectoryInfo } from "$lib/modules/filesystem";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
|
||||
interface Props {
|
||||
info: SubDirectoryInfo;
|
||||
onclick: () => void;
|
||||
onRemoveClick: () => void;
|
||||
}
|
||||
|
||||
let { info, onclick, onRemoveClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<ActionEntryButton
|
||||
class="h-14"
|
||||
{onclick}
|
||||
actionButtonIcon={IconClose}
|
||||
onActionButtonClick={onRemoveClick}
|
||||
>
|
||||
<DirectoryEntryLabel type="directory" name={info.name} isFavorite />
|
||||
</ActionEntryButton>
|
||||
34
src/routes/(main)/favorites/File.svelte
Normal file
34
src/routes/(main)/favorites/File.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "$lib/components/atoms";
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import { getFileThumbnail } from "$lib/modules/file";
|
||||
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||
import { formatDateTime } from "$lib/utils";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
|
||||
interface Props {
|
||||
info: SummarizedFileInfo;
|
||||
onclick: () => void;
|
||||
onRemoveClick: () => void;
|
||||
}
|
||||
|
||||
let { info, onclick, onRemoveClick }: Props = $props();
|
||||
|
||||
let thumbnail = $derived(getFileThumbnail(info));
|
||||
</script>
|
||||
|
||||
<ActionEntryButton
|
||||
class="h-14"
|
||||
{onclick}
|
||||
actionButtonIcon={IconClose}
|
||||
onActionButtonClick={onRemoveClick}
|
||||
>
|
||||
<DirectoryEntryLabel
|
||||
type="file"
|
||||
thumbnail={$thumbnail}
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
isFavorite
|
||||
/>
|
||||
</ActionEntryButton>
|
||||
68
src/routes/(main)/favorites/service.ts
Normal file
68
src/routes/(main)/favorites/service.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
type SummarizedFileInfo,
|
||||
type SubDirectoryInfo,
|
||||
type LocalDirectoryInfo,
|
||||
} from "$lib/modules/filesystem";
|
||||
import { HybridPromise, sortEntries } from "$lib/utils";
|
||||
import { trpc } from "$trpc/client";
|
||||
import type { RouterOutputs } from "$trpc/router.server";
|
||||
|
||||
export type FavoriteEntry =
|
||||
| { type: "directory"; name: string; details: SubDirectoryInfo }
|
||||
| { type: "file"; name: string; details: SummarizedFileInfo };
|
||||
|
||||
export const requestFavoriteEntries = async (
|
||||
favorites: RouterOutputs["favorites"]["get"],
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const [directories, files] = await HybridPromise.all([
|
||||
HybridPromise.all(
|
||||
favorites.directories.map((directory) =>
|
||||
getDirectoryInfo(directory.id, masterKey, {
|
||||
serverResponse: { ...directory, isFavorite: true },
|
||||
}),
|
||||
),
|
||||
),
|
||||
HybridPromise.all(
|
||||
favorites.files.map((file) =>
|
||||
getFileInfo(file.id, masterKey, { serverResponse: { ...file, isFavorite: true } }),
|
||||
),
|
||||
),
|
||||
]);
|
||||
return [
|
||||
...sortEntries(
|
||||
directories.map(
|
||||
(directory): FavoriteEntry => ({
|
||||
type: "directory",
|
||||
name: directory.name!,
|
||||
details: directory as LocalDirectoryInfo,
|
||||
}),
|
||||
),
|
||||
),
|
||||
...sortEntries(
|
||||
files.map(
|
||||
(file): FavoriteEntry => ({
|
||||
type: "file",
|
||||
name: file.name!,
|
||||
details: file as SummarizedFileInfo,
|
||||
}),
|
||||
),
|
||||
),
|
||||
];
|
||||
};
|
||||
|
||||
export const requestRemoveFavorite = async (type: "file" | "directory", id: number) => {
|
||||
try {
|
||||
if (type === "directory") {
|
||||
await trpc().favorites.removeDirectory.mutate({ id });
|
||||
} else {
|
||||
await trpc().favorites.removeFile.mutate({ id });
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -62,9 +62,7 @@ const streamFromServer = async (
|
||||
) => {
|
||||
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
|
||||
const start = range?.start ?? 0;
|
||||
const end =
|
||||
range?.end ??
|
||||
(range && !metadata.isLegacy ? Math.min(start + CHUNK_SIZE, totalSize) : totalSize) - 1;
|
||||
const end = range?.end ?? totalSize - 1;
|
||||
if (start > end || start < 0 || end >= totalSize) {
|
||||
return new Response("Invalid range", { status: 416 });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
categoryRouter,
|
||||
clientRouter,
|
||||
directoryRouter,
|
||||
favoritesRouter,
|
||||
fileRouter,
|
||||
hskRouter,
|
||||
mekRouter,
|
||||
@@ -19,6 +20,7 @@ export const appRouter = router({
|
||||
category: categoryRouter,
|
||||
client: clientRouter,
|
||||
directory: directoryRouter,
|
||||
favorites: favoritesRouter,
|
||||
file: fileRouter,
|
||||
hsk: hskRouter,
|
||||
mek: mekRouter,
|
||||
|
||||
@@ -57,6 +57,7 @@ const categoryRouter = router({
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
isRecursive: file.isRecursive,
|
||||
})),
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { DirectoryIdSchema } from "$lib/schemas";
|
||||
import { FileRepo, IntegrityError } from "$lib/server/db";
|
||||
import { DirectoryRepo, FileRepo, IntegrityError } from "$lib/server/db";
|
||||
import { safeUnlink } from "$lib/server/modules/filesystem";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
|
||||
@@ -14,13 +14,15 @@ const directoryRouter = router({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const directory =
|
||||
input.id !== "root" ? await FileRepo.getDirectory(ctx.session.userId, input.id) : undefined;
|
||||
input.id !== "root"
|
||||
? await DirectoryRepo.getDirectory(ctx.session.userId, input.id)
|
||||
: undefined;
|
||||
if (directory === null) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
}
|
||||
|
||||
const [directories, files] = await Promise.all([
|
||||
FileRepo.getAllDirectoriesByParent(ctx.session.userId, input.id),
|
||||
DirectoryRepo.getAllDirectoriesByParent(ctx.session.userId, input.id),
|
||||
FileRepo.getAllFilesByParent(ctx.session.userId, input.id),
|
||||
]);
|
||||
return {
|
||||
@@ -31,6 +33,7 @@ const directoryRouter = router({
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
isFavorite: directory.isFavorite,
|
||||
},
|
||||
subDirectories: directories.map((directory) => ({
|
||||
id: directory.id,
|
||||
@@ -39,6 +42,7 @@ const directoryRouter = router({
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
isFavorite: directory.isFavorite,
|
||||
})),
|
||||
files: files.map((file) => ({
|
||||
id: file.id,
|
||||
@@ -52,6 +56,7 @@ const directoryRouter = router({
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
@@ -75,7 +80,7 @@ const directoryRouter = router({
|
||||
}
|
||||
|
||||
try {
|
||||
await FileRepo.registerDirectory({
|
||||
await DirectoryRepo.registerDirectory({
|
||||
parentId: input.parent,
|
||||
userId: ctx.session.userId,
|
||||
mekVersion: input.mekVersion,
|
||||
@@ -102,7 +107,7 @@ const directoryRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await FileRepo.setDirectoryEncName(ctx.session.userId, input.id, input.dekVersion, {
|
||||
await DirectoryRepo.setDirectoryEncName(ctx.session.userId, input.id, input.dekVersion, {
|
||||
ciphertext: input.name,
|
||||
iv: input.nameIv,
|
||||
});
|
||||
@@ -126,7 +131,7 @@ const directoryRouter = router({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
const files = await FileRepo.unregisterDirectory(ctx.session.userId, input.id);
|
||||
const files = await DirectoryRepo.unregisterDirectory(ctx.session.userId, input.id);
|
||||
return {
|
||||
deletedFiles: files.map((file) => {
|
||||
safeUnlink(file.path); // Intended
|
||||
|
||||
124
src/trpc/routers/favorites.ts
Normal file
124
src/trpc/routers/favorites.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { DirectoryRepo, FileRepo, IntegrityError } from "$lib/server/db";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
|
||||
const favoritesRouter = router({
|
||||
get: roleProcedure["activeClient"].query(async ({ ctx }) => {
|
||||
const [directories, files] = await Promise.all([
|
||||
DirectoryRepo.getAllFavoriteDirectories(ctx.session.userId),
|
||||
FileRepo.getAllFavoriteFiles(ctx.session.userId),
|
||||
]);
|
||||
return {
|
||||
directories: directories.map((directory) => ({
|
||||
id: directory.id,
|
||||
parent: directory.parentId,
|
||||
mekVersion: directory.mekVersion,
|
||||
dek: directory.encDek,
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
})),
|
||||
files: files.map((file) => ({
|
||||
id: file.id,
|
||||
parent: file.parentId,
|
||||
mekVersion: file.mekVersion,
|
||||
dek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
contentType: file.contentType,
|
||||
name: file.encName.ciphertext,
|
||||
nameIv: file.encName.iv,
|
||||
createdAt: file.encCreatedAt?.ciphertext,
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
addDirectory: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await DirectoryRepo.setDirectoryFavorite(ctx.session.userId, input.id, true);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
} else if (e.message === "Directory already favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
removeDirectory: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await DirectoryRepo.setDirectoryFavorite(ctx.session.userId, input.id, false);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Directory not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
|
||||
} else if (e.message === "Directory not favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
addFile: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await FileRepo.setFileFavorite(ctx.session.userId, input.id, true);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
} else if (e.message === "File already favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
|
||||
removeFile: roleProcedure["activeClient"]
|
||||
.input(
|
||||
z.object({
|
||||
id: z.int().positive(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
try {
|
||||
await FileRepo.setFileFavorite(ctx.session.userId, input.id, false);
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
|
||||
} else if (e.message === "File not favorited") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
export default favoritesRouter;
|
||||
@@ -31,6 +31,7 @@ const fileRouter = router({
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
categories: categories.map((category) => ({
|
||||
id: category.id,
|
||||
parent: category.parentId,
|
||||
@@ -65,6 +66,7 @@ const fileRouter = router({
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
categories: file.categories.map((category) => ({
|
||||
id: category.id,
|
||||
parent: category.parentId,
|
||||
@@ -112,6 +114,7 @@ const fileRouter = router({
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
}));
|
||||
}),
|
||||
|
||||
@@ -131,6 +134,7 @@ const fileRouter = router({
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
}));
|
||||
}),
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export { default as authRouter } from "./auth";
|
||||
export { default as categoryRouter } from "./category";
|
||||
export { default as clientRouter } from "./client";
|
||||
export { default as directoryRouter } from "./directory";
|
||||
export { default as favoritesRouter } from "./favorites";
|
||||
export { default as fileRouter } from "./file";
|
||||
export { default as hskRouter } from "./hsk";
|
||||
export { default as mekRouter } from "./mek";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { DirectoryIdSchema } from "$lib/schemas";
|
||||
import { FileRepo } from "$lib/server/db";
|
||||
import { DirectoryRepo, FileRepo } from "$lib/server/db";
|
||||
import { router, roleProcedure } from "../init.server";
|
||||
|
||||
const searchRouter = router({
|
||||
@@ -8,6 +8,7 @@ const searchRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
ancestor: DirectoryIdSchema.default("root"),
|
||||
inFavorites: z.boolean().default(false),
|
||||
includeCategories: z.number().positive().array().default([]),
|
||||
excludeCategories: z.number().positive().array().default([]),
|
||||
}),
|
||||
@@ -15,10 +16,14 @@ const searchRouter = router({
|
||||
.query(async ({ ctx, input }) => {
|
||||
const [directories, files] = await Promise.all([
|
||||
input.includeCategories.length === 0 && input.excludeCategories.length === 0
|
||||
? FileRepo.getAllRecursiveDirectoriesByParent(ctx.session.userId, input.ancestor)
|
||||
? DirectoryRepo.searchDirectories(ctx.session.userId, {
|
||||
parentId: input.ancestor,
|
||||
inFavorites: input.inFavorites,
|
||||
})
|
||||
: [],
|
||||
FileRepo.searchFiles(ctx.session.userId, {
|
||||
parentId: input.ancestor,
|
||||
inFavorites: input.inFavorites,
|
||||
includeCategoryIds: input.includeCategories,
|
||||
excludeCategoryIds: input.excludeCategories,
|
||||
}),
|
||||
@@ -32,6 +37,7 @@ const searchRouter = router({
|
||||
dekVersion: directory.dekVersion,
|
||||
name: directory.encName.ciphertext,
|
||||
nameIv: directory.encName.iv,
|
||||
isFavorite: directory.isFavorite,
|
||||
})),
|
||||
files: files.map((file) => ({
|
||||
id: file.id,
|
||||
@@ -46,6 +52,7 @@ const searchRouter = router({
|
||||
createdAtIv: file.encCreatedAt?.iv,
|
||||
lastModifiedAt: file.encLastModifiedAt.ciphertext,
|
||||
lastModifiedAtIv: file.encLastModifiedAt.iv,
|
||||
isFavorite: file.isFavorite,
|
||||
})),
|
||||
};
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user