diff --git a/src/lib/server/db/directory.ts b/src/lib/server/db/directory.ts new file mode 100644 index 0000000..432b1dd --- /dev/null +++ b/src/lib/server/db/directory.ts @@ -0,0 +1,245 @@ +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): 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) => { + 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 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(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 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); + }); +}; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index b6967be..af2b6ad 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,20 +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; - isFavorite: boolean; -} +import type { Ciphertext, FileTable } from "./schema"; interface File { id: number; @@ -44,209 +33,24 @@ interface FileCategory { encName: Ciphertext; } -export const registerDirectory = async (params: Omit) => { - 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) => { - 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, - isFavorite: directory.is_favorite, - }) 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, - isFavorite: directory.is_favorite, - }) 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, - isFavorite: directory.is_favorite, - } 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); - }); -}; +const toFile = (row: Selectable): 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, +}); export const registerFile = async (trx: typeof db, params: Omit) => { if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) { @@ -286,33 +90,14 @@ export const registerFile = async (trx: typeof db, params: Omit { - 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, - isFavorite: file.is_favorite, - }) 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 ( @@ -345,28 +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, - isFavorite: file.is_favorite, - isRecursive: file.depth > 0, - }) satisfies File & { isRecursive: boolean }, - ); + return files.map((file) => ({ + ...toFile(file), + isRecursive: file.depth > 0, + })); }; export const getAllFileIds = async (userId: number) => { @@ -382,27 +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, - isFavorite: file.is_favorite, - }) satisfies File, - ); + return files.map(toFile); }; export const getFilesWithoutThumbnail = async (userId: number, limit: number = 100) => { @@ -426,27 +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, - isFavorite: file.is_favorite, - }) satisfies File, - ); + return files.map(toFile); }; export const getAllFileIdsByContentHmac = async ( @@ -472,26 +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, - isFavorite: file.is_favorite, - } satisfies File) - : null; + return file ? toFile(file) : null; }; export const getFilesWithCategories = async (userId: number, fileIds: number[]) => { @@ -510,35 +218,30 @@ 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, - isFavorite: file.is_favorite, - 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 ( @@ -625,24 +328,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, - isFavorite: file.is_favorite, - })); + return files.map(toFile); }; export const setFileEncName = async ( @@ -848,94 +534,3 @@ export const setFileFavorite = async (userId: number, fileId: number, isFavorite .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/index.ts b/src/lib/server/db/index.ts index 140cf7d..bc1dade 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -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"; diff --git a/src/lib/server/db/migrations/1737357000-Initial.ts b/src/lib/server/db/migrations/1737357000-Initial.ts index 5caf503..07bac98 100644 --- a/src/lib/server/db/migrations/1737357000-Initial.ts +++ b/src/lib/server/db/migrations/1737357000-Initial.ts @@ -135,7 +135,7 @@ export const up = async (db: Kysely) => { ) .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) => { .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()) diff --git a/src/lib/server/db/migrations/1768643000-AddFavorites.ts b/src/lib/server/db/migrations/1768643000-AddFavorites.ts index 660e116..4dc9323 100644 --- a/src/lib/server/db/migrations/1768643000-AddFavorites.ts +++ b/src/lib/server/db/migrations/1768643000-AddFavorites.ts @@ -2,11 +2,13 @@ import { Kysely } from "kysely"; // eslint-disable-next-line @typescript-eslint/no-explicit-any export const up = async (db: Kysely) => { - // file.ts + // 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)) diff --git a/src/lib/server/db/schema/category.ts b/src/lib/server/db/schema/category.ts index ccaba95..5996f89 100644 --- a/src/lib/server/db/schema/category.ts +++ b/src/lib/server/db/schema/category.ts @@ -1,7 +1,7 @@ import type { Generated } from "kysely"; import type { Ciphertext } from "./utils"; -interface CategoryTable { +export interface CategoryTable { id: Generated; parent_id: number | null; user_id: number; @@ -11,7 +11,7 @@ interface CategoryTable { encrypted_name: Ciphertext; } -interface CategoryLogTable { +export interface CategoryLogTable { id: Generated; category_id: number; timestamp: Date; diff --git a/src/lib/server/db/schema/client.ts b/src/lib/server/db/schema/client.ts index d66e42b..f5de41b 100644 --- a/src/lib/server/db/schema/client.ts +++ b/src/lib/server/db/schema/client.ts @@ -1,6 +1,6 @@ import type { ColumnType, Generated } from "kysely"; -interface ClientTable { +export interface ClientTable { id: Generated; 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; } -interface UserClientChallengeTable { +export interface UserClientChallengeTable { id: Generated; user_id: number; client_id: number; diff --git a/src/lib/server/db/schema/directory.ts b/src/lib/server/db/schema/directory.ts new file mode 100644 index 0000000..390998d --- /dev/null +++ b/src/lib/server/db/schema/directory.ts @@ -0,0 +1,28 @@ +import type { ColumnType, Generated } from "kysely"; +import type { Ciphertext } from "./utils"; + +export interface DirectoryTable { + id: Generated; + 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; +} + +export interface DirectoryLogTable { + id: Generated; + directory_id: number; + timestamp: ColumnType; + action: "create" | "rename" | "add-to-favorites" | "remove-from-favorites"; + new_name: Ciphertext | null; +} + +declare module "./index" { + interface Database { + directory: DirectoryTable; + directory_log: DirectoryLogTable; + } +} diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index c785b04..784ad93 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -1,26 +1,7 @@ import type { ColumnType, Generated } from "kysely"; import type { Ciphertext } from "./utils"; -interface DirectoryTable { - id: Generated; - 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; -} - -interface DirectoryLogTable { - id: Generated; - directory_id: number; - timestamp: ColumnType; - action: "create" | "rename" | "add-to-favorites" | "remove-from-favorites"; - new_name: Ciphertext | null; -} - -interface FileTable { +export interface FileTable { id: Generated; parent_id: number | null; user_id: number; @@ -39,7 +20,7 @@ interface FileTable { is_favorite: Generated; } -interface FileLogTable { +export interface FileLogTable { id: Generated; file_id: number; timestamp: ColumnType; @@ -55,15 +36,13 @@ interface FileLogTable { 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; diff --git a/src/lib/server/db/schema/hsk.ts b/src/lib/server/db/schema/hsk.ts index 71457b0..42ea1b6 100644 --- a/src/lib/server/db/schema/hsk.ts +++ b/src/lib/server/db/schema/hsk.ts @@ -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; user_id: number; hmac_secret_key_version: number; diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 7a13395..5c31e5f 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -1,5 +1,6 @@ export * from "./category"; export * from "./client"; +export * from "./directory"; export * from "./file"; export * from "./hsk"; export * from "./media"; diff --git a/src/lib/server/db/schema/media.ts b/src/lib/server/db/schema/media.ts index 1fef90b..97645df 100644 --- a/src/lib/server/db/schema/media.ts +++ b/src/lib/server/db/schema/media.ts @@ -1,6 +1,6 @@ import type { Generated } from "kysely"; -interface ThumbnailTable { +export interface ThumbnailTable { id: Generated; directory_id: number | null; file_id: number | null; diff --git a/src/lib/server/db/schema/mek.ts b/src/lib/server/db/schema/mek.ts index d1b3c76..fce5ed1 100644 --- a/src/lib/server/db/schema/mek.ts +++ b/src/lib/server/db/schema/mek.ts @@ -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; 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; diff --git a/src/lib/server/db/schema/session.ts b/src/lib/server/db/schema/session.ts index 301a879..3962691 100644 --- a/src/lib/server/db/schema/session.ts +++ b/src/lib/server/db/schema/session.ts @@ -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; session_id: string; client_id: number; diff --git a/src/lib/server/db/schema/upload.ts b/src/lib/server/db/schema/upload.ts index 5635921..b9c8510 100644 --- a/src/lib/server/db/schema/upload.ts +++ b/src/lib/server/db/schema/upload.ts @@ -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; diff --git a/src/lib/server/db/schema/user.ts b/src/lib/server/db/schema/user.ts index a5f111f..9f1ebe0 100644 --- a/src/lib/server/db/schema/user.ts +++ b/src/lib/server/db/schema/user.ts @@ -1,6 +1,6 @@ import type { Generated } from "kysely"; -interface UserTable { +export interface UserTable { id: Generated; email: string; nickname: string; diff --git a/src/trpc/routers/directory.ts b/src/trpc/routers/directory.ts index dfe5a3b..449d5b1 100644 --- a/src/trpc/routers/directory.ts +++ b/src/trpc/routers/directory.ts @@ -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 { @@ -78,7 +80,7 @@ const directoryRouter = router({ } try { - await FileRepo.registerDirectory({ + await DirectoryRepo.registerDirectory({ parentId: input.parent, userId: ctx.session.userId, mekVersion: input.mekVersion, @@ -105,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, }); @@ -129,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 diff --git a/src/trpc/routers/favorites.ts b/src/trpc/routers/favorites.ts index 8aba70b..18dc973 100644 --- a/src/trpc/routers/favorites.ts +++ b/src/trpc/routers/favorites.ts @@ -1,15 +1,24 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { FileRepo, IntegrityError } from "$lib/server/db"; +import { DirectoryRepo, 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([ + const [directories, files] = await Promise.all([ + DirectoryRepo.getAllFavoriteDirectories(ctx.session.userId), FileRepo.getAllFavoriteFiles(ctx.session.userId), - FileRepo.getAllFavoriteDirectories(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, @@ -24,18 +33,51 @@ const favoritesRouter = router({ 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, - })), }; }), + 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({ @@ -77,48 +119,6 @@ const favoritesRouter = router({ 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/search.ts b/src/trpc/routers/search.ts index 34e814d..07caeb2 100644 --- a/src/trpc/routers/search.ts +++ b/src/trpc/routers/search.ts @@ -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({ @@ -15,7 +15,7 @@ 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.getAllRecursiveDirectoriesByParent(ctx.session.userId, input.ancestor) : [], FileRepo.searchFiles(ctx.session.userId, { parentId: input.ancestor,