From 420e30f677945975720b9cee00d104b58d37b38f Mon Sep 17 00:00:00 2001 From: static Date: Sat, 17 Jan 2026 19:41:52 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/modules/filesystem/directory.ts | 2 + src/lib/modules/filesystem/types.ts | 2 + src/lib/server/db/error.ts | 4 + src/lib/server/db/file.ts | 139 +++++++++++++++++- .../db/migrations/1768643000-AddFavorites.ts | 29 ++++ src/lib/server/db/migrations/index.ts | 2 + src/lib/server/db/schema/file.ts | 13 +- .../(fullscreen)/file/[id]/+page.svelte | 2 +- src/routes/(main)/BottomBar.svelte | 2 +- .../(main)/directory/[[id]]/+page.svelte | 9 +- .../[[id]]/DirectoryEntries/File.svelte | 8 +- .../DirectoryEntries/SubDirectory.svelte | 8 +- .../[[id]]/EntryMenuBottomSheet.svelte | 15 +- .../(main)/directory/[[id]]/service.svelte.ts | 23 +++ src/routes/(main)/favorite/+page.svelte | 3 - src/routes/(main)/favorites/+page.server.ts | 7 + src/routes/(main)/favorites/+page.svelte | 84 +++++++++++ src/routes/(main)/favorites/Directory.svelte | 24 +++ src/routes/(main)/favorites/File.svelte | 27 ++++ src/routes/(main)/favorites/service.ts | 86 +++++++++++ src/trpc/router.server.ts | 2 + src/trpc/routers/directory.ts | 3 + src/trpc/routers/favorites.ts | 124 ++++++++++++++++ src/trpc/routers/index.ts | 1 + 24 files changed, 605 insertions(+), 14 deletions(-) create mode 100644 src/lib/server/db/migrations/1768643000-AddFavorites.ts delete mode 100644 src/routes/(main)/favorite/+page.svelte create mode 100644 src/routes/(main)/favorites/+page.server.ts create mode 100644 src/routes/(main)/favorites/+page.svelte create mode 100644 src/routes/(main)/favorites/Directory.svelte create mode 100644 src/routes/(main)/favorites/File.svelte create mode 100644 src/routes/(main)/favorites/service.ts create mode 100644 src/trpc/routers/favorites.ts diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index 38b6376..361b3c5 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -39,6 +39,7 @@ const cache = new FilesystemCache({ 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({ id: file.id, parentId: id, contentType: file.contentType, + isFavorite: file.isFavorite, ...(await decryptFileMetadata(file, masterKey)), })), ), diff --git a/src/lib/modules/filesystem/types.ts b/src/lib/modules/filesystem/types.ts index 58df5eb..f2bfbe6 100644 --- a/src/lib/modules/filesystem/types.ts +++ b/src/lib/modules/filesystem/types.ts @@ -6,6 +6,7 @@ export interface LocalDirectoryInfo { parentId: DirectoryId; dataKey?: DataKey; name: string; + isFavorite?: boolean; subDirectories: SubDirectoryInfo[]; files: SummarizedFileInfo[]; } @@ -36,6 +37,7 @@ export interface FileInfo { createdAt?: Date; lastModifiedAt: Date; categories: FileCategoryInfo[]; + isFavorite?: boolean; } export type MaybeFileInfo = diff --git a/src/lib/server/db/error.ts b/src/lib/server/db/error.ts index 0d61d72..0ca6ec2 100644 --- a/src/lib/server/db/error.ts +++ b/src/lib/server/db/error.ts @@ -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" diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 23a9b8c..d70abf8 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -13,6 +13,7 @@ interface Directory { encDek: string; dekVersion: Date; encName: Ciphertext; + isFavorite: boolean; } interface File { @@ -31,6 +32,7 @@ interface File { encName: Ciphertext; encCreatedAt: Ciphertext | null; encLastModifiedAt: Ciphertext; + isFavorite: boolean; } interface FileCategory { @@ -42,7 +44,7 @@ interface FileCategory { encName: Ciphertext; } -export const registerDirectory = async (params: Omit) => { +export const registerDirectory = async (params: Omit) => { await db.transaction().execute(async (trx) => { const mek = await trx .selectFrom("master_encryption_key") @@ -97,6 +99,7 @@ export const getAllDirectoriesByParent = async (userId: number, parentId: Direct encDek: directory.encrypted_data_encryption_key, dekVersion: directory.data_encryption_key_version, encName: directory.encrypted_name, + isFavorite: directory.is_favorite, }) satisfies Directory, ); }; @@ -130,6 +133,7 @@ export const getAllRecursiveDirectoriesByParent = async (userId: number, parentI encDek: directory.encrypted_data_encryption_key, dekVersion: directory.data_encryption_key_version, encName: directory.encrypted_name, + isFavorite: directory.is_favorite, }) satisfies Directory, ); }; @@ -151,6 +155,7 @@ export const getDirectory = async (userId: number, directoryId: number) => { encDek: directory.encrypted_data_encryption_key, dekVersion: directory.data_encryption_key_version, encName: directory.encrypted_name, + isFavorite: directory.is_favorite, } satisfies Directory) : null; }; @@ -243,7 +248,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) = }); }; -export const registerFile = async (trx: typeof db, params: Omit) => { +export const registerFile = async (trx: typeof db, params: Omit) => { if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) { throw new Error("Invalid arguments"); } @@ -305,6 +310,7 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) encName: file.encrypted_name, encCreatedAt: file.encrypted_created_at, encLastModifiedAt: file.encrypted_last_modified_at, + isFavorite: file.is_favorite, }) satisfies File, ); }; @@ -357,6 +363,7 @@ export const getAllFilesByCategory = async ( encName: file.encrypted_name, encCreatedAt: file.encrypted_created_at, encLastModifiedAt: file.encrypted_last_modified_at, + isFavorite: file.is_favorite, isRecursive: file.depth > 0, }) satisfies File & { isRecursive: boolean }, ); @@ -393,6 +400,7 @@ export const getLegacyFiles = async (userId: number, limit: number = 100) => { encName: file.encrypted_name, encCreatedAt: file.encrypted_created_at, encLastModifiedAt: file.encrypted_last_modified_at, + isFavorite: file.is_favorite, }) satisfies File, ); }; @@ -436,6 +444,7 @@ export const getFilesWithoutThumbnail = async (userId: number, limit: number = 1 encName: file.encrypted_name, encCreatedAt: file.encrypted_created_at, encLastModifiedAt: file.encrypted_last_modified_at, + isFavorite: file.is_favorite, }) satisfies File, ); }; @@ -480,6 +489,7 @@ export const getFile = async (userId: number, fileId: number) => { encName: file.encrypted_name, encCreatedAt: file.encrypted_created_at, encLastModifiedAt: file.encrypted_last_modified_at, + isFavorite: file.is_favorite, } satisfies File) : null; }; @@ -518,6 +528,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[]) encName: file.encrypted_name, encCreatedAt: file.encrypted_created_at, encLastModifiedAt: file.encrypted_last_modified_at, + isFavorite: file.is_favorite, categories: file.categories.map((category) => ({ id: category.id, parentId: category.parent_id ?? "root", @@ -803,3 +814,127 @@ 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(); + }); +}; + +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 getAllFavoriteFiles = async (userId: number) => { + const files = await db + .selectFrom("file") + .selectAll() + .where("user_id", "=", userId) + .where("is_favorite", "=", true) + .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, + isFavorite: file.is_favorite, + }) satisfies File, + ); +}; + +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( + (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, + isFavorite: directory.is_favorite, + }) satisfies Directory, + ); +}; diff --git a/src/lib/server/db/migrations/1768643000-AddFavorites.ts b/src/lib/server/db/migrations/1768643000-AddFavorites.ts new file mode 100644 index 0000000..d18cce0 --- /dev/null +++ b/src/lib/server/db/migrations/1768643000-AddFavorites.ts @@ -0,0 +1,29 @@ +import { Kysely, sql } from "kysely"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const up = async (db: Kysely) => { + // file.ts + await db.schema + .alterTable("directory") + .addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false)) + .execute(); + 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) => { + 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(); +}; diff --git a/src/lib/server/db/migrations/index.ts b/src/lib/server/db/migrations/index.ts index ca3310a..363a394 100644 --- a/src/lib/server/db/migrations/index.ts +++ b/src/lib/server/db/migrations/index.ts @@ -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, }; diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index 3680d1d..c785b04 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -9,13 +9,14 @@ interface DirectoryTable { encrypted_data_encryption_key: string; // Base64 data_encryption_key_version: Date; encrypted_name: Ciphertext; + is_favorite: Generated; } interface DirectoryLogTable { id: Generated; directory_id: number; timestamp: ColumnType; - action: "create" | "rename"; + action: "create" | "rename" | "add-to-favorites" | "remove-from-favorites"; new_name: Ciphertext | null; } @@ -35,13 +36,21 @@ interface FileTable { encrypted_name: Ciphertext; encrypted_created_at: Ciphertext | null; encrypted_last_modified_at: Ciphertext; + is_favorite: Generated; } interface FileLogTable { id: Generated; file_id: number; timestamp: ColumnType; - 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; } diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 6221249..9ae8825 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -150,7 +150,7 @@ { + if (await requestFavoriteToggle(context.selectedEntry!)) { + isEntryMenuBottomSheetOpen = false; + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + } + }} /> { - 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 ?? false, + }); }; diff --git a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte index 018a1e5..3f86025 100644 --- a/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte +++ b/src/routes/(main)/directory/[[id]]/DirectoryEntries/SubDirectory.svelte @@ -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 ?? false, + }); }; diff --git a/src/routes/(main)/directory/[[id]]/EntryMenuBottomSheet.svelte b/src/routes/(main)/directory/[[id]]/EntryMenuBottomSheet.svelte index 95e675a..a3d978a 100644 --- a/src/routes/(main)/directory/[[id]]/EntryMenuBottomSheet.svelte +++ b/src/routes/(main)/directory/[[id]]/EntryMenuBottomSheet.svelte @@ -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(); {#if context.selectedEntry} - {@const { name, type } = context.selectedEntry} + {@const { name, type, isFavorite } = context.selectedEntry}
+ + {isFavorite ? "즐겨찾기에서 해제하기" : "즐겨찾기에 추가하기"} + 이름 바꾸기 diff --git a/src/routes/(main)/directory/[[id]]/service.svelte.ts b/src/routes/(main)/directory/[[id]]/service.svelte.ts index be6392c..82a7456 100644 --- a/src/routes/(main)/directory/[[id]]/service.svelte.ts +++ b/src/routes/(main)/directory/[[id]]/service.svelte.ts @@ -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; + } +}; diff --git a/src/routes/(main)/favorite/+page.svelte b/src/routes/(main)/favorite/+page.svelte deleted file mode 100644 index 73d68b7..0000000 --- a/src/routes/(main)/favorite/+page.svelte +++ /dev/null @@ -1,3 +0,0 @@ -
-

