diff --git a/src/lib/server/db/category.ts b/src/lib/server/db/category.ts new file mode 100644 index 0000000..db543ab --- /dev/null +++ b/src/lib/server/db/category.ts @@ -0,0 +1,111 @@ +import { and, eq, isNull } from "drizzle-orm"; +import db from "./drizzle"; +import { IntegrityError } from "./error"; +import { category, categoryLog, mek } from "./schema"; + +type CategoryId = "root" | number; + +export interface NewCategoryParams { + parentId: "root" | number; + userId: number; + mekVersion: number; + encDek: string; + dekVersion: Date; + encName: string; + encNameIv: string; +} + +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"); + } + + 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, + timestamp: new Date(), + action: "create", + newName: { ciphertext: params.encName, iv: params.encNameIv }, + }); + }, + { behavior: "exclusive" }, + ); +}; + +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), + ), + ); +}; + +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; +}; + +export const setCategoryEncName = async ( + userId: number, + categoryId: number, + dekVersion: Date, + encName: string, + encNameIv: string, +) => { + 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 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, + timestamp: new Date(), + action: "rename", + newName: { ciphertext: encName, iv: encNameIv }, + }); + }, + { behavior: "exclusive" }, + ); +}; + +export const unregisterCategory = async (userId: number, categoryId: number) => { + await db.delete(category).where(and(eq(category.userId, userId), eq(category.id, categoryId))); +}; diff --git a/src/lib/server/db/error.ts b/src/lib/server/db/error.ts index 547cc6c..a145f14 100644 --- a/src/lib/server/db/error.ts +++ b/src/lib/server/db/error.ts @@ -1,4 +1,6 @@ type IntegrityErrorMessages = + // Category + | "Category not found" // Challenge | "Challenge already registered" // Client @@ -7,6 +9,8 @@ type IntegrityErrorMessages = // File | "Directory not found" | "File not found" + | "File not found in category" + | "File already added to category" | "Invalid DEK version" // HSK | "HSK already registered" diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index b99bd38..5d89306 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -1,7 +1,8 @@ +import { SqliteError } from "better-sqlite3"; import { and, eq, isNull } from "drizzle-orm"; import db from "./drizzle"; import { IntegrityError } from "./error"; -import { directory, directoryLog, file, fileLog, hsk, mek } from "./schema"; +import { directory, directoryLog, file, fileLog, fileCategory, hsk, mek } from "./schema"; type DirectoryId = "root" | number; @@ -237,6 +238,14 @@ 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))); +}; + export const getAllFileIdsByContentHmac = async ( userId: number, hskVersion: number, @@ -308,3 +317,46 @@ export const unregisterFile = async (userId: number, fileId: number) => { } return files[0].path; }; + +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, + 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; + } + }, + { behavior: "exclusive" }, + ); +}; + +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 tx.insert(fileLog).values({ + fileId, + timestamp: new Date(), + action: "removeFromCategory", + categoryId, + }); + }, + { behavior: "exclusive" }, + ); +}; diff --git a/src/lib/server/db/schema/category.ts b/src/lib/server/db/schema/category.ts new file mode 100644 index 0000000..f8e1621 --- /dev/null +++ b/src/lib/server/db/schema/category.ts @@ -0,0 +1,52 @@ +import { + sqliteTable, + text, + integer, + foreignKey, + type AnySQLiteColumn, +} from "drizzle-orm/sqlite-core"; +import { mek } from "./mek"; +import { user } from "./user"; + +const ciphertext = (name: string) => + text(name, { mode: "json" }).$type<{ + ciphertext: string; // Base64 + iv: string; // Base64 + }>(); + +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], + }), + }), +); + +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"), +}); diff --git a/src/lib/server/db/schema/file.ts b/src/lib/server/db/schema/file.ts index 65c5471..9400ae9 100644 --- a/src/lib/server/db/schema/file.ts +++ b/src/lib/server/db/schema/file.ts @@ -1,4 +1,5 @@ -import { sqliteTable, text, integer, foreignKey } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core"; +import { category } from "./category"; import { hsk } from "./hsk"; import { mek } from "./mek"; import { user } from "./user"; @@ -82,6 +83,26 @@ export const fileLog = sqliteTable("file_log", { .notNull() .references(() => file.id, { onDelete: "cascade" }), timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(), - action: text("action", { enum: ["create", "rename"] }).notNull(), + action: text("action", { + enum: ["create", "rename", "addToCategory", "removeFromCategory"], + }).notNull(), newName: ciphertext("new_name"), + categoryId: integer("category_id").references(() => category.id, { onDelete: "set 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], + }), + }), +); diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 40cb9be..13aff6b 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -1,3 +1,4 @@ +export * from "./category"; export * from "./client"; export * from "./file"; export * from "./hsk";