From 2a2d01b50e2120d7c2e887ab8288171f07cdba95 Mon Sep 17 00:00:00 2001 From: static Date: Tue, 21 Jan 2025 10:57:32 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20DB=20=EC=8A=A4=ED=82=A4=EB=A7=88/=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20Kysely=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/server/db/category.ts | 189 ++++++++++-------- src/lib/server/db/file.ts | 104 ++++++---- .../migrations/1737422340-AddFileCategory.ts | 65 ++++++ src/lib/server/db/migrations/index.ts | 2 + src/lib/server/db/schema/category.ts | 73 +++---- src/lib/server/db/schema/file.ts | 30 +-- src/lib/server/db/schema/index.ts | 1 + src/lib/server/db/schema/util.ts | 4 + 8 files changed, 281 insertions(+), 187 deletions(-) create mode 100644 src/lib/server/db/migrations/1737422340-AddFileCategory.ts create mode 100644 src/lib/server/db/schema/util.ts diff --git a/src/lib/server/db/category.ts b/src/lib/server/db/category.ts index db543ab..fb85322 100644 --- a/src/lib/server/db/category.ts +++ b/src/lib/server/db/category.ts @@ -1,111 +1,144 @@ -import { and, eq, isNull } from "drizzle-orm"; -import db from "./drizzle"; import { IntegrityError } from "./error"; -import { category, categoryLog, mek } from "./schema"; +import db from "./kysely"; +import type { Ciphertext } from "./schema"; type CategoryId = "root" | number; -export interface NewCategoryParams { - parentId: "root" | number; +interface Category { + id: number; + parentId: CategoryId; userId: number; mekVersion: number; encDek: string; dekVersion: Date; - encName: string; - encNameIv: string; + encName: Ciphertext; } -export const registerCategory = async (params: NewCategoryParams) => { - await db.transaction( - async (tx) => { - const meks = await tx - .select({ version: mek.version }) - .from(mek) - .where(and(eq(mek.userId, params.userId), eq(mek.state, "active"))) - .limit(1); - if (meks[0]?.version !== params.mekVersion) { - throw new IntegrityError("Inactive MEK version"); - } +export type NewCategory = Omit; - const newCategories = await tx - .insert(category) - .values({ - parentId: params.parentId === "root" ? null : params.parentId, - userId: params.userId, - mekVersion: params.mekVersion, - encDek: params.encDek, - dekVersion: params.dekVersion, - encName: { ciphertext: params.encName, iv: params.encNameIv }, - }) - .returning({ id: category.id }); - const { id: categoryId } = newCategories[0]!; - await tx.insert(categoryLog).values({ - categoryId, +export const registerCategory = async (params: NewCategory) => { + 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 { categoryId } = await trx + .insertInto("category") + .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 categoryId") + .executeTakeFirstOrThrow(); + await trx + .insertInto("category_log") + .values({ + category_id: categoryId, timestamp: new Date(), action: "create", - newName: { ciphertext: params.encName, iv: params.encNameIv }, - }); - }, - { behavior: "exclusive" }, - ); + new_name: params.encName, + }) + .execute(); + }); }; export const getAllCategoriesByParent = async (userId: number, parentId: CategoryId) => { - return await db - .select() - .from(category) - .where( - and( - eq(category.userId, userId), - parentId === "root" ? isNull(category.parentId) : eq(category.parentId, parentId), - ), - ); + let query = db.selectFrom("category").selectAll().where("user_id", "=", userId); + query = + parentId === "root" + ? query.where("parent_id", "is", null) + : query.where("parent_id", "=", parentId); + const categories = await query.execute(); + return categories.map( + (category) => + ({ + id: category.id, + parentId: category.parent_id ?? "root", + userId: category.user_id, + mekVersion: category.master_encryption_key_version, + encDek: category.encrypted_data_encryption_key, + dekVersion: category.data_encryption_key_version, + encName: category.encrypted_name, + }) satisfies Category, + ); }; export const getCategory = async (userId: number, categoryId: number) => { - const res = await db - .select() - .from(category) - .where(and(eq(category.userId, userId), eq(category.id, categoryId))) - .limit(1); - return res[0] ?? null; + const category = await db + .selectFrom("category") + .selectAll() + .where("id", "=", categoryId) + .where("user_id", "=", userId) + .limit(1) + .executeTakeFirst(); + return category + ? ({ + id: category.id, + parentId: category.parent_id ?? "root", + userId: category.user_id, + mekVersion: category.master_encryption_key_version, + encDek: category.encrypted_data_encryption_key, + dekVersion: category.data_encryption_key_version, + encName: category.encrypted_name, + } satisfies Category) + : null; }; export const setCategoryEncName = async ( userId: number, categoryId: number, dekVersion: Date, - encName: string, - encNameIv: string, + encName: Ciphertext, ) => { - await db.transaction( - async (tx) => { - const categories = await tx - .select({ version: category.dekVersion }) - .from(category) - .where(and(eq(category.userId, userId), eq(category.id, categoryId))) - .limit(1); - if (!categories[0]) { - throw new IntegrityError("Category not found"); - } else if (categories[0].version.getTime() !== dekVersion.getTime()) { - throw new IntegrityError("Invalid DEK version"); - } + await db.transaction().execute(async (trx) => { + const category = await trx + .selectFrom("category") + .select("data_encryption_key_version") + .where("id", "=", categoryId) + .where("user_id", "=", userId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (!category) { + throw new IntegrityError("Category not found"); + } else if (category.data_encryption_key_version.getTime() !== dekVersion.getTime()) { + throw new IntegrityError("Invalid DEK version"); + } - await tx - .update(category) - .set({ encName: { ciphertext: encName, iv: encNameIv } }) - .where(and(eq(category.userId, userId), eq(category.id, categoryId))); - await tx.insert(categoryLog).values({ - categoryId, + await trx + .updateTable("category") + .set({ encrypted_name: encName }) + .where("id", "=", categoryId) + .where("user_id", "=", userId) + .execute(); + await trx + .insertInto("category_log") + .values({ + category_id: categoryId, timestamp: new Date(), action: "rename", - newName: { ciphertext: encName, iv: encNameIv }, - }); - }, - { behavior: "exclusive" }, - ); + new_name: encName, + }) + .execute(); + }); }; export const unregisterCategory = async (userId: number, categoryId: number) => { - await db.delete(category).where(and(eq(category.userId, userId), eq(category.id, categoryId))); + await db + .deleteFrom("category") + .where("id", "=", categoryId) + .where("user_id", "=", userId) + .execute(); }; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index b372557..789c3b3 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,3 +1,4 @@ +import pg from "pg"; import { IntegrityError } from "./error"; import db from "./kysely"; import type { Ciphertext } from "./schema"; @@ -290,11 +291,33 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) }; export const getAllFilesByCategory = async (userId: number, categoryId: number) => { - return await db - .select() - .from(file) - .innerJoin(fileCategory, eq(file.id, fileCategory.fileId)) - .where(and(eq(file.userId, userId), eq(fileCategory.categoryId, categoryId))); + const files = await db + .selectFrom("file") + .innerJoin("file_category", "file.id", "file_category.file_id") + .selectAll("file") + .where("user_id", "=", userId) + .where("category_id", "=", categoryId) + .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, + ); }; export const getAllFileIdsByContentHmac = async ( @@ -394,44 +417,49 @@ export const unregisterFile = async (userId: number, fileId: number) => { }; export const addFileToCategory = async (fileId: number, categoryId: number) => { - await db.transaction( - async (tx) => { - try { - await tx.insert(fileCategory).values({ fileId, categoryId }); - await tx.insert(fileLog).values({ - fileId, + await db.transaction().execute(async (trx) => { + try { + await trx + .insertInto("file_category") + .values({ file_id: fileId, category_id: categoryId }) + .execute(); + await trx + .insertInto("file_log") + .values({ + file_id: fileId, timestamp: new Date(), - action: "addToCategory", - categoryId, - }); - } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") { - throw new IntegrityError("File already added to category"); - } - throw e; + action: "add-to-category", + category_id: categoryId, + }) + .execute(); + } catch (e) { + if (e instanceof pg.DatabaseError && e.code === "23505") { + throw new IntegrityError("File already added to category"); } - }, - { behavior: "exclusive" }, - ); + throw e; + } + }); }; export const removeFileFromCategory = async (fileId: number, categoryId: number) => { - await db.transaction( - async (tx) => { - const res = await tx - .delete(fileCategory) - .where(and(eq(fileCategory.fileId, fileId), eq(fileCategory.categoryId, categoryId))); - if (res.changes === 0) { - throw new IntegrityError("File not found in category"); - } + await db.transaction().execute(async (trx) => { + const res = await trx + .deleteFrom("file_category") + .where("file_id", "=", fileId) + .where("category_id", "=", categoryId) + .executeTakeFirst(); + if (res.numDeletedRows === 0n) { + throw new IntegrityError("File not found in category"); + } - await tx.insert(fileLog).values({ - fileId, + await trx + .insertInto("file_log") + .values({ + file_id: fileId, timestamp: new Date(), - action: "removeFromCategory", - categoryId, - }); - }, - { behavior: "exclusive" }, - ); + action: "remove-from-category", + category_id: categoryId, + }) + .execute(); + }); }; diff --git a/src/lib/server/db/migrations/1737422340-AddFileCategory.ts b/src/lib/server/db/migrations/1737422340-AddFileCategory.ts new file mode 100644 index 0000000..ff811e1 --- /dev/null +++ b/src/lib/server/db/migrations/1737422340-AddFileCategory.ts @@ -0,0 +1,65 @@ +import { Kysely } from "kysely"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const up = async (db: Kysely) => { + // category.ts + await db.schema + .createTable("category") + .addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()) + .addColumn("parent_id", "integer", (col) => col.references("category.id").onDelete("cascade")) + .addColumn("user_id", "integer", (col) => col.references("user.id").notNull()) + .addColumn("master_encryption_key_version", "integer", (col) => col.notNull()) + .addColumn("encrypted_data_encryption_key", "text", (col) => col.unique().notNull()) + .addColumn("data_encryption_key_version", "timestamp(3)", (col) => col.notNull()) + .addColumn("encrypted_name", "json", (col) => col.notNull()) + .addForeignKeyConstraint( + "category_fk01", + ["user_id", "master_encryption_key_version"], + "master_encryption_key", + ["user_id", "version"], + ) + .execute(); + await db.schema + .createTable("category_log") + .addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()) + .addColumn("category_id", "integer", (col) => + col.references("category.id").onDelete("cascade").notNull(), + ) + .addColumn("timestamp", "timestamp(3)", (col) => col.notNull()) + .addColumn("action", "text", (col) => col.notNull()) + .addColumn("new_name", "json") + .execute(); + + // file.ts + await db.schema + .alterTable("file_log") + .addColumn("category_id", "integer", (col) => + col.references("category.id").onDelete("set null"), + ) + .execute(); + await db.schema + .createTable("file_category") + .addColumn("file_id", "integer", (col) => + col.references("file.id").onDelete("cascade").notNull(), + ) + .addColumn("category_id", "integer", (col) => + col.references("category.id").onDelete("cascade").notNull(), + ) + .addPrimaryKeyConstraint("file_category_pk", ["file_id", "category_id"]) + .execute(); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const down = async (db: Kysely) => { + await db + .deleteFrom("file_log") + .where((eb) => + eb.or([eb("action", "=", "add-to-category"), eb("action", "=", "remove-from-category")]), + ) + .execute(); + + await db.schema.dropTable("file_category").execute(); + await db.schema.alterTable("file_log").dropColumn("category_id").execute(); + await db.schema.dropTable("category_log").execute(); + await db.schema.dropTable("category").execute(); +}; diff --git a/src/lib/server/db/migrations/index.ts b/src/lib/server/db/migrations/index.ts index 6caca84..aa6ee13 100644 --- a/src/lib/server/db/migrations/index.ts +++ b/src/lib/server/db/migrations/index.ts @@ -1,5 +1,7 @@ import * as Initial1737357000 from "./1737357000-Initial"; +import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory"; export default { "1737357000-Initial": Initial1737357000, + "1737422340-AddFileCategory": AddFileCategory1737422340, }; diff --git a/src/lib/server/db/schema/category.ts b/src/lib/server/db/schema/category.ts index f8e1621..2304264 100644 --- a/src/lib/server/db/schema/category.ts +++ b/src/lib/server/db/schema/category.ts @@ -1,52 +1,27 @@ -import { - sqliteTable, - text, - integer, - foreignKey, - type AnySQLiteColumn, -} from "drizzle-orm/sqlite-core"; -import { mek } from "./mek"; -import { user } from "./user"; +import type { Generated } from "kysely"; +import type { Ciphertext } from "./util"; -const ciphertext = (name: string) => - text(name, { mode: "json" }).$type<{ - ciphertext: string; // Base64 - iv: string; // Base64 - }>(); +interface CategoryTable { + 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; +} -export const category = sqliteTable( - "category", - { - id: integer("id").primaryKey({ autoIncrement: true }), - parentId: integer("parent_id").references((): AnySQLiteColumn => category.id, { - onDelete: "cascade", - }), - userId: integer("user_id") - .notNull() - .references(() => user.id), - mekVersion: integer("master_encryption_key_version").notNull(), - encDek: text("encrypted_data_encryption_key").notNull().unique(), // Base64 - dekVersion: integer("data_encryption_key_version", { mode: "timestamp_ms" }).notNull(), - encName: ciphertext("encrypted_name").notNull(), - }, - (t) => ({ - ref1: foreignKey({ - columns: [t.parentId], - foreignColumns: [t.id], - }), - ref2: foreignKey({ - columns: [t.userId, t.mekVersion], - foreignColumns: [mek.userId, mek.version], - }), - }), -); +interface CategoryLogTable { + id: Generated; + category_id: number; + timestamp: Date; + action: "create" | "rename"; + new_name: Ciphertext | null; +} -export const categoryLog = sqliteTable("category_log", { - id: integer("id").primaryKey({ autoIncrement: true }), - categoryId: integer("category_id") - .notNull() - .references(() => category.id, { onDelete: "cascade" }), - timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(), - action: text("action", { enum: ["create", "rename"] }).notNull(), - newName: ciphertext("new_name"), -}); +declare module "./index" { + interface Database { + category: CategoryTable; + category_log: CategoryLogTable; + } +} diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index 2e13b4c..a1bf9bd 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -1,9 +1,5 @@ import type { ColumnType, Generated } from "kysely"; - -export type Ciphertext = { - ciphertext: string; // Base64 - iv: string; // Base64 -}; +import type { Ciphertext } from "./util"; interface DirectoryTable { id: Generated; @@ -45,26 +41,15 @@ interface FileLogTable { id: Generated; file_id: number; timestamp: ColumnType; - action: "create" | "rename"; + action: "create" | "rename" | "add-to-category" | "remove-from-category"; new_name: Ciphertext | null; + category_id: number | null; } -export const fileCategory = sqliteTable( - "file_category", - { - fileId: integer("file_id") - .notNull() - .references(() => file.id, { onDelete: "cascade" }), - categoryId: integer("category_id") - .notNull() - .references(() => category.id, { onDelete: "cascade" }), - }, - (t) => ({ - pk: primaryKey({ - columns: [t.fileId, t.categoryId], - }), - }), -); +interface FileCategoryTable { + file_id: number; + category_id: number; +} declare module "./index" { interface Database { @@ -72,5 +57,6 @@ declare module "./index" { directory_log: DirectoryLogTable; file: FileTable; file_log: FileLogTable; + file_category: FileCategoryTable; } } diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 00c72a7..d3dd9b1 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -5,6 +5,7 @@ export * from "./hsk"; export * from "./mek"; export * from "./session"; export * from "./user"; +export * from "./util"; // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface Database {} diff --git a/src/lib/server/db/schema/util.ts b/src/lib/server/db/schema/util.ts new file mode 100644 index 0000000..d7f7350 --- /dev/null +++ b/src/lib/server/db/schema/util.ts @@ -0,0 +1,4 @@ +export type Ciphertext = { + ciphertext: string; // Base64 + iv: string; // Base64 +};