아직 개발 중이에요.

-
diff --git a/src/routes/(main)/favorites/+page.server.ts b/src/routes/(main)/favorites/+page.server.ts new file mode 100644 index 0000000..484016d --- /dev/null +++ b/src/routes/(main)/favorites/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/(main)/favorites/+page.svelte b/src/routes/(main)/favorites/+page.svelte new file mode 100644 index 0000000..20d2a97 --- /dev/null +++ b/src/routes/(main)/favorites/+page.svelte @@ -0,0 +1,84 @@ + + + + 즐겨찾기 + + +
+ {#if isLoading} +
+

+ {#if data.favorites.files.length === 0 && data.favorites.directories.length === 0} + 즐겨찾기한 항목이 없어요. + {:else} + 로딩 중... + {/if} +

+
+ {:else if entries.length === 0} +
+

즐겨찾기한 항목이 없어요.

+
+ {:else} + `${entries[index]!.type}-${entries[index]!.details.id}`} + estimateItemHeight={() => 56} + itemGap={4} + > + {#snippet item(index)} + {@const entry = entries[index]!} + {#if entry.type === "directory"} + handleClick(entry)} + onRemoveClick={() => handleRemove(entry)} + /> + {:else} + handleClick(entry)} + onRemoveClick={() => handleRemove(entry)} + /> + {/if} + {/snippet} + + {/if} +
diff --git a/src/routes/(main)/favorites/Directory.svelte b/src/routes/(main)/favorites/Directory.svelte new file mode 100644 index 0000000..ae1ab13 --- /dev/null +++ b/src/routes/(main)/favorites/Directory.svelte @@ -0,0 +1,24 @@ + + + + + diff --git a/src/routes/(main)/favorites/File.svelte b/src/routes/(main)/favorites/File.svelte new file mode 100644 index 0000000..78015d7 --- /dev/null +++ b/src/routes/(main)/favorites/File.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/src/routes/(main)/favorites/service.ts b/src/routes/(main)/favorites/service.ts new file mode 100644 index 0000000..7717d1c --- /dev/null +++ b/src/routes/(main)/favorites/service.ts @@ -0,0 +1,86 @@ +import { + decryptDirectoryMetadata, + decryptFileMetadata, + getFileInfo, + type SummarizedFileInfo, + type SubDirectoryInfo, +} 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, +): Promise => { + const directories: FavoriteEntry[] = await Promise.all( + favorites.directories.map(async (dir) => { + const metadata = await decryptDirectoryMetadata(dir, masterKey); + return { + type: "directory" as const, + name: metadata.name, + details: { + id: dir.id, + parentId: dir.parent, + isFavorite: true, + dataKey: metadata.dataKey, + name: metadata.name, + } as SubDirectoryInfo, + }; + }), + ); + + const fileResults = await Promise.all( + favorites.files.map(async (file) => { + const result = await HybridPromise.resolve( + getFileInfo(file.id, masterKey, { + async fetchFromServer(id, cachedInfo) { + const metadata = await decryptFileMetadata(file, masterKey); + return { + categories: [], + ...cachedInfo, + id: id as number, + exists: true, + parentId: file.parent, + contentType: file.contentType, + isFavorite: true, + ...metadata, + }; + }, + }), + ); + if (result?.exists) { + return { + type: "file" as const, + name: result.name, + details: result as SummarizedFileInfo, + }; + } + return null; + }), + ); + + const files = fileResults.filter( + (f): f is { type: "file"; name: string; details: SummarizedFileInfo } => f !== null, + ); + + return [...sortEntries(directories), ...sortEntries(files)]; +}; + +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; + } +}; diff --git a/src/trpc/router.server.ts b/src/trpc/router.server.ts index c305a21..09a3918 100644 --- a/src/trpc/router.server.ts +++ b/src/trpc/router.server.ts @@ -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, diff --git a/src/trpc/routers/directory.ts b/src/trpc/routers/directory.ts index 15f16f3..dfe5a3b 100644 --- a/src/trpc/routers/directory.ts +++ b/src/trpc/routers/directory.ts @@ -31,6 +31,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 +40,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 +54,7 @@ const directoryRouter = router({ createdAtIv: file.encCreatedAt?.iv, lastModifiedAt: file.encLastModifiedAt.ciphertext, lastModifiedAtIv: file.encLastModifiedAt.iv, + isFavorite: file.isFavorite, })), }; }), diff --git a/src/trpc/routers/favorites.ts b/src/trpc/routers/favorites.ts new file mode 100644 index 0000000..8aba70b --- /dev/null +++ b/src/trpc/routers/favorites.ts @@ -0,0 +1,124 @@ +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { FileRepo, IntegrityError } from "$lib/server/db"; +import { router, roleProcedure } from "../init.server"; + +const favoritesRouter = router({ + get: roleProcedure["activeClient"].query(async ({ ctx }) => { + const [files, directories] = await Promise.all([ + FileRepo.getAllFavoriteFiles(ctx.session.userId), + FileRepo.getAllFavoriteDirectories(ctx.session.userId), + ]); + return { + 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, + })), + 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, + })), + }; + }), + + 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; + } + }), + + addDirectory: roleProcedure["activeClient"] + .input( + z.object({ + id: z.int().positive(), + }), + ) + .mutation(async ({ ctx, input }) => { + try { + await FileRepo.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 FileRepo.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; + } + }), +}); + +export default favoritesRouter; diff --git a/src/trpc/routers/index.ts b/src/trpc/routers/index.ts index e8fbecc..3e3f7ba 100644 --- a/src/trpc/routers/index.ts +++ b/src/trpc/routers/index.ts @@ -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";