diff --git a/kysely.config.ts b/kysely.config.ts index 1fe5a76..1d3b9a5 100644 --- a/kysely.config.ts +++ b/kysely.config.ts @@ -1,17 +1,17 @@ import { defineConfig } from "kysely-ctl"; -import { Pool } from "pg"; +import { createDialect } from "./src/lib/server/db/dialect"; + +// Empty values (e.g. `DATABASE_HOST=` in .env) are treated as unset, as in src/env.ts +const optional = (name: string) => process.env[name] || undefined; export default defineConfig({ - dialect: "pg", - dialectConfig: { - pool: new Pool({ - host: process.env.DATABASE_HOST, - port: process.env.DATABASE_PORT ? parseInt(process.env.DATABASE_PORT) : undefined, - user: process.env.DATABASE_USER, - password: process.env.DATABASE_PASSWORD, - database: process.env.DATABASE_NAME, - }), - }, + dialect: createDialect({ + host: optional("DATABASE_HOST"), + port: optional("DATABASE_PORT") ? Number(optional("DATABASE_PORT")) : undefined, + user: optional("DATABASE_USER"), + password: optional("DATABASE_PASSWORD"), + name: optional("DATABASE_NAME"), + }), migrations: { migrationFolder: "./src/lib/server/db/migrations", }, diff --git a/package.json b/package.json index f3e0dc8..d4d40ba 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "node-schedule": "^2.1.1", "pg": "^8.23.1", "superjson": "^2.2.6", - "uuid": "^14.0.3", "zod": "^4.6.5" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81dd0e7..c94737c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,6 @@ importers: superjson: specifier: ^2.2.6 version: 2.2.6 - uuid: - specifier: ^14.0.3 - version: 14.0.3 zod: specifier: ^4.6.5 version: 4.6.5 @@ -2470,10 +2467,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - uuid@14.0.3: - resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==} - hasBin: true - vite@8.3.4: resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4771,8 +4764,6 @@ snapshots: util-deprecate@1.0.2: {} - uuid@14.0.3: {} - vite@8.3.4(@types/node@26.6.5)(jiti@1.21.7)(yaml@2.9.1): dependencies: lightningcss: 1.33.0 diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2f56401..b78bae2 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,23 +1,21 @@ import { sequence, type ServerInit } from "@sveltejs/kit/hooks"; import schedule from "node-schedule"; -import { cleanupExpiredUserClientChallenges } from "#lib/server/db/client.js"; +import { ClientRepo, SessionRepo } from "#lib/server/db/index.js"; import { migrateDB } from "#lib/server/db/kysely.js"; -import { - cleanupExpiredSessions, - cleanupExpiredSessionUpgradeChallenges, -} from "#lib/server/db/session.js"; import { authenticate, setAgentInfo } from "#lib/server/middlewares/index.js"; -import { cleanupExpiredUploadSessions } from "#lib/server/services/upload.js"; +import { FileDeletionService, UploadService } from "#lib/server/services/index.js"; export const init: ServerInit = async () => { await migrateDB(); schedule.scheduleJob("0 * * * *", () => { - cleanupExpiredUserClientChallenges(); - cleanupExpiredSessions(); - cleanupExpiredSessionUpgradeChallenges(); - cleanupExpiredUploadSessions(); + ClientRepo.cleanupExpiredUserClientChallenges(); + SessionRepo.cleanupExpiredSessions(); + SessionRepo.cleanupExpiredSessionUpgradeChallenges(); + UploadService.cleanupExpiredUploadSessions(); + FileDeletionService.schedulePendingFileDeletions(); }); + FileDeletionService.schedulePendingFileDeletions(); }; export const handle = sequence(setAgentInfo, authenticate); diff --git a/src/lib/schemas/filesystem.ts b/src/lib/schemas/filesystem.ts deleted file mode 100644 index d3a45f4..0000000 --- a/src/lib/schemas/filesystem.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { z } from "zod"; - -export const DirectoryIdSchema = z.union([z.literal("root"), z.int().positive()]); -export const CategoryIdSchema = z.union([z.literal("root"), z.int().positive()]); diff --git a/src/lib/schemas/index.ts b/src/lib/schemas/index.ts deleted file mode 100644 index 7d29e5d..0000000 --- a/src/lib/schemas/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./filesystem"; diff --git a/src/lib/server/modules/auth.ts b/src/lib/server/auth.ts similarity index 56% rename from src/lib/server/modules/auth.ts rename to src/lib/server/auth.ts index 92ed984..7b75203 100644 --- a/src/lib/server/modules/auth.ts +++ b/src/lib/server/auth.ts @@ -1,7 +1,7 @@ -import { error } from "@sveltejs/kit"; import { ClientRepo, SessionRepo, IntegrityError } from "#lib/server/db/index.js"; -import env from "#lib/server/loadenv.js"; -import { verifySessionId } from "#lib/server/modules/crypto.js"; +import env from "#lib/server/config.js"; +import { verifySessionId } from "#lib/server/crypto.js"; +import { AppError } from "#lib/server/errors.js"; export interface Session { sessionId: string; @@ -26,16 +26,6 @@ export class AuthenticationError extends Error { } } -export class AuthorizationError extends Error { - constructor( - public status: 403 | 500, - message: string, - ) { - super(message); - this.name = "AuthorizationError"; - } -} - export const cookieOptions = { path: "/", maxAge: env.session.exp / 1000, @@ -64,56 +54,21 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen } }; -export const authorizeInternal = async ( - locals: App.Locals, - requiredPermission: SessionPermission, -): Promise => { - if (!locals.session) { - throw new AuthorizationError(500, "Unauthenticated"); +const ensureUserClientState = async ( + userId: number, + clientId: number | undefined, + state: "pending" | "active", +) => { + if (!clientId) { + throw new AppError("FORBIDDEN", "Forbidden"); } - const { id: sessionId, userId, clientId } = locals.session; - - switch (requiredPermission) { - case "any": - break; - case "notClient": - if (clientId) { - throw new AuthorizationError(403, "Forbidden"); - } - break; - case "anyClient": - if (!clientId) { - throw new AuthorizationError(403, "Forbidden"); - } - break; - case "pendingClient": { - if (!clientId) { - throw new AuthorizationError(403, "Forbidden"); - } - const userClient = await ClientRepo.getUserClient(userId, clientId); - if (!userClient) { - throw new AuthorizationError(500, "Invalid session id"); - } else if (userClient.state !== "pending") { - throw new AuthorizationError(403, "Forbidden"); - } - break; - } - case "activeClient": { - if (!clientId) { - throw new AuthorizationError(403, "Forbidden"); - } - const userClient = await ClientRepo.getUserClient(userId, clientId); - if (!userClient) { - throw new AuthorizationError(500, "Invalid session id"); - } else if (userClient.state !== "active") { - throw new AuthorizationError(403, "Forbidden"); - } - break; - } + const userClient = await ClientRepo.getUserClient(userId, clientId); + if (!userClient) { + throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id"); + } else if (userClient.state !== state) { + throw new AppError("FORBIDDEN", "Forbidden"); } - - return { sessionId, userId, clientId }; }; export async function authorize( @@ -130,12 +85,32 @@ export async function authorize( locals: App.Locals, requiredPermission: SessionPermission, ): Promise { - try { - return await authorizeInternal(locals, requiredPermission); - } catch (e) { - if (e instanceof AuthorizationError) { - error(e.status, e.message); - } - throw e; + if (!locals.session) { + throw new AppError("UNAUTHORIZED", "Unauthenticated"); } + + const { id: sessionId, userId, clientId } = locals.session; + + switch (requiredPermission) { + case "any": + break; + case "notClient": + if (clientId) { + throw new AppError("FORBIDDEN", "Forbidden"); + } + break; + case "anyClient": + if (!clientId) { + throw new AppError("FORBIDDEN", "Forbidden"); + } + break; + case "pendingClient": + await ensureUserClientState(userId, clientId, "pending"); + break; + case "activeClient": + await ensureUserClientState(userId, clientId, "active"); + break; + } + + return { sessionId, userId, clientId }; } diff --git a/src/lib/server/loadenv.ts b/src/lib/server/config.ts similarity index 100% rename from src/lib/server/loadenv.ts rename to src/lib/server/config.ts diff --git a/src/lib/server/modules/crypto.ts b/src/lib/server/crypto.ts similarity index 100% rename from src/lib/server/modules/crypto.ts rename to src/lib/server/crypto.ts diff --git a/src/lib/server/db/dialect.ts b/src/lib/server/db/dialect.ts new file mode 100644 index 0000000..cb86751 --- /dev/null +++ b/src/lib/server/db/dialect.ts @@ -0,0 +1,22 @@ +import { PostgresDialect } from "kysely"; +import pg from "pg"; + +export interface DatabaseConfig { + host?: string; + port?: number; + user?: string; + password?: string; + name?: string; +} + +export const createDialect = (config: DatabaseConfig) => { + return new PostgresDialect({ + pool: new pg.Pool({ + host: config.host, + port: config.port, + user: config.user, + password: config.password, + database: config.name, + }), + }); +}; diff --git a/src/lib/server/db/error.ts b/src/lib/server/db/error.ts index ea5d1b6..39b33fa 100644 --- a/src/lib/server/db/error.ts +++ b/src/lib/server/db/error.ts @@ -1,34 +1,41 @@ -type IntegrityErrorMessages = - // Category - | "Category not found" - // Challenge - | "Challenge already registered" - // Client - | "Public key(s) already registered" - | "User client already exists" - // File - | "Directory not found" - | "Directory already favorited" - | "Directory not favorited" - | "File not found" - | "File not found in category" - | "File already added to category" - | "File already favorited" - | "File not favorited" - | "Invalid DEK version" - // HSK - | "HSK already registered" - | "Inactive HSK version" - // MEK - | "MEK already registered" - | "Inactive MEK version" - // Session - | "Session not found" - | "Session already exists"; +import { AppError, type ErrorCode } from "#lib/server/errors.js"; -export class IntegrityError extends Error { - constructor(public message: IntegrityErrorMessages) { - super(message); +const errorCodes = { + // Category + "Category not found": "NOT_FOUND", + // Challenge + "Challenge already registered": "CONFLICT", + // Client + "Public key(s) already registered": "CONFLICT", + "User client already exists": "CONFLICT", + // File + "Directory not found": "NOT_FOUND", + "Directory already favorited": "BAD_REQUEST", + "Directory not favorited": "BAD_REQUEST", + "File not found": "NOT_FOUND", + "File not found in category": "BAD_REQUEST", + "File already added to category": "BAD_REQUEST", + "File already favorited": "BAD_REQUEST", + "File not favorited": "BAD_REQUEST", + "Invalid DEK version": "CONFLICT", + // HSK + "HSK already registered": "CONFLICT", + "Inactive HSK version": "BAD_REQUEST", + // MEK + "MEK already registered": "CONFLICT", + "Inactive MEK version": "BAD_REQUEST", + // Session + "Session not found": "UNAUTHORIZED", + "Session already exists": "CONFLICT", + // Upload + "Upload session not found": "NOT_FOUND", +} as const satisfies Record; + +type IntegrityErrorMessage = keyof typeof errorCodes; + +export class IntegrityError extends AppError { + constructor(public override readonly message: IntegrityErrorMessage) { + super(errorCodes[message], message); this.name = "IntegrityError"; } } diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index bc1dade..3bf3c1b 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -1,12 +1,14 @@ -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"; -export * as MekRepo from "./mek"; -export * as SessionRepo from "./session"; -export * as UploadRepo from "./upload"; -export * as UserRepo from "./user"; +export * as CategoryRepo from "./repositories/category"; +export * as ClientRepo from "./repositories/client"; +export * as DirectoryRepo from "./repositories/directory"; +export * as FileRepo from "./repositories/file"; +export * as FileDeletionRepo from "./repositories/fileDeletion"; +export * as HskRepo from "./repositories/hsk"; +export * as MediaRepo from "./repositories/media"; +export * as MekRepo from "./repositories/mek"; +export * as SessionRepo from "./repositories/session"; +export * as UploadRepo from "./repositories/upload"; +export * as UserRepo from "./repositories/user"; export * from "./error"; +export { default as db } from "./kysely"; diff --git a/src/lib/server/db/kysely.ts b/src/lib/server/db/kysely.ts index 70a6bf6..8469729 100644 --- a/src/lib/server/db/kysely.ts +++ b/src/lib/server/db/kysely.ts @@ -1,21 +1,11 @@ -import { Kysely, PostgresDialect } from "kysely"; +import { Kysely } from "kysely"; import { Migrator } from "kysely/migration"; -import pg from "pg"; -import env from "#lib/server/loadenv.js"; +import env from "#lib/server/config.js"; +import { createDialect } from "./dialect"; import migrations from "./migrations"; import type { Database } from "./schema"; -const dialect = new PostgresDialect({ - pool: new pg.Pool({ - host: env.database.host, - port: env.database.port, - user: env.database.user, - password: env.database.password, - database: env.database.name, - }), -}); - -const db = new Kysely({ dialect }); +const db = new Kysely({ dialect: createDialect(env.database) }); export const migrateDB = async () => { if (env.nodeEnv !== "production") return; diff --git a/src/lib/server/db/migrations/1791730000-AddFileDeletionQueue.ts b/src/lib/server/db/migrations/1791730000-AddFileDeletionQueue.ts new file mode 100644 index 0000000..208a27f --- /dev/null +++ b/src/lib/server/db/migrations/1791730000-AddFileDeletionQueue.ts @@ -0,0 +1,17 @@ +import { Kysely } from "kysely"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const up = async (db: Kysely) => { + // fileDeletion.ts + await db.schema + .createTable("file_deletion_queue") + .addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()) + .addColumn("path", "text", (col) => col.notNull()) + .addColumn("enqueued_at", "timestamp(3)", (col) => col.notNull()) + .execute(); +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const down = async (db: Kysely) => { + await db.schema.dropTable("file_deletion_queue").execute(); +}; diff --git a/src/lib/server/db/migrations/index.ts b/src/lib/server/db/migrations/index.ts index 363a394..9aa5c7e 100644 --- a/src/lib/server/db/migrations/index.ts +++ b/src/lib/server/db/migrations/index.ts @@ -3,6 +3,7 @@ import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory"; import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail"; import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload"; import * as AddFavorites1768643000 from "./1768643000-AddFavorites"; +import * as AddFileDeletionQueue1791730000 from "./1791730000-AddFileDeletionQueue"; export default { "1737357000-Initial": Initial1737357000, @@ -10,4 +11,5 @@ export default { "1738409340-AddThumbnail": AddThumbnail1738409340, "1768062380-AddChunkedUpload": AddChunkedUpload1768062380, "1768643000-AddFavorites": AddFavorites1768643000, + "1791730000-AddFileDeletionQueue": AddFileDeletionQueue1791730000, }; diff --git a/src/lib/server/db/category.ts b/src/lib/server/db/repositories/category.ts similarity index 73% rename from src/lib/server/db/category.ts rename to src/lib/server/db/repositories/category.ts index 13dd418..b144899 100644 --- a/src/lib/server/db/category.ts +++ b/src/lib/server/db/repositories/category.ts @@ -1,8 +1,10 @@ -import { IntegrityError } from "./error"; -import db from "./kysely"; -import type { Ciphertext } from "./schema"; +import type { Selectable } from "kysely"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import type { CategoryTable, Ciphertext } from "../schema"; +import { lockActiveMekVersion } from "./mek"; -interface Category { +export interface Category { id: number; parentId: CategoryId; userId: number; @@ -14,6 +16,21 @@ interface Category { export type NewCategory = Omit; +// Timestamps are serialized as strings when the row is selected as JSON +type CategoryRow = Omit, "data_encryption_key_version"> & { + data_encryption_key_version: Date | string; +}; + +export const toCategory = (row: CategoryRow): Category => ({ + 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: new Date(row.data_encryption_key_version), + encName: row.encrypted_name, +}); + export const ensureCategoryExists = async ( trx: typeof db, userId: number, @@ -36,17 +53,7 @@ export const ensureCategoryExists = async ( 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"); - } + await lockActiveMekVersion(trx, params.userId, params.mekVersion); await ensureCategoryExists(trx, params.userId, params.parentId); @@ -81,18 +88,7 @@ export const getAllCategoriesByParent = async (userId: number, parentId: Categor ? 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, - ); + return categories.map(toCategory); }; export const getCategory = async (userId: number, categoryId: number) => { @@ -103,17 +99,7 @@ export const getCategory = async (userId: number, categoryId: number) => { .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; + return category ? toCategory(category) : null; }; export const setCategoryEncName = async ( diff --git a/src/lib/server/db/client.ts b/src/lib/server/db/repositories/client.ts similarity index 97% rename from src/lib/server/db/client.ts rename to src/lib/server/db/repositories/client.ts index 272df61..0e408e2 100644 --- a/src/lib/server/db/client.ts +++ b/src/lib/server/db/repositories/client.ts @@ -1,7 +1,7 @@ import pg from "pg"; -import { IntegrityError } from "./error"; -import db from "./kysely"; -import type { UserClientState } from "./schema"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import type { UserClientState } from "../schema"; interface Client { id: number; diff --git a/src/lib/server/db/directory.ts b/src/lib/server/db/repositories/directory.ts similarity index 77% rename from src/lib/server/db/directory.ts rename to src/lib/server/db/repositories/directory.ts index c168edd..74183dc 100644 --- a/src/lib/server/db/directory.ts +++ b/src/lib/server/db/repositories/directory.ts @@ -1,9 +1,11 @@ import type { Selectable } from "kysely"; -import { IntegrityError } from "./error"; -import db from "./kysely"; -import type { Ciphertext, DirectoryTable } from "./schema"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import type { Ciphertext, DirectoryTable } from "../schema"; +import { enqueueFileDeletions } from "./fileDeletion"; +import { lockActiveMekVersion } from "./mek"; -interface Directory { +export interface Directory { id: number; parentId: DirectoryId; userId: number; @@ -47,17 +49,7 @@ export const ensureDirectoryExists = async ( 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"); - } + await lockActiveMekVersion(trx, params.userId, params.mekVersion); await ensureDirectoryExists(trx, params.userId, params.parentId); @@ -251,50 +243,59 @@ export const setDirectoryFavorite = async ( }; 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 + return await db.transaction().execute(async (trx) => { + const directories = await trx + .withRecursive("directory_tree", (db) => + db .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); - }); + .unionAll( + db + .selectFrom("directory as d") + .innerJoin("directory_tree as dt", "d.parent_id", "dt.id") + .select("d.id"), + ), + ) + .selectFrom("directory") + .select("id") + .where("id", "in", (eb) => eb.selectFrom("directory_tree").select("id")) + .forUpdate() + .execute(); + if (directories.length === 0) { + throw new IntegrityError("Directory not found"); + } + + const directoryIds = directories.map(({ id }) => id); + 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", "in", directoryIds) + .forUpdate("file") + .execute(); + const fileIds = files.map(({ id }) => id); + + const uploadSessions = await trx + .deleteFrom("upload_session") + .where((eb) => + eb.or([ + eb("parent_id", "in", directoryIds), + ...(fileIds.length > 0 ? [eb("file_id", "in", fileIds)] : []), + ]), + ) + .returning("path") + .execute(); + if (fileIds.length > 0) { + await trx.deleteFrom("file").where("id", "in", fileIds).execute(); + } + await trx.deleteFrom("directory").where("id", "in", directoryIds).execute(); + + await enqueueFileDeletions(trx, [ + ...files.flatMap(({ path, thumbnailPath }) => [path, thumbnailPath]), + ...uploadSessions.map(({ path }) => path), + ]); + return { deletedFileIds: fileIds }; + }); }; diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/repositories/file.ts similarity index 92% rename from src/lib/server/db/file.ts rename to src/lib/server/db/repositories/file.ts index 68f5b0c..e8ee131 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/repositories/file.ts @@ -1,11 +1,13 @@ 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, FileTable } from "./schema"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import type { Ciphertext, FileTable } from "../schema"; +import { toCategory } from "./category"; +import { enqueueFileDeletions } from "./fileDeletion"; -interface File { +export interface File { id: number; parentId: DirectoryId; userId: number; @@ -24,15 +26,6 @@ interface File { isFavorite: boolean; } -interface FileCategory { - id: number; - parentId: CategoryId; - mekVersion: number; - encDek: string; - dekVersion: Date; - encName: Ciphertext; -} - const toFile = (row: Selectable): File => ({ id: row.id, parentId: row.parent_id ?? "root", @@ -209,17 +202,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[]) .execute(); 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 FileCategory, - ), + categories: file.categories.map(toCategory), })); }; @@ -387,7 +370,7 @@ export const setFileEncName = async ( }; export const unregisterFile = async (userId: number, fileId: number) => { - return await db.transaction().execute(async (trx) => { + await db.transaction().execute(async (trx) => { const file = await trx .selectFrom("file") .leftJoin("thumbnail", "file.id", "thumbnail.file_id") @@ -400,8 +383,17 @@ export const unregisterFile = async (userId: number, fileId: number) => { throw new IntegrityError("File not found"); } + const uploadSessions = await trx + .deleteFrom("upload_session") + .where("file_id", "=", fileId) + .returning("path") + .execute(); await trx.deleteFrom("file").where("id", "=", fileId).execute(); - return file; + await enqueueFileDeletions(trx, [ + file.path, + file.thumbnailPath, + ...uploadSessions.map(({ path }) => path), + ]); }); }; @@ -437,17 +429,7 @@ export const getAllFileCategories = async (fileId: number) => { .selectAll("category") .where("file_id", "=", fileId) .execute(); - return categories.map( - (category) => - ({ - id: category.id, - parentId: category.parent_id ?? "root", - mekVersion: category.master_encryption_key_version, - encDek: category.encrypted_data_encryption_key, - dekVersion: category.data_encryption_key_version, - encName: category.encrypted_name, - }) satisfies FileCategory, - ); + return categories.map(toCategory); }; export const removeFileFromCategory = async (fileId: number, categoryId: number) => { diff --git a/src/lib/server/db/repositories/fileDeletion.ts b/src/lib/server/db/repositories/fileDeletion.ts new file mode 100644 index 0000000..8cdbf24 --- /dev/null +++ b/src/lib/server/db/repositories/fileDeletion.ts @@ -0,0 +1,28 @@ +import db from "../kysely"; + +export const enqueueFileDeletions = async ( + trx: typeof db, + paths: (string | null | undefined)[], +) => { + const values = paths + .filter((path) => !!path) + .map((path) => ({ path: path!, enqueued_at: new Date() })); + if (values.length > 0) { + await trx.insertInto("file_deletion_queue").values(values).execute(); + } +}; + +export const getPendingFileDeletions = async (limit: number) => { + return await db + .selectFrom("file_deletion_queue") + .select(["id", "path"]) + .orderBy("id") + .limit(limit) + .execute(); +}; + +export const completeFileDeletions = async (ids: number[]) => { + if (ids.length > 0) { + await db.deleteFrom("file_deletion_queue").where("id", "in", ids).execute(); + } +}; diff --git a/src/lib/server/db/hsk.ts b/src/lib/server/db/repositories/hsk.ts similarity index 75% rename from src/lib/server/db/hsk.ts rename to src/lib/server/db/repositories/hsk.ts index 4673cae..9be9425 100644 --- a/src/lib/server/db/hsk.ts +++ b/src/lib/server/db/repositories/hsk.ts @@ -1,7 +1,7 @@ import pg from "pg"; -import { IntegrityError } from "./error"; -import db from "./kysely"; -import type { HskState } from "./schema"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import type { HskState } from "../schema"; interface Hsk { userId: number; @@ -11,6 +11,20 @@ interface Hsk { encHsk: string; } +export const lockActiveHskVersion = async (trx: typeof db, userId: number, version: number) => { + const hsk = await trx + .selectFrom("hmac_secret_key") + .select("version") + .where("user_id", "=", userId) + .where("state", "=", "active") + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (hsk?.version !== version) { + throw new IntegrityError("Inactive HSK version"); + } +}; + export const registerInitialHsk = async ( userId: number, createdBy: number, diff --git a/src/lib/server/db/media.ts b/src/lib/server/db/repositories/media.ts similarity index 91% rename from src/lib/server/db/media.ts rename to src/lib/server/db/repositories/media.ts index 9a080ab..e138a87 100644 --- a/src/lib/server/db/media.ts +++ b/src/lib/server/db/repositories/media.ts @@ -1,6 +1,7 @@ import type { NotNull } from "kysely"; -import { IntegrityError } from "./error"; -import db from "./kysely"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import { enqueueFileDeletions } from "./fileDeletion"; interface Thumbnail { id: number; @@ -60,7 +61,7 @@ export const updateFileThumbnail = async ( }), ) .execute(); - return thumbnail?.oldPath ?? null; + await enqueueFileDeletions(trx, [thumbnail?.oldPath]); }; export const getFileThumbnail = async (userId: number, fileId: number) => { diff --git a/src/lib/server/db/mek.ts b/src/lib/server/db/repositories/mek.ts similarity index 81% rename from src/lib/server/db/mek.ts rename to src/lib/server/db/repositories/mek.ts index 7f4f5f2..cc5f2dc 100644 --- a/src/lib/server/db/mek.ts +++ b/src/lib/server/db/repositories/mek.ts @@ -1,7 +1,7 @@ import pg from "pg"; -import { IntegrityError } from "./error"; -import db from "./kysely"; -import type { MekState } from "./schema"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import type { MekState } from "../schema"; interface Mek { userId: number; @@ -15,6 +15,20 @@ interface ClientMekWithDetails extends Mek { encMekSig: string; } +export const lockActiveMekVersion = async (trx: typeof db, userId: number, version: number) => { + const mek = await trx + .selectFrom("master_encryption_key") + .select("version") + .where("user_id", "=", userId) + .where("state", "=", "active") + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (mek?.version !== version) { + throw new IntegrityError("Inactive MEK version"); + } +}; + export const registerInitialMek = async ( userId: number, createdBy: number, diff --git a/src/lib/server/db/session.ts b/src/lib/server/db/repositories/session.ts similarity index 97% rename from src/lib/server/db/session.ts rename to src/lib/server/db/repositories/session.ts index 76ab06d..58a255f 100644 --- a/src/lib/server/db/session.ts +++ b/src/lib/server/db/repositories/session.ts @@ -1,7 +1,7 @@ import pg from "pg"; -import env from "#lib/server/loadenv.js"; -import { IntegrityError } from "./error"; -import db from "./kysely"; +import env from "#lib/server/config.js"; +import { IntegrityError } from "../error"; +import db from "../kysely"; export const createSession = async ( userId: number, diff --git a/src/lib/server/db/upload.ts b/src/lib/server/db/repositories/upload.ts similarity index 72% rename from src/lib/server/db/upload.ts rename to src/lib/server/db/repositories/upload.ts index d07450b..1bd361f 100644 --- a/src/lib/server/db/upload.ts +++ b/src/lib/server/db/repositories/upload.ts @@ -1,8 +1,11 @@ -import { sql } from "kysely"; -import { IntegrityError } from "./error"; +import { sql, type Selectable } from "kysely"; +import { IntegrityError } from "../error"; +import db from "../kysely"; +import type { Ciphertext, UploadSessionTable } from "../schema"; import { ensureDirectoryExists } from "./directory"; -import db from "./kysely"; -import type { Ciphertext } from "./schema"; +import { enqueueFileDeletions } from "./fileDeletion"; +import { lockActiveHskVersion } from "./hsk"; +import { lockActiveMekVersion } from "./mek"; interface BaseUploadSession { id: string; @@ -37,30 +40,10 @@ export const createFileUploadSession = 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"); - } + await lockActiveMekVersion(trx, params.userId, params.mekVersion); if (params.hskVersion) { - const hsk = await trx - .selectFrom("hmac_secret_key") - .select("version") - .where("user_id", "=", params.userId) - .where("state", "=", "active") - .limit(1) - .forUpdate() - .executeTakeFirst(); - if (hsk?.version !== params.hskVersion) { - throw new IntegrityError("Inactive HSK version"); - } + await lockActiveHskVersion(trx, params.userId, params.hskVersion); } await ensureDirectoryExists(trx, params.userId, params.parentId); @@ -124,18 +107,8 @@ export const createThumbnailUploadSession = async ( }); }; -export const getUploadSession = async (sessionId: string, userId: number) => { - const session = await db - .selectFrom("upload_session") - .selectAll() - .where("id", "=", sessionId) - .where("user_id", "=", userId) - .where("expires_at", ">", new Date()) - .limit(1) - .executeTakeFirst(); - if (!session) { - return null; - } else if (session.type === "file") { +const toUploadSession = (session: Selectable) => { + if (session.type === "file") { return { type: "file", id: session.id, @@ -171,6 +144,35 @@ export const getUploadSession = async (sessionId: string, userId: number) => { } }; +export const getUploadSession = async (sessionId: string, userId: number) => { + const session = await db + .selectFrom("upload_session") + .selectAll() + .where("id", "=", sessionId) + .where("user_id", "=", userId) + .where("expires_at", ">", new Date()) + .limit(1) + .executeTakeFirst(); + return session ? toUploadSession(session) : null; +}; + +// Locks the session until the transaction ends so that it can be completed only once +export const lockUploadSession = async (trx: typeof db, sessionId: string, userId: number) => { + const session = await trx + .selectFrom("upload_session") + .selectAll() + .where("id", "=", sessionId) + .where("user_id", "=", userId) + .where("expires_at", ">", new Date()) + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (!session) { + throw new IntegrityError("Upload session not found"); + } + return toUploadSession(session); +}; + export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => { await db .updateTable("upload_session") @@ -185,11 +187,30 @@ export const deleteUploadSession = async (trx: typeof db, sessionId: string) => await trx.deleteFrom("upload_session").where("id", "=", sessionId).execute(); }; +export const discardUploadSession = async (sessionId: string) => { + await db.transaction().execute(async (trx) => { + const sessions = await trx + .deleteFrom("upload_session") + .where("id", "=", sessionId) + .returning("path") + .execute(); + await enqueueFileDeletions( + trx, + sessions.map(({ path }) => path), + ); + }); +}; + export const cleanupExpiredUploadSessions = async () => { - const sessions = await db - .deleteFrom("upload_session") - .where("expires_at", "<=", new Date()) - .returning("path") - .execute(); - return sessions.map(({ path }) => path); + await db.transaction().execute(async (trx) => { + const sessions = await trx + .deleteFrom("upload_session") + .where("expires_at", "<=", new Date()) + .returning("path") + .execute(); + await enqueueFileDeletions( + trx, + sessions.map(({ path }) => path), + ); + }); }; diff --git a/src/lib/server/db/user.ts b/src/lib/server/db/repositories/user.ts similarity index 96% rename from src/lib/server/db/user.ts rename to src/lib/server/db/repositories/user.ts index f718d4f..b51c25e 100644 --- a/src/lib/server/db/user.ts +++ b/src/lib/server/db/repositories/user.ts @@ -1,4 +1,4 @@ -import db from "./kysely"; +import db from "../kysely"; interface User { id: number; diff --git a/src/lib/server/db/schema/fileDeletion.ts b/src/lib/server/db/schema/fileDeletion.ts new file mode 100644 index 0000000..8348bd6 --- /dev/null +++ b/src/lib/server/db/schema/fileDeletion.ts @@ -0,0 +1,13 @@ +import type { ColumnType, Generated } from "kysely"; + +export interface FileDeletionQueueTable { + id: Generated; + path: string; + enqueued_at: ColumnType; +} + +declare module "./index" { + interface Database { + file_deletion_queue: FileDeletionQueueTable; + } +} diff --git a/src/lib/server/db/schema/index.ts b/src/lib/server/db/schema/index.ts index 5c31e5f..9265ccd 100644 --- a/src/lib/server/db/schema/index.ts +++ b/src/lib/server/db/schema/index.ts @@ -2,6 +2,7 @@ export * from "./category"; export * from "./client"; export * from "./directory"; export * from "./file"; +export * from "./fileDeletion"; export * from "./hsk"; export * from "./media"; export * from "./mek"; diff --git a/src/lib/server/errors.ts b/src/lib/server/errors.ts new file mode 100644 index 0000000..270fb16 --- /dev/null +++ b/src/lib/server/errors.ts @@ -0,0 +1,33 @@ +export type ErrorCode = + | "BAD_REQUEST" + | "UNAUTHORIZED" + | "FORBIDDEN" + | "NOT_FOUND" + | "CONFLICT" + | "RANGE_NOT_SATISFIABLE" + | "INTERNAL_SERVER_ERROR"; + +const httpStatuses = { + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + RANGE_NOT_SATISFIABLE: 416, + INTERNAL_SERVER_ERROR: 500, +} as const satisfies Record; + +// Expected errors that are safe to expose to clients +export class AppError extends Error { + constructor( + public readonly code: ErrorCode, + message: string, + ) { + super(message); + this.name = "AppError"; + } + + get httpStatus() { + return httpStatuses[this.code]; + } +} diff --git a/src/lib/server/http.ts b/src/lib/server/http.ts new file mode 100644 index 0000000..87f317f --- /dev/null +++ b/src/lib/server/http.ts @@ -0,0 +1,18 @@ +import { error, type RequestEvent } from "@sveltejs/kit"; +import { AppError } from "#lib/server/errors.js"; + +// Converts `AppError`s thrown by services into HTTP errors +export const handleAppErrors = ( + handler: (event: Event) => Promise, +): ((event: Event) => Promise) => { + return async (event) => { + try { + return await handler(event); + } catch (e) { + if (e instanceof AppError) { + error(e.httpStatus, e.message); + } + throw e; + } + }; +}; diff --git a/src/lib/server/middlewares/authenticate.ts b/src/lib/server/middlewares/authenticate.ts index 02fcc40..64031db 100644 --- a/src/lib/server/middlewares/authenticate.ts +++ b/src/lib/server/middlewares/authenticate.ts @@ -1,6 +1,6 @@ import { error, redirect } from "@sveltejs/kit"; import type { Handle } from "@sveltejs/kit/hooks"; -import { cookieOptions, authenticate, AuthenticationError } from "#lib/server/modules/auth.js"; +import { cookieOptions, authenticate, AuthenticationError } from "#lib/server/auth.js"; export const authenticateMiddleware: Handle = async ({ event, resolve }) => { try { diff --git a/src/lib/server/modules/filesystem.ts b/src/lib/server/modules/filesystem.ts deleted file mode 100644 index 65cb9ec..0000000 --- a/src/lib/server/modules/filesystem.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { unlink } from "fs/promises"; - -export const safeUnlink = async (path: string | null | undefined) => { - if (path) { - await unlink(path).catch(console.error); - } -}; diff --git a/src/lib/server/services/auth.ts b/src/lib/server/services/auth.ts new file mode 100644 index 0000000..70fc787 --- /dev/null +++ b/src/lib/server/services/auth.ts @@ -0,0 +1,99 @@ +import argon2 from "argon2"; +import { ClientRepo, IntegrityError, SessionRepo, UserRepo } from "#lib/server/db/index.js"; +import env from "#lib/server/config.js"; +import { issueSessionId } from "#lib/server/crypto.js"; +import { AppError } from "#lib/server/errors.js"; +import { createChallenge, verifyChallengeAnswer } from "./client"; + +const MIN_PASSWORD_LENGTH = 8; + +export const login = async (email: string, password: string, ip: string, userAgent: string) => { + const user = await UserRepo.getUserByEmail(email); + if (!user || !(await argon2.verify(user.password, password))) { + throw new AppError("UNAUTHORIZED", "Invalid email or password"); + } + + const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); + await SessionRepo.createSession(user.id, sessionId, ip, userAgent); + return { sessionIdSigned }; +}; + +export const logout = async (sessionId: string) => { + await SessionRepo.deleteSession(sessionId); +}; + +export const changePassword = async ( + userId: number, + sessionId: string, + oldPassword: string, + newPassword: string, +) => { + if (oldPassword === newPassword) { + throw new AppError("BAD_REQUEST", "Same passwords"); + } else if (newPassword.length < MIN_PASSWORD_LENGTH) { + throw new AppError("BAD_REQUEST", "Too short password"); + } + + const user = await UserRepo.getUser(userId); + if (!user) { + throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id"); + } else if (!(await argon2.verify(user.password, oldPassword))) { + throw new AppError("FORBIDDEN", "Invalid password"); + } + + await UserRepo.setUserPassword(userId, await argon2.hash(newPassword)); + await SessionRepo.deleteAllOtherSessions(userId, sessionId); +}; + +export const requestSessionUpgrade = async ( + userId: number, + sessionId: string, + ip: string, + encPubKey: string, + sigPubKey: string, +) => { + const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey); + if (!client) { + throw new AppError("UNAUTHORIZED", "Invalid public key(s)"); + } + + const userClient = await ClientRepo.getUserClient(userId, client.id); + if (!userClient || userClient.state === "challenging") { + throw new AppError("FORBIDDEN", "Unregistered client"); + } + + return await createChallenge(encPubKey, (answer) => + SessionRepo.registerSessionUpgradeChallenge( + sessionId, + client.id, + answer, + ip, + new Date(Date.now() + env.challenge.sessionUpgradeExp), + ), + ); +}; + +export const verifySessionUpgrade = async ( + userId: number, + sessionId: string, + ip: string, + challengeId: number, + answerSig: string, + force: boolean, +) => { + const challenge = await SessionRepo.consumeSessionUpgradeChallenge(challengeId, sessionId, ip); + const client = await verifyChallengeAnswer(challenge, answerSig); + + try { + await SessionRepo.upgradeSession(userId, sessionId, client.id, force); + } catch (e) { + if (e instanceof IntegrityError) { + if (e.message === "Session not found") { + throw new AppError("INTERNAL_SERVER_ERROR", "Invalid challenge answer"); + } else if (e.message === "Session already exists") { + throw new AppError("CONFLICT", "Already logged in"); + } + } + throw e; + } +}; diff --git a/src/lib/server/services/category.ts b/src/lib/server/services/category.ts new file mode 100644 index 0000000..52b821c --- /dev/null +++ b/src/lib/server/services/category.ts @@ -0,0 +1,62 @@ +import { CategoryRepo, FileRepo } from "#lib/server/db/index.js"; +import type { Ciphertext } from "#lib/server/db/schema/index.js"; +import { AppError } from "#lib/server/errors.js"; +import { ensureFreshDekVersion } from "./common"; + +export const getCategory = async (userId: number, categoryId: CategoryId, recurse: boolean) => { + const category = + categoryId !== "root" ? await CategoryRepo.getCategory(userId, categoryId) : undefined; + if (category === null) { + throw new AppError("NOT_FOUND", "Category not found"); + } + + const [subCategories, files] = await Promise.all([ + CategoryRepo.getAllCategoriesByParent(userId, categoryId), + categoryId !== "root" ? FileRepo.getAllFilesByCategory(userId, categoryId, recurse) : undefined, + ]); + return { category, subCategories, files }; +}; + +export const createCategory = async (params: CategoryRepo.NewCategory) => { + ensureFreshDekVersion(params.dekVersion); + await CategoryRepo.registerCategory(params); +}; + +export const renameCategory = async ( + userId: number, + categoryId: number, + dekVersion: Date, + encName: Ciphertext, +) => { + await CategoryRepo.setCategoryEncName(userId, categoryId, dekVersion, encName); +}; + +export const deleteCategory = async (userId: number, categoryId: number) => { + await CategoryRepo.unregisterCategory(userId, categoryId); +}; + +const ensureCategoryAndFileExist = async (userId: number, categoryId: number, fileId: number) => { + const [category, file] = await Promise.all([ + CategoryRepo.getCategory(userId, categoryId), + FileRepo.getFile(userId, fileId), + ]); + if (!category) { + throw new AppError("NOT_FOUND", "Category not found"); + } else if (!file) { + throw new AppError("NOT_FOUND", "File not found"); + } +}; + +export const addFileToCategory = async (userId: number, categoryId: number, fileId: number) => { + await ensureCategoryAndFileExist(userId, categoryId, fileId); + await FileRepo.addFileToCategory(fileId, categoryId); +}; + +export const removeFileFromCategory = async ( + userId: number, + categoryId: number, + fileId: number, +) => { + await ensureCategoryAndFileExist(userId, categoryId, fileId); + await FileRepo.removeFileFromCategory(fileId, categoryId); +}; diff --git a/src/lib/server/services/client.ts b/src/lib/server/services/client.ts new file mode 100644 index 0000000..46856e3 --- /dev/null +++ b/src/lib/server/services/client.ts @@ -0,0 +1,76 @@ +import { ClientRepo } from "#lib/server/db/index.js"; +import env from "#lib/server/config.js"; +import { generateChallenge, verifyPubKey, verifySignature } from "#lib/server/crypto.js"; +import { AppError } from "#lib/server/errors.js"; + +const CHALLENGE_LENGTH = 32; + +export const createChallenge = async ( + encPubKey: string, + register: (answer: string) => Promise<{ id: number }>, +) => { + const { answer, challenge } = await generateChallenge(CHALLENGE_LENGTH, encPubKey); + const { id } = await register(answer.toString("base64")); + return { id, challenge: challenge.toString("base64") }; +}; + +export const verifyChallengeAnswer = async ( + challenge: { clientId: number; answer: string } | null, + answerSig: string, +) => { + if (!challenge) { + throw new AppError("FORBIDDEN", "Invalid challenge answer"); + } + + const client = await ClientRepo.getClient(challenge.clientId); + if (!client) { + throw new AppError("INTERNAL_SERVER_ERROR", "Invalid challenge answer"); + } else if ( + !verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey) + ) { + throw new AppError("FORBIDDEN", "Invalid challenge answer signature"); + } + return client; +}; + +export const registerClient = async ( + userId: number, + ip: string, + encPubKey: string, + sigPubKey: string, +) => { + const getClientId = async () => { + const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey); + if (client) { + await ClientRepo.createUserClient(userId, client.id); + return client.id; + } else if (encPubKey === sigPubKey) { + throw new AppError("BAD_REQUEST", "Same public keys"); + } else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) { + throw new AppError("BAD_REQUEST", "Invalid public key(s)"); + } + return (await ClientRepo.createClient(encPubKey, sigPubKey, userId)).id; + }; + + const clientId = await getClientId(); + return await createChallenge(encPubKey, (answer) => + ClientRepo.registerUserClientChallenge( + userId, + clientId, + answer, + ip, + new Date(Date.now() + env.challenge.userClientExp), + ), + ); +}; + +export const verifyClient = async ( + userId: number, + ip: string, + challengeId: number, + answerSig: string, +) => { + const challenge = await ClientRepo.consumeUserClientChallenge(challengeId, userId, ip); + const client = await verifyChallengeAnswer(challenge, answerSig); + await ClientRepo.setUserClientStateToPending(userId, client.id); +}; diff --git a/src/lib/server/services/common.ts b/src/lib/server/services/common.ts new file mode 100644 index 0000000..62d0788 --- /dev/null +++ b/src/lib/server/services/common.ts @@ -0,0 +1,10 @@ +import { AppError } from "#lib/server/errors.js"; + +const DEK_VERSION_TOLERANCE = 60 * 1000; // 1 minute + +// DEK versions are creation timestamps generated by clients +export const ensureFreshDekVersion = (dekVersion: Date) => { + if (Math.abs(dekVersion.getTime() - Date.now()) >= DEK_VERSION_TOLERANCE) { + throw new AppError("BAD_REQUEST", "Invalid DEK version"); + } +}; diff --git a/src/lib/server/services/directory.ts b/src/lib/server/services/directory.ts new file mode 100644 index 0000000..1dee8ac --- /dev/null +++ b/src/lib/server/services/directory.ts @@ -0,0 +1,58 @@ +import { DirectoryRepo, FileRepo } from "#lib/server/db/index.js"; +import type { Ciphertext } from "#lib/server/db/schema/index.js"; +import { AppError } from "#lib/server/errors.js"; +import { ensureFreshDekVersion } from "./common"; +import { schedulePendingFileDeletions } from "./fileDeletion"; + +export const getDirectory = async (userId: number, directoryId: DirectoryId) => { + const directory = + directoryId !== "root" ? await DirectoryRepo.getDirectory(userId, directoryId) : undefined; + if (directory === null) { + throw new AppError("NOT_FOUND", "Directory not found"); + } + + const [subDirectories, files] = await Promise.all([ + DirectoryRepo.getAllDirectoriesByParent(userId, directoryId), + FileRepo.getAllFilesByParent(userId, directoryId), + ]); + return { directory, subDirectories, files }; +}; + +export const createDirectory = async (params: { + userId: number; + parentId: DirectoryId; + mekVersion: number; + encDek: string; + dekVersion: Date; + encName: Ciphertext; +}) => { + ensureFreshDekVersion(params.dekVersion); + await DirectoryRepo.registerDirectory(params); +}; + +export const renameDirectory = async ( + userId: number, + directoryId: number, + dekVersion: Date, + encName: Ciphertext, +) => { + await DirectoryRepo.setDirectoryEncName(userId, directoryId, dekVersion, encName); +}; + +export const deleteDirectory = async (userId: number, directoryId: number) => { + const { deletedFileIds } = await DirectoryRepo.unregisterDirectory(userId, directoryId); + schedulePendingFileDeletions(); + return { deletedFileIds }; +}; + +export const getFavoriteDirectories = async (userId: number) => { + return await DirectoryRepo.getAllFavoriteDirectories(userId); +}; + +export const setDirectoryFavorite = async ( + userId: number, + directoryId: number, + isFavorite: boolean, +) => { + await DirectoryRepo.setDirectoryFavorite(userId, directoryId, isFavorite); +}; diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index b84eb6a..a95ac3f 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -1,75 +1,82 @@ -import { error } from "@sveltejs/kit"; -import { createReadStream } from "fs"; -import { stat } from "fs/promises"; -import { Readable } from "stream"; import { FileRepo, MediaRepo } from "#lib/server/db/index.js"; +import type { Ciphertext } from "#lib/server/db/schema/index.js"; +import { AppError } from "#lib/server/errors.js"; +import { createEncContentStream } from "#lib/server/storage/index.js"; +import { schedulePendingFileDeletions } from "./fileDeletion"; -export const createEncContentStream = async ( - path: string, - iv?: Buffer, - range?: { start?: number; end?: number }, -) => { - const { size: fileSize } = await stat(path); - const ivSize = iv?.byteLength ?? 0; - const totalSize = fileSize + ivSize; +type Range = { start?: number; end?: number }; - const start = range?.start ?? 0; - const end = range?.end ?? totalSize - 1; - if (start > end || start < 0 || end >= totalSize) { - error(416, "Invalid range"); - } - - return { - encContentStream: Readable.toWeb( - Readable.from( - (async function* () { - if (start < ivSize) { - yield iv!.subarray(start, Math.min(end + 1, ivSize)); - } - if (end >= ivSize) { - yield* createReadStream(path, { - start: Math.max(0, start - ivSize), - end: end - ivSize, - }); - } - })(), - ), - ), - range: { start, end, total: totalSize }, - isRangeRequest: !!range, - }; -}; - -export const getFileStream = async ( - userId: number, - fileId: number, - range?: { start?: number; end?: number }, -) => { +export const getFile = async (userId: number, fileId: number) => { const file = await FileRepo.getFile(userId, fileId); if (!file) { - error(404, "Invalid file id"); + throw new AppError("NOT_FOUND", "File not found"); } - return createEncContentStream( - file.path, - file.encContentIv ? Buffer.from(file.encContentIv, "base64") : undefined, - range, - ); + const categories = await FileRepo.getAllFileCategories(fileId); + return { ...file, categories }; }; -export const getFileThumbnailStream = async ( +export const getFiles = async (userId: number, fileIds: number[]) => { + return await FileRepo.getFilesWithCategories(userId, fileIds); +}; + +export const getAllFileIds = async (userId: number) => { + return await FileRepo.getAllFileIds(userId); +}; + +export const getFileIdsByContentHmac = async ( + userId: number, + hskVersion: number, + contentHmac: string, +) => { + return await FileRepo.getAllFileIdsByContentHmac(userId, hskVersion, contentHmac); +}; + +export const getFilesWithoutThumbnail = async (userId: number) => { + return await FileRepo.getFilesWithoutThumbnail(userId); +}; + +export const renameFile = async ( userId: number, fileId: number, - range?: { start?: number; end?: number }, + dekVersion: Date, + encName: Ciphertext, ) => { + await FileRepo.setFileEncName(userId, fileId, dekVersion, encName); +}; + +export const deleteFile = async (userId: number, fileId: number) => { + await FileRepo.unregisterFile(userId, fileId); + schedulePendingFileDeletions(); +}; + +export const getFavoriteFiles = async (userId: number) => { + return await FileRepo.getAllFavoriteFiles(userId); +}; + +export const setFileFavorite = async (userId: number, fileId: number, isFavorite: boolean) => { + await FileRepo.setFileFavorite(userId, fileId, isFavorite); +}; + +export const getFileThumbnail = async (userId: number, fileId: number) => { const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId); if (!thumbnail) { - error(404, "File or its thumbnail not found"); + throw new AppError("NOT_FOUND", "File or its thumbnail not found"); } + return thumbnail; +}; - return createEncContentStream( - thumbnail.path, - thumbnail.encContentIv ? Buffer.from(thumbnail.encContentIv, "base64") : undefined, - range, - ); +const decodeIv = (iv: string | null) => (iv ? Buffer.from(iv, "base64") : undefined); + +export const getFileStream = async (userId: number, fileId: number, range?: Range) => { + const file = await FileRepo.getFile(userId, fileId); + if (!file) { + throw new AppError("NOT_FOUND", "File not found"); + } + return await createEncContentStream(file.path, decodeIv(file.encContentIv), range); +}; + +export const getFileThumbnailStream = async (userId: number, fileId: number, range?: Range) => { + const thumbnail = await getFileThumbnail(userId, fileId); + return await createEncContentStream(thumbnail.path, decodeIv(thumbnail.encContentIv), range); }; diff --git a/src/lib/server/services/fileDeletion.ts b/src/lib/server/services/fileDeletion.ts new file mode 100644 index 0000000..f0a2291 --- /dev/null +++ b/src/lib/server/services/fileDeletion.ts @@ -0,0 +1,44 @@ +import { FileDeletionRepo } from "#lib/server/db/index.js"; +import { deleteFile } from "#lib/server/storage/index.js"; + +const BATCH_SIZE = 100; + +// Deletes files enqueued by committed transactions. Failed deletions are retried later. +export const processPendingFileDeletions = async () => { + while (true) { + const deletions = await FileDeletionRepo.getPendingFileDeletions(BATCH_SIZE); + if (deletions.length === 0) return; + + const results = await Promise.allSettled(deletions.map(({ path }) => deleteFile(path))); + const deletedIds = deletions + .filter((_, i) => results[i]!.status === "fulfilled") + .map(({ id }) => id); + results.forEach((result) => { + if (result.status === "rejected") console.error(result.reason); + }); + + await FileDeletionRepo.completeFileDeletions(deletedIds); + if (deletedIds.length < deletions.length) return; // Retry later + } +}; + +let processing: Promise | undefined; +let isRequestedAgain = false; + +export const schedulePendingFileDeletions = () => { + if (processing) { + isRequestedAgain = true; + return; + } + + processing = (async () => { + do { + isRequestedAgain = false; + await processPendingFileDeletions(); + } while (isRequestedAgain); + })() + .catch(console.error) + .finally(() => { + processing = undefined; + }); +}; diff --git a/src/lib/server/services/index.ts b/src/lib/server/services/index.ts new file mode 100644 index 0000000..593e708 --- /dev/null +++ b/src/lib/server/services/index.ts @@ -0,0 +1,10 @@ +export * as AuthService from "./auth"; +export * as CategoryService from "./category"; +export * as ClientService from "./client"; +export * as DirectoryService from "./directory"; +export * as FileService from "./file"; +export * as FileDeletionService from "./fileDeletion"; +export * as KeyService from "./key"; +export * as SearchService from "./search"; +export * as UploadService from "./upload"; +export * as UserService from "./user"; diff --git a/src/lib/server/services/key.ts b/src/lib/server/services/key.ts new file mode 100644 index 0000000..44427be --- /dev/null +++ b/src/lib/server/services/key.ts @@ -0,0 +1,50 @@ +import { ClientRepo, HskRepo, MekRepo } from "#lib/server/db/index.js"; +import { verifySignature } from "#lib/server/crypto.js"; +import { AppError } from "#lib/server/errors.js"; + +export const getClientMeks = async (userId: number, clientId: number) => { + return await MekRepo.getAllValidClientMeks(userId, clientId); +}; + +const verifyClientEncMekSig = async ( + userId: number, + clientId: number, + version: number, + encMek: string, + encMekSig: string, +) => { + const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId); + if (!userClient) { + throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id"); + } + + const data = JSON.stringify({ version, key: encMek }); + return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey); +}; + +export const registerInitialMek = async ( + userId: number, + clientId: number, + encMek: string, + encMekSig: string, +) => { + if (!(await verifyClientEncMekSig(userId, clientId, 1, encMek, encMekSig))) { + throw new AppError("BAD_REQUEST", "Invalid signature"); + } + + await MekRepo.registerInitialMek(userId, clientId, encMek, encMekSig); + await ClientRepo.setUserClientStateToActive(userId, clientId); +}; + +export const getHsks = async (userId: number) => { + return await HskRepo.getAllValidHsks(userId); +}; + +export const registerInitialHsk = async ( + userId: number, + clientId: number, + mekVersion: number, + encHsk: string, +) => { + await HskRepo.registerInitialHsk(userId, clientId, mekVersion, encHsk); +}; diff --git a/src/lib/server/services/search.ts b/src/lib/server/services/search.ts new file mode 100644 index 0000000..a2b8955 --- /dev/null +++ b/src/lib/server/services/search.ts @@ -0,0 +1,31 @@ +import { DirectoryRepo, FileRepo } from "#lib/server/db/index.js"; + +export const search = async ( + userId: number, + filters: { + ancestorId: DirectoryId; + inFavorites: boolean; + includeCategoryIds: number[]; + excludeCategoryIds: number[]; + }, +) => { + // Directories can't belong to categories + const hasCategoryFilter = + filters.includeCategoryIds.length > 0 || filters.excludeCategoryIds.length > 0; + + const [directories, files] = await Promise.all([ + !hasCategoryFilter + ? DirectoryRepo.searchDirectories(userId, { + parentId: filters.ancestorId, + inFavorites: filters.inFavorites, + }) + : [], + FileRepo.searchFiles(userId, { + parentId: filters.ancestorId, + inFavorites: filters.inFavorites, + includeCategoryIds: filters.includeCategoryIds, + excludeCategoryIds: filters.excludeCategoryIds, + }), + ]); + return { directories, files }; +}; diff --git a/src/lib/server/services/upload.ts b/src/lib/server/services/upload.ts index d23acfd..db95614 100644 --- a/src/lib/server/services/upload.ts +++ b/src/lib/server/services/upload.ts @@ -1,11 +1,18 @@ -import { error } from "@sveltejs/kit"; -import { open } from "fs/promises"; -import { Readable } from "stream"; -import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js"; -import { UploadRepo } from "#lib/server/db/index.js"; -import { safeUnlink } from "#lib/server/modules/filesystem.js"; +import type { Readable } from "stream"; +import { db, FileRepo, MediaRepo, UploadRepo } from "#lib/server/db/index.js"; +import type { Ciphertext } from "#lib/server/db/schema/index.js"; +import { AppError } from "#lib/server/errors.js"; +import { + deleteFile, + hashFile, + reserveFile, + writeChunkAtOffset, +} from "#lib/server/storage/index.js"; +import { ensureFreshDekVersion } from "./common"; +import { schedulePendingFileDeletions } from "./fileDeletion"; -const chunkLocks = new Set(); +const UPLOAD_EXPIRES = 24 * 3600 * 1000; // 24 hours +const THUMBNAIL_CHUNKS = 1; // Up to 4 MiB export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => { chunkIndex -= 1; @@ -13,93 +20,173 @@ export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => { return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks }; -const writeChunkAtOffset = async ( - path: string, - encChunkStream: Readable, - chunkIndex: number, - isLastChunk: boolean, +const withReservedFile = async ( + kind: "library" | "thumbnails", + userId: number, + callback: (path: string) => Promise, ) => { - const offset = (chunkIndex - 1) * ENCRYPTED_CHUNK_SIZE; - const file = await open(path, "r+"); - let written = 0; - + const path = await reserveFile(kind, userId); try { - for await (const chunk of encChunkStream) { - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - written += buffer.length; - if (written > ENCRYPTED_CHUNK_SIZE) { - throw new Error("Invalid chunk size"); - } - - let chunkOffset = 0; - while (chunkOffset < buffer.length) { - const { bytesWritten } = await file.write( - buffer, - chunkOffset, - buffer.length - chunkOffset, - offset + written - buffer.length + chunkOffset, - ); - if (bytesWritten <= 0) { - throw new Error("Failed to write chunk"); - } - chunkOffset += bytesWritten; - } - } - - if ( - (!isLastChunk && written !== ENCRYPTED_CHUNK_SIZE) || - (isLastChunk && (written <= ENCRYPTION_OVERHEAD || written > ENCRYPTED_CHUNK_SIZE)) - ) { - throw new Error("Invalid chunk size"); - } - - if (isLastChunk) { - await file.truncate(offset + written); - } - - return written; - } finally { - await file.close(); + return await callback(path); + } catch (e) { + await deleteFile(path).catch(console.error); + throw e; } }; +export const startFileUpload = async (params: { + userId: number; + totalChunks: number; + parentId: DirectoryId; + mekVersion: number; + encDek: string; + dekVersion: Date; + hskVersion: number | null; + contentType: string; + encName: Ciphertext; + encCreatedAt: Ciphertext | null; + encLastModifiedAt: Ciphertext; +}) => { + ensureFreshDekVersion(params.dekVersion); + + return await withReservedFile("library", params.userId, async (path) => { + const id = crypto.randomUUID(); + await UploadRepo.createFileUploadSession({ + ...params, + id, + path, + expiresAt: new Date(Date.now() + UPLOAD_EXPIRES), + }); + return { uploadId: id }; + }); +}; + +export const startFileThumbnailUpload = async ( + userId: number, + fileId: number, + dekVersion: Date, +) => { + return await withReservedFile("thumbnails", userId, async (path) => { + const id = crypto.randomUUID(); + await UploadRepo.createThumbnailUploadSession({ + id, + type: "thumbnail", + userId, + path, + totalChunks: THUMBNAIL_CHUNKS, + expiresAt: new Date(Date.now() + UPLOAD_EXPIRES), + fileId, + dekVersion, + }); + return { uploadId: id }; + }); +}; + export const uploadChunk = async ( userId: number, sessionId: string, chunkIndex: number, encChunkStream: Readable, ) => { - const lockKey = `${sessionId}/${chunkIndex}`; - if (chunkLocks.has(lockKey)) { - error(409, "Chunk upload already in progress"); - } else { - chunkLocks.add(lockKey); + const session = await UploadRepo.getUploadSession(sessionId, userId); + if (!session) { + throw new AppError("NOT_FOUND", "Invalid upload id"); + } else if (chunkIndex > session.totalChunks) { + throw new AppError("BAD_REQUEST", "Invalid chunk index"); + } else if (isChunkUploaded(session.bitmap, chunkIndex)) { + throw new AppError("CONFLICT", "Chunk already uploaded"); } - try { - const session = await UploadRepo.getUploadSession(sessionId, userId); - if (!session) { - error(404, "Invalid upload id"); - } else if (chunkIndex > session.totalChunks) { - error(400, "Invalid chunk index"); - } else if (isChunkUploaded(session.bitmap, chunkIndex)) { - error(409, "Chunk already uploaded"); + // Concurrent uploads of the same chunk may corrupt the file, which will be detected by the + // hash verification on completion + const isLastChunk = chunkIndex === session.totalChunks; + await writeChunkAtOffset(session.path, encChunkStream, chunkIndex, isLastChunk); + await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex); +}; + +const getCompletedUploadSession = async ( + userId: number, + uploadId: string, + type: T, +) => { + const session = await UploadRepo.getUploadSession(uploadId, userId); + if (session?.type !== type) { + throw new AppError("NOT_FOUND", "Invalid upload id"); + } else if (session.uploadedChunks < session.totalChunks) { + throw new AppError("BAD_REQUEST", "Upload not completed"); + } + return session as Extract, { type: T }>; +}; + +export const completeFileUpload = async ( + userId: number, + uploadId: string, + contentHmac: string | null, + encContentHash: string, +) => { + const session = await getCompletedUploadSession(userId, uploadId, "file"); + if (!!session.hskVersion !== !!contentHmac) { + throw new AppError("BAD_REQUEST", "Invalid content HMAC"); + } + + const hash = await hashFile(session.path); + if (hash !== encContentHash) { + await UploadRepo.discardUploadSession(uploadId); + schedulePendingFileDeletions(); + throw new AppError("CONFLICT", "Uploaded file corrupted"); + } + + return await db.transaction().execute(async (trx) => { + // Locking the session ensures that it is completed only once + const lockedSession = await UploadRepo.lockUploadSession(trx, uploadId, userId); + if (lockedSession.type !== "file") { + throw new AppError("NOT_FOUND", "Invalid upload id"); } - const isLastChunk = chunkIndex === session.totalChunks; - await writeChunkAtOffset(session.path, encChunkStream, chunkIndex, isLastChunk); - await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex); - } catch (e) { - if (e instanceof Error && e.message === "Invalid chunk size") { - error(400, "Invalid request body"); + const { id: fileId } = await FileRepo.registerFile(trx, { + parentId: lockedSession.parentId, + userId, + path: lockedSession.path, + mekVersion: lockedSession.mekVersion, + encDek: lockedSession.encDek, + dekVersion: lockedSession.dekVersion, + hskVersion: lockedSession.hskVersion, + contentHmac, + contentType: lockedSession.contentType, + encContentIv: null, + encContentHash: hash, + encName: lockedSession.encName, + encCreatedAt: lockedSession.encCreatedAt, + encLastModifiedAt: lockedSession.encLastModifiedAt, + }); + await UploadRepo.deleteUploadSession(trx, uploadId); + return { fileId }; + }); +}; + +export const completeFileThumbnailUpload = async (userId: number, uploadId: string) => { + await getCompletedUploadSession(userId, uploadId, "thumbnail"); + + await db.transaction().execute(async (trx) => { + const session = await UploadRepo.lockUploadSession(trx, uploadId, userId); + if (session.type !== "thumbnail") { + throw new AppError("NOT_FOUND", "Invalid upload id"); } - throw e; - } finally { - chunkLocks.delete(lockKey); - } + + await MediaRepo.updateFileThumbnail( + trx, + userId, + session.fileId, + session.dekVersion, + session.path, + null, + ); + await UploadRepo.deleteUploadSession(trx, uploadId); + }); + schedulePendingFileDeletions(); }; export const cleanupExpiredUploadSessions = async () => { - const paths = await UploadRepo.cleanupExpiredUploadSessions(); - await Promise.all(paths.map(safeUnlink)); + await UploadRepo.cleanupExpiredUploadSessions(); + schedulePendingFileDeletions(); }; diff --git a/src/lib/server/services/user.ts b/src/lib/server/services/user.ts new file mode 100644 index 0000000..ce65b5a --- /dev/null +++ b/src/lib/server/services/user.ts @@ -0,0 +1,10 @@ +import { UserRepo } from "#lib/server/db/index.js"; +import { AppError } from "#lib/server/errors.js"; + +export const getUser = async (userId: number) => { + const user = await UserRepo.getUser(userId); + if (!user) { + throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id"); + } + return user; +}; diff --git a/src/lib/server/storage/chunks.ts b/src/lib/server/storage/chunks.ts new file mode 100644 index 0000000..ceebba6 --- /dev/null +++ b/src/lib/server/storage/chunks.ts @@ -0,0 +1,56 @@ +import { open } from "fs/promises"; +import type { Readable } from "stream"; +import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js"; +import { AppError } from "#lib/server/errors.js"; + +const invalidChunkSize = () => new AppError("BAD_REQUEST", "Invalid chunk size"); + +export const writeChunkAtOffset = async ( + path: string, + encChunkStream: Readable, + chunkIndex: number, + isLastChunk: boolean, +) => { + const offset = (chunkIndex - 1) * ENCRYPTED_CHUNK_SIZE; + const file = await open(path, "r+"); + let written = 0; + + try { + for await (const chunk of encChunkStream) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + written += buffer.length; + if (written > ENCRYPTED_CHUNK_SIZE) { + throw invalidChunkSize(); + } + + let chunkOffset = 0; + while (chunkOffset < buffer.length) { + const { bytesWritten } = await file.write( + buffer, + chunkOffset, + buffer.length - chunkOffset, + offset + written - buffer.length + chunkOffset, + ); + if (bytesWritten <= 0) { + throw new Error("Failed to write chunk"); + } + chunkOffset += bytesWritten; + } + } + + if ( + (!isLastChunk && written !== ENCRYPTED_CHUNK_SIZE) || + (isLastChunk && (written <= ENCRYPTION_OVERHEAD || written > ENCRYPTED_CHUNK_SIZE)) + ) { + throw invalidChunkSize(); + } + + if (isLastChunk) { + await file.truncate(offset + written); + } + + return written; + } finally { + await file.close(); + } +}; diff --git a/src/lib/server/storage/files.ts b/src/lib/server/storage/files.ts new file mode 100644 index 0000000..a7b98e0 --- /dev/null +++ b/src/lib/server/storage/files.ts @@ -0,0 +1,37 @@ +import { createHash, randomUUID } from "crypto"; +import { createReadStream } from "fs"; +import { mkdir, open, unlink } from "fs/promises"; +import { dirname } from "path"; +import env from "#lib/server/config.js"; + +const roots = { + library: () => env.libraryPath, + thumbnails: () => env.thumbnailsPath, +}; + +// Creates an empty file at a new path so that it can't be taken by others +export const reserveFile = async (kind: keyof typeof roots, userId: number) => { + const path = `${roots[kind]()}/${userId}/${randomUUID()}`; + await mkdir(dirname(path), { recursive: true }); + const file = await open(path, "wx", 0o600); + await file.close(); + return path; +}; + +export const deleteFile = async (path: string) => { + try { + await unlink(path); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== "ENOENT") { + throw e; + } + } +}; + +export const hashFile = async (path: string) => { + const hash = createHash("sha256"); + for await (const chunk of createReadStream(path)) { + hash.update(chunk); + } + return hash.digest("base64"); +}; diff --git a/src/lib/server/storage/index.ts b/src/lib/server/storage/index.ts new file mode 100644 index 0000000..2435011 --- /dev/null +++ b/src/lib/server/storage/index.ts @@ -0,0 +1,3 @@ +export * from "./chunks"; +export * from "./files"; +export * from "./streams"; diff --git a/src/lib/server/storage/streams.ts b/src/lib/server/storage/streams.ts new file mode 100644 index 0000000..2accb43 --- /dev/null +++ b/src/lib/server/storage/streams.ts @@ -0,0 +1,40 @@ +import { createReadStream } from "fs"; +import { stat } from "fs/promises"; +import { Readable } from "stream"; +import { AppError } from "#lib/server/errors.js"; + +export const createEncContentStream = async ( + path: string, + iv?: Buffer, + range?: { start?: number; end?: number }, +) => { + const { size: fileSize } = await stat(path); + const ivSize = iv?.byteLength ?? 0; + const totalSize = fileSize + ivSize; + + const start = range?.start ?? 0; + const end = range?.end ?? totalSize - 1; + if (start > end || start < 0 || end >= totalSize) { + throw new AppError("RANGE_NOT_SATISFIABLE", "Invalid range"); + } + + return { + encContentStream: Readable.toWeb( + Readable.from( + (async function* () { + if (start < ivSize) { + yield iv!.subarray(start, Math.min(end + 1, ivSize)); + } + if (end >= ivSize) { + yield* createReadStream(path, { + start: Math.max(0, start - ivSize), + end: end - ivSize, + }); + } + })(), + ), + ), + range: { start, end, total: totalSize }, + isRangeRequest: !!range, + }; +}; diff --git a/src/routes/api/file/[id]/[[thumbnail=thumbnail]]/download/+server.ts b/src/routes/api/file/[id]/[[thumbnail=thumbnail]]/download/+server.ts index 98cfb7b..8724ed6 100644 --- a/src/routes/api/file/[id]/[[thumbnail=thumbnail]]/download/+server.ts +++ b/src/routes/api/file/[id]/[[thumbnail=thumbnail]]/download/+server.ts @@ -1,11 +1,12 @@ import { error } from "@sveltejs/kit"; import { z } from "zod"; import { parseRangeHeader, getContentRangeHeader } from "#lib/modules/http.js"; -import { authorize } from "#lib/server/modules/auth.js"; -import { getFileStream, getFileThumbnailStream } from "#lib/server/services/file.js"; -import type { RequestHandler, RouteParams } from "./$types"; +import { authorize } from "#lib/server/auth.js"; +import { handleAppErrors } from "#lib/server/http.js"; +import { FileService } from "#lib/server/services/index.js"; +import type { RequestEvent, RequestHandler } from "./$types"; -const downloadHandler = async (locals: App.Locals, params: RouteParams, request: Request) => { +const downloadHandler = async ({ locals, params, request }: RequestEvent) => { const { userId } = await authorize(locals, "activeClient"); const zodRes = z @@ -16,7 +17,9 @@ const downloadHandler = async (locals: App.Locals, params: RouteParams, request: if (!zodRes.success) error(400, "Invalid path parameters"); const { id } = zodRes.data; - const getStream = params.thumbnail ? getFileThumbnailStream : getFileStream; + const getStream = params.thumbnail + ? FileService.getFileThumbnailStream + : FileService.getFileStream; const { encContentStream, range, isRangeRequest } = await getStream( userId, id, @@ -34,11 +37,13 @@ const downloadHandler = async (locals: App.Locals, params: RouteParams, request: }; }; -export const GET: RequestHandler = async ({ locals, params, request }) => { - const { stream, ...init } = await downloadHandler(locals, params, request); +export const GET: RequestHandler = handleAppErrors(async (event) => { + const { stream, ...init } = await downloadHandler(event); return new Response(stream as ReadableStream, init); -}; +}); -export const HEAD: RequestHandler = async ({ locals, params, request }) => { - return new Response(null, await downloadHandler(locals, params, request)); -}; +export const HEAD: RequestHandler = handleAppErrors(async (event) => { + const { stream, ...init } = await downloadHandler(event); + await stream.cancel(); + return new Response(null, init); +}); diff --git a/src/routes/api/upload/[id]/chunks/[index]/+server.ts b/src/routes/api/upload/[id]/chunks/[index]/+server.ts index e91b3bf..45e3cdc 100644 --- a/src/routes/api/upload/[id]/chunks/[index]/+server.ts +++ b/src/routes/api/upload/[id]/chunks/[index]/+server.ts @@ -2,11 +2,12 @@ import { error } from "@sveltejs/kit"; import { Readable } from "stream"; import type { ReadableStream } from "stream/web"; import { z } from "zod"; -import { authorize } from "#lib/server/modules/auth.js"; -import { uploadChunk } from "#lib/server/services/upload.js"; +import { authorize } from "#lib/server/auth.js"; +import { handleAppErrors } from "#lib/server/http.js"; +import { UploadService } from "#lib/server/services/index.js"; import type { RequestHandler } from "./$types"; -export const POST: RequestHandler = async ({ locals, params, request }) => { +export const POST: RequestHandler = handleAppErrors(async ({ locals, params, request }) => { const { userId } = await authorize(locals, "activeClient"); const zodRes = z @@ -22,11 +23,11 @@ export const POST: RequestHandler = async ({ locals, params, request }) => { error(400, "Invalid request body"); } - await uploadChunk( + await UploadService.uploadChunk( userId, sessionId, chunkIndex, Readable.fromWeb(request.body as ReadableStream), ); return new Response("Chunk uploaded", { headers: { "Content-Type": "text/plain" } }); -}; +}); diff --git a/src/trpc/dto.ts b/src/trpc/dto.ts new file mode 100644 index 0000000..5e9fd67 --- /dev/null +++ b/src/trpc/dto.ts @@ -0,0 +1,48 @@ +import type { Category } from "#lib/server/db/repositories/category.js"; +import type { Directory } from "#lib/server/db/repositories/directory.js"; +import type { File } from "#lib/server/db/repositories/file.js"; + +// Shapes of filesystem entries exposed by the API + +export const toDirectoryDto = (directory: Directory) => ({ + id: directory.id, + parent: directory.parentId, + mekVersion: directory.mekVersion, + dek: directory.encDek, + dekVersion: directory.dekVersion, + name: directory.encName.ciphertext, + nameIv: directory.encName.iv, + isFavorite: directory.isFavorite, +}); + +export const toFileDto = (file: File) => ({ + id: file.id, + isLegacy: !!file.encContentIv, + 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, + isFavorite: file.isFavorite, +}); + +export const toCategoryDto = (category: Category) => ({ + id: category.id, + parent: category.parentId, + mekVersion: category.mekVersion, + dek: category.encDek, + dekVersion: category.dekVersion, + name: category.encName.ciphertext, + nameIv: category.encName.iv, +}); + +export const toFileWithCategoriesDto = (file: File & { categories: Category[] }) => ({ + ...toFileDto(file), + categories: file.categories.map(toCategoryDto), +}); diff --git a/src/trpc/init.server.ts b/src/trpc/init.server.ts index 8d5d7c5..4919656 100644 --- a/src/trpc/init.server.ts +++ b/src/trpc/init.server.ts @@ -1,14 +1,34 @@ import type { RequestEvent } from "@sveltejs/kit"; import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; -import { authorizeMiddleware, authorizeClientMiddleware } from "./middlewares/authorize"; +import { authorize, type ClientSession, type Session } from "#lib/server/auth.js"; +import { AppError, type ErrorCode } from "#lib/server/errors.js"; export const createContext = (event: RequestEvent) => event; export type Context = Awaited>; export const t = initTRPC.context().create({ transformer: superjson }); export const router = t.router; -export const publicProcedure = t.procedure; + +const trpcErrorCodes = { + BAD_REQUEST: "BAD_REQUEST", + UNAUTHORIZED: "UNAUTHORIZED", + FORBIDDEN: "FORBIDDEN", + NOT_FOUND: "NOT_FOUND", + CONFLICT: "CONFLICT", + RANGE_NOT_SATISFIABLE: "BAD_REQUEST", + INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR", +} as const satisfies Record; + +// Converts `AppError`s thrown by services into tRPC errors +export const publicProcedure = t.procedure.use(async ({ next }) => { + const result = await next(); + if (!result.ok && result.error.cause instanceof AppError) { + const { code, message } = result.error.cause; + throw new TRPCError({ code: trpcErrorCodes[code], message, cause: result.error.cause }); + } + return result; +}); const authedProcedure = publicProcedure.use(async ({ ctx, next }) => { if (!ctx.locals.session) { @@ -17,10 +37,24 @@ const authedProcedure = publicProcedure.use(async ({ ctx, next }) => { return next(); }); +const sessionProcedure = (requiredPermission: "any" | "notClient") => + authedProcedure.use(async ({ ctx, next }) => { + const session: Session = await authorize(ctx.locals, requiredPermission); + return next({ ctx: { session } }); + }); + +const clientSessionProcedure = ( + requiredPermission: "anyClient" | "pendingClient" | "activeClient", +) => + authedProcedure.use(async ({ ctx, next }) => { + const session: ClientSession = await authorize(ctx.locals, requiredPermission); + return next({ ctx: { session } }); + }); + export const roleProcedure = { - any: authedProcedure.use(authorizeMiddleware("any")), - notClient: authedProcedure.use(authorizeMiddleware("notClient")), - anyClient: authedProcedure.use(authorizeClientMiddleware("anyClient")), - pendingClient: authedProcedure.use(authorizeClientMiddleware("pendingClient")), - activeClient: authedProcedure.use(authorizeClientMiddleware("activeClient")), + any: sessionProcedure("any"), + notClient: sessionProcedure("notClient"), + anyClient: clientSessionProcedure("anyClient"), + pendingClient: clientSessionProcedure("pendingClient"), + activeClient: clientSessionProcedure("activeClient"), }; diff --git a/src/trpc/middlewares/authorize.ts b/src/trpc/middlewares/authorize.ts deleted file mode 100644 index 390bc7b..0000000 --- a/src/trpc/middlewares/authorize.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { TRPCError } from "@trpc/server"; -import { - AuthorizationError, - authorizeInternal, - type ClientSession, - type SessionPermission, -} from "#lib/server/modules/auth.js"; -import { t } from "../init.server"; - -const authorize = async (locals: App.Locals, requiredPermission: SessionPermission) => { - try { - return await authorizeInternal(locals, requiredPermission); - } catch (e) { - if (e instanceof AuthorizationError) { - throw new TRPCError({ - code: e.status === 403 ? "FORBIDDEN" : "INTERNAL_SERVER_ERROR", - message: e.message, - }); - } - throw e; - } -}; - -export const authorizeMiddleware = (requiredPermission: "any" | "notClient") => - t.middleware(async ({ ctx, next }) => { - const session = await authorize(ctx.locals, requiredPermission); - return next({ ctx: { session } }); - }); - -export const authorizeClientMiddleware = ( - requiredPermission: "anyClient" | "pendingClient" | "activeClient", -) => - t.middleware(async ({ ctx, next }) => { - const session = (await authorize(ctx.locals, requiredPermission)) as ClientSession; - return next({ ctx: { session } }); - }); diff --git a/src/trpc/routers/auth.ts b/src/trpc/routers/auth.ts index ae4d7b9..fedd870 100644 --- a/src/trpc/routers/auth.ts +++ b/src/trpc/routers/auth.ts @@ -1,11 +1,8 @@ -import { TRPCError } from "@trpc/server"; -import argon2 from "argon2"; import { z } from "zod"; -import { ClientRepo, SessionRepo, UserRepo, IntegrityError } from "#lib/server/db/index.js"; -import env from "#lib/server/loadenv.js"; -import { cookieOptions } from "#lib/server/modules/auth.js"; -import { generateChallenge, verifySignature, issueSessionId } from "#lib/server/modules/crypto.js"; -import { router, publicProcedure, roleProcedure } from "../init.server"; +import { cookieOptions } from "#lib/server/auth.js"; +import { AuthService } from "#lib/server/services/index.js"; +import { router, publicProcedure, roleProcedure } from "#trpc/init.server.js"; +import { Base64Schema, IdSchema } from "#trpc/schemas.js"; const authRouter = router({ login: publicProcedure @@ -16,18 +13,17 @@ const authRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - const user = await UserRepo.getUserByEmail(input.email); - if (!user || !(await argon2.verify(user.password, input.password))) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" }); - } - - const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); - await SessionRepo.createSession(user.id, sessionId, ctx.locals.ip, ctx.locals.userAgent); + const { sessionIdSigned } = await AuthService.login( + input.email, + input.password, + ctx.locals.ip, + ctx.locals.userAgent, + ); ctx.cookies.set("sessionId", sessionIdSigned, cookieOptions); }), logout: roleProcedure["any"].mutation(async ({ ctx }) => { - await SessionRepo.deleteSession(ctx.session.sessionId); + await AuthService.logout(ctx.session.sessionId); ctx.cookies.delete("sessionId", cookieOptions); }), @@ -39,103 +35,48 @@ const authRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - if (input.oldPassword === input.newPassword) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Same passwords" }); - } else if (input.newPassword.length < 8) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Too short password" }); - } - - const user = await UserRepo.getUser(ctx.session.userId); - if (!user) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" }); - } else if (!(await argon2.verify(user.password, input.oldPassword))) { - throw new TRPCError({ code: "FORBIDDEN", message: "Invalid password" }); - } - - await UserRepo.setUserPassword(ctx.session.userId, await argon2.hash(input.newPassword)); - await SessionRepo.deleteAllOtherSessions(ctx.session.userId, ctx.session.sessionId); + await AuthService.changePassword( + ctx.session.userId, + ctx.session.sessionId, + input.oldPassword, + input.newPassword, + ); }), upgrade: roleProcedure["notClient"] .input( z.object({ - encPubKey: z.base64().nonempty(), - sigPubKey: z.base64().nonempty(), + encPubKey: Base64Schema, + sigPubKey: Base64Schema, }), ) .mutation(async ({ ctx, input }) => { - const client = await ClientRepo.getClientByPubKeys(input.encPubKey, input.sigPubKey); - const userClient = client - ? await ClientRepo.getUserClient(ctx.session.userId, client.id) - : undefined; - if (!client) { - throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid public key(s)" }); - } else if (!userClient || userClient.state === "challenging") { - throw new TRPCError({ code: "FORBIDDEN", message: "Unregistered client" }); - } - - const { answer, challenge } = await generateChallenge(32, input.encPubKey); - const { id } = await SessionRepo.registerSessionUpgradeChallenge( + return await AuthService.requestSessionUpgrade( + ctx.session.userId, ctx.session.sessionId, - client.id, - answer.toString("base64"), ctx.locals.ip, - new Date(Date.now() + env.challenge.sessionUpgradeExp), + input.encPubKey, + input.sigPubKey, ); - - return { id, challenge: challenge.toString("base64") }; }), verifyUpgrade: roleProcedure["notClient"] .input( z.object({ - id: z.int().positive(), - answerSig: z.base64().nonempty(), + id: IdSchema, + answerSig: Base64Schema, force: z.boolean().default(false), }), ) .mutation(async ({ ctx, input }) => { - const challenge = await SessionRepo.consumeSessionUpgradeChallenge( - input.id, + await AuthService.verifySessionUpgrade( + ctx.session.userId, ctx.session.sessionId, ctx.locals.ip, + input.id, + input.answerSig, + input.force, ); - if (!challenge) { - throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer" }); - } - - const client = await ClientRepo.getClient(challenge.clientId); - if (!client) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid challenge answer" }); - } else if ( - !verifySignature(Buffer.from(challenge.answer, "base64"), input.answerSig, client.sigPubKey) - ) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "Invalid challenge answer signature", - }); - } - - try { - await SessionRepo.upgradeSession( - ctx.session.userId, - ctx.session.sessionId, - client.id, - input.force, - ); - } catch (e) { - if (e instanceof IntegrityError) { - if (e.message === "Session not found") { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Invalid challenge answer", - }); - } else if (!input.force && e.message === "Session already exists") { - throw new TRPCError({ code: "CONFLICT", message: "Already logged in" }); - } - } - throw e; - } }), }); diff --git a/src/trpc/routers/category.ts b/src/trpc/routers/category.ts index 67dc95a..eed25ba 100644 --- a/src/trpc/routers/category.ts +++ b/src/trpc/routers/category.ts @@ -1,8 +1,14 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { CategoryIdSchema } from "#lib/schemas/index.js"; -import { CategoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js"; -import { router, roleProcedure } from "../init.server"; +import { CategoryService } from "#lib/server/services/index.js"; +import { toCategoryDto, toFileDto } from "#trpc/dto.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { + Base64Schema, + CategoryIdSchema, + EncryptedNameSchema, + IdSchema, + toCiphertext, +} from "#trpc/schemas.js"; const categoryRouter = router({ get: roleProcedure["activeClient"] @@ -13,53 +19,15 @@ const categoryRouter = router({ }), ) .query(async ({ ctx, input }) => { - const category = - input.id !== "root" - ? await CategoryRepo.getCategory(ctx.session.userId, input.id) - : undefined; - if (category === null) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); - } - - const [categories, files] = await Promise.all([ - CategoryRepo.getAllCategoriesByParent(ctx.session.userId, input.id), - input.id !== "root" - ? FileRepo.getAllFilesByCategory(ctx.session.userId, input.id, input.recurse) - : undefined, - ]); + const { category, subCategories, files } = await CategoryService.getCategory( + ctx.session.userId, + input.id, + input.recurse, + ); return { - metadata: category && { - parent: category.parentId, - mekVersion: category.mekVersion, - dek: category.encDek, - dekVersion: category.dekVersion, - name: category.encName.ciphertext, - nameIv: category.encName.iv, - }, - subCategories: categories.map((category) => ({ - id: category.id, - mekVersion: category.mekVersion, - dek: category.encDek, - dekVersion: category.dekVersion, - name: category.encName.ciphertext, - nameIv: category.encName.iv, - })), - files: files?.map((file) => ({ - id: file.id, - parent: file.parentId, - mekVersion: file.mekVersion, - dek: file.encDek, - dekVersion: file.dekVersion, - contentType: file.contentType, - name: file.encName.ciphertext, - nameIv: file.encName.iv, - createdAt: file.encCreatedAt?.ciphertext, - createdAtIv: file.encCreatedAt?.iv, - lastModifiedAt: file.encLastModifiedAt.ciphertext, - lastModifiedAtIv: file.encLastModifiedAt.iv, - isFavorite: file.isFavorite, - isRecursive: file.isRecursive, - })), + metadata: category && toCategoryDto(category), + subCategories: subCategories.map(toCategoryDto), + files: files?.map((file) => ({ ...toFileDto(file), isRecursive: file.isRecursive })), }; }), @@ -67,139 +35,70 @@ const categoryRouter = router({ .input( z.object({ parent: CategoryIdSchema, - mekVersion: z.int().positive(), - dek: z.base64().nonempty(), + mekVersion: IdSchema, + dek: Base64Schema, dekVersion: z.date(), - name: z.base64().nonempty(), - nameIv: z.base64().nonempty(), + ...EncryptedNameSchema, }), ) .mutation(async ({ ctx, input }) => { - const oneMinuteAgo = new Date(Date.now() - 60 * 1000); - const oneMinuteLater = new Date(Date.now() + 60 * 1000); - if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" }); - } - - try { - await CategoryRepo.registerCategory({ - parentId: input.parent, - userId: ctx.session.userId, - mekVersion: input.mekVersion, - encDek: input.dek, - dekVersion: input.dekVersion, - encName: { ciphertext: input.name, iv: input.nameIv }, - }); - } catch (e) { - if (e instanceof IntegrityError) { - if (e.message === "Inactive MEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); - } else if (e.message === "Category not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent category id" }); - } - } - throw e; - } + await CategoryService.createCategory({ + userId: ctx.session.userId, + parentId: input.parent, + mekVersion: input.mekVersion, + encDek: input.dek, + dekVersion: input.dekVersion, + encName: toCiphertext(input.name, input.nameIv), + }); }), rename: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, dekVersion: z.date(), - name: z.base64().nonempty(), - nameIv: z.base64().nonempty(), + ...EncryptedNameSchema, }), ) .mutation(async ({ ctx, input }) => { - try { - await CategoryRepo.setCategoryEncName(ctx.session.userId, input.id, input.dekVersion, { - ciphertext: input.name, - iv: input.nameIv, - }); - } catch (e) { - if (e instanceof IntegrityError) { - if (e.message === "Category not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); - } else if (e.message === "Invalid DEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); - } - } - throw e; - } + await CategoryService.renameCategory( + ctx.session.userId, + input.id, + input.dekVersion, + toCiphertext(input.name, input.nameIv), + ); }), delete: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, }), ) .mutation(async ({ ctx, input }) => { - try { - await CategoryRepo.unregisterCategory(ctx.session.userId, input.id); - } catch (e) { - if (e instanceof IntegrityError && e.message === "Category not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); - } - throw e; - } + await CategoryService.deleteCategory(ctx.session.userId, input.id); }), addFile: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), - file: z.int().positive(), + id: IdSchema, + file: IdSchema, }), ) .mutation(async ({ ctx, input }) => { - const [category, file] = await Promise.all([ - CategoryRepo.getCategory(ctx.session.userId, input.id), - FileRepo.getFile(ctx.session.userId, input.file), - ]); - if (!category) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); - } else if (!file) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" }); - } - - try { - await FileRepo.addFileToCategory(input.file, input.id); - } catch (e) { - if (e instanceof IntegrityError && e.message === "File already added to category") { - throw new TRPCError({ code: "BAD_REQUEST", message: "File already added" }); - } - throw e; - } + await CategoryService.addFileToCategory(ctx.session.userId, input.id, input.file); }), removeFile: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), - file: z.int().positive(), + id: IdSchema, + file: IdSchema, }), ) .mutation(async ({ ctx, input }) => { - const [category, file] = await Promise.all([ - CategoryRepo.getCategory(ctx.session.userId, input.id), - FileRepo.getFile(ctx.session.userId, input.file), - ]); - if (!category) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid category id" }); - } else if (!file) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" }); - } - - try { - await FileRepo.removeFileFromCategory(input.file, input.id); - } catch (e) { - if (e instanceof IntegrityError && e.message === "File not found in category") { - throw new TRPCError({ code: "BAD_REQUEST", message: "File not added" }); - } - throw e; - } + await CategoryService.removeFileFromCategory(ctx.session.userId, input.id, input.file); }), }); diff --git a/src/trpc/routers/client.ts b/src/trpc/routers/client.ts index d66e7ac..8a050e5 100644 --- a/src/trpc/routers/client.ts +++ b/src/trpc/routers/client.ts @@ -1,96 +1,39 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { ClientRepo, IntegrityError } from "#lib/server/db/index.js"; -import env from "#lib/server/loadenv.js"; -import { verifyPubKey, verifySignature, generateChallenge } from "#lib/server/modules/crypto.js"; -import { router, roleProcedure } from "../init.server"; - -const createUserClientChallenge = async ( - ip: string, - userId: number, - clientId: number, - encPubKey: string, -) => { - const { answer, challenge } = await generateChallenge(32, encPubKey); - const { id } = await ClientRepo.registerUserClientChallenge( - userId, - clientId, - answer.toString("base64"), - ip, - new Date(Date.now() + env.challenge.userClientExp), - ); - - return { id, challenge: challenge.toString("base64") }; -}; +import { ClientService } from "#lib/server/services/index.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { Base64Schema, IdSchema } from "#trpc/schemas.js"; const clientRouter = router({ register: roleProcedure["notClient"] .input( z.object({ - encPubKey: z.base64().nonempty(), - sigPubKey: z.base64().nonempty(), + encPubKey: Base64Schema, + sigPubKey: Base64Schema, }), ) .mutation(async ({ ctx, input }) => { - const { userId } = ctx.session; - const { encPubKey, sigPubKey } = input; - const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey); - if (client) { - try { - await ClientRepo.createUserClient(userId, client.id); - return await createUserClientChallenge(ctx.locals.ip, userId, client.id, encPubKey); - } catch (e) { - if (e instanceof IntegrityError && e.message === "User client already exists") { - throw new TRPCError({ code: "CONFLICT", message: "Client already registered" }); - } - throw e; - } - } else { - if (encPubKey === sigPubKey) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Same public keys" }); - } else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid public key(s)" }); - } - - try { - const { id: clientId } = await ClientRepo.createClient(encPubKey, sigPubKey, userId); - return await createUserClientChallenge(ctx.locals.ip, userId, clientId, encPubKey); - } catch (e) { - if (e instanceof IntegrityError && e.message === "Public key(s) already registered") { - throw new TRPCError({ code: "CONFLICT", message: "Public key(s) already used" }); - } - throw e; - } - } + return await ClientService.registerClient( + ctx.session.userId, + ctx.locals.ip, + input.encPubKey, + input.sigPubKey, + ); }), verify: roleProcedure["notClient"] .input( z.object({ - id: z.int().positive(), - answerSig: z.base64().nonempty(), + id: IdSchema, + answerSig: Base64Schema, }), ) .mutation(async ({ ctx, input }) => { - const challenge = await ClientRepo.consumeUserClientChallenge( - input.id, + await ClientService.verifyClient( ctx.session.userId, ctx.locals.ip, + input.id, + input.answerSig, ); - if (!challenge) { - throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer" }); - } - - const client = await ClientRepo.getClient(challenge.clientId); - if (!client) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid challenge answer" }); - } else if ( - !verifySignature(Buffer.from(challenge.answer, "base64"), input.answerSig, client.sigPubKey) - ) { - throw new TRPCError({ code: "FORBIDDEN", message: "Invalid challenge answer signature" }); - } - - await ClientRepo.setUserClientStateToPending(ctx.session.userId, client.id); }), }); diff --git a/src/trpc/routers/directory.ts b/src/trpc/routers/directory.ts index 21f0fc6..d8fd716 100644 --- a/src/trpc/routers/directory.ts +++ b/src/trpc/routers/directory.ts @@ -1,9 +1,14 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { DirectoryIdSchema } from "#lib/schemas/index.js"; -import { DirectoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js"; -import { safeUnlink } from "#lib/server/modules/filesystem.js"; -import { router, roleProcedure } from "../init.server"; +import { DirectoryService } from "#lib/server/services/index.js"; +import { toDirectoryDto, toFileDto } from "#trpc/dto.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { + Base64Schema, + DirectoryIdSchema, + EncryptedNameSchema, + IdSchema, + toCiphertext, +} from "#trpc/schemas.js"; const directoryRouter = router({ get: roleProcedure["activeClient"] @@ -13,51 +18,14 @@ const directoryRouter = router({ }), ) .query(async ({ ctx, input }) => { - const directory = - 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([ - DirectoryRepo.getAllDirectoriesByParent(ctx.session.userId, input.id), - FileRepo.getAllFilesByParent(ctx.session.userId, input.id), - ]); + const { directory, subDirectories, files } = await DirectoryService.getDirectory( + ctx.session.userId, + input.id, + ); return { - metadata: directory && { - parent: directory.parentId, - mekVersion: directory.mekVersion, - dek: directory.encDek, - dekVersion: directory.dekVersion, - name: directory.encName.ciphertext, - nameIv: directory.encName.iv, - isFavorite: directory.isFavorite, - }, - subDirectories: directories.map((directory) => ({ - id: directory.id, - mekVersion: directory.mekVersion, - dek: directory.encDek, - dekVersion: directory.dekVersion, - name: directory.encName.ciphertext, - nameIv: directory.encName.iv, - isFavorite: directory.isFavorite, - })), - files: files.map((file) => ({ - id: file.id, - 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, - isFavorite: file.isFavorite, - })), + metadata: directory && toDirectoryDto(directory), + subDirectories: subDirectories.map(toDirectoryDto), + files: files.map(toFileDto), }; }), @@ -65,90 +33,52 @@ const directoryRouter = router({ .input( z.object({ parent: DirectoryIdSchema, - mekVersion: z.int().positive(), - dek: z.base64().nonempty(), + mekVersion: IdSchema, + dek: Base64Schema, dekVersion: z.date(), - name: z.base64().nonempty(), - nameIv: z.base64().nonempty(), + ...EncryptedNameSchema, }), ) .mutation(async ({ ctx, input }) => { - const oneMinuteAgo = new Date(Date.now() - 60 * 1000); - const oneMinuteLater = new Date(Date.now() + 60 * 1000); - if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" }); - } - - try { - await DirectoryRepo.registerDirectory({ - parentId: input.parent, - userId: ctx.session.userId, - mekVersion: input.mekVersion, - encDek: input.dek, - dekVersion: input.dekVersion, - encName: { ciphertext: input.name, iv: input.nameIv }, - }); - } catch (e) { - if (e instanceof IntegrityError) { - if (e.message === "Inactive MEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); - } else if (e.message === "Directory not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" }); - } - } - throw e; - } + await DirectoryService.createDirectory({ + userId: ctx.session.userId, + parentId: input.parent, + mekVersion: input.mekVersion, + encDek: input.dek, + dekVersion: input.dekVersion, + encName: toCiphertext(input.name, input.nameIv), + }); }), rename: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, dekVersion: z.date(), - name: z.base64().nonempty(), - nameIv: z.base64().nonempty(), + ...EncryptedNameSchema, }), ) .mutation(async ({ ctx, input }) => { - try { - await DirectoryRepo.setDirectoryEncName(ctx.session.userId, input.id, input.dekVersion, { - ciphertext: input.name, - iv: input.nameIv, - }); - } 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 === "Invalid DEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); - } - } - throw e; - } + await DirectoryService.renameDirectory( + ctx.session.userId, + input.id, + input.dekVersion, + toCiphertext(input.name, input.nameIv), + ); }), delete: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, }), ) .mutation(async ({ ctx, input }) => { - try { - const files = await DirectoryRepo.unregisterDirectory(ctx.session.userId, input.id); - return { - deletedFiles: files.map((file) => { - safeUnlink(file.path); // Intended - safeUnlink(file.thumbnailPath); // Intended - return file.id; - }), - }; - } catch (e) { - if (e instanceof IntegrityError && e.message === "Directory not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" }); - } - throw e; - } + const { deletedFileIds } = await DirectoryService.deleteDirectory( + ctx.session.userId, + input.id, + ); + return { deletedFiles: deletedFileIds }; }), }); diff --git a/src/trpc/routers/favorites.ts b/src/trpc/routers/favorites.ts index 880706d..52137f3 100644 --- a/src/trpc/routers/favorites.ts +++ b/src/trpc/routers/favorites.ts @@ -1,123 +1,43 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { DirectoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js"; -import { router, roleProcedure } from "../init.server"; +import { DirectoryService, FileService } from "#lib/server/services/index.js"; +import { toDirectoryDto, toFileDto } from "#trpc/dto.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { IdSchema } from "#trpc/schemas.js"; + +const IdInputSchema = z.object({ id: IdSchema }); const favoritesRouter = router({ get: roleProcedure["activeClient"].query(async ({ ctx }) => { const [directories, files] = await Promise.all([ - DirectoryRepo.getAllFavoriteDirectories(ctx.session.userId), - FileRepo.getAllFavoriteFiles(ctx.session.userId), + DirectoryService.getFavoriteDirectories(ctx.session.userId), + FileService.getFavoriteFiles(ctx.session.userId), ]); return { - directories: directories.map((directory) => ({ - id: directory.id, - parent: directory.parentId, - mekVersion: directory.mekVersion, - dek: directory.encDek, - dekVersion: directory.dekVersion, - name: directory.encName.ciphertext, - nameIv: directory.encName.iv, - })), - files: files.map((file) => ({ - id: file.id, - parent: file.parentId, - mekVersion: file.mekVersion, - dek: file.encDek, - dekVersion: file.dekVersion, - contentType: file.contentType, - name: file.encName.ciphertext, - nameIv: file.encName.iv, - createdAt: file.encCreatedAt?.ciphertext, - createdAtIv: file.encCreatedAt?.iv, - lastModifiedAt: file.encLastModifiedAt.ciphertext, - lastModifiedAtIv: file.encLastModifiedAt.iv, - })), + directories: directories.map(toDirectoryDto), + files: files.map(toFileDto), }; }), addDirectory: roleProcedure["activeClient"] - .input( - z.object({ - id: z.int().positive(), - }), - ) + .input(IdInputSchema) .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; - } + await DirectoryService.setDirectoryFavorite(ctx.session.userId, input.id, true); }), removeDirectory: roleProcedure["activeClient"] - .input( - z.object({ - id: z.int().positive(), - }), - ) + .input(IdInputSchema) .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; - } + await DirectoryService.setDirectoryFavorite(ctx.session.userId, input.id, false); }), - 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; - } - }), + addFile: roleProcedure["activeClient"].input(IdInputSchema).mutation(async ({ ctx, input }) => { + await FileService.setFileFavorite(ctx.session.userId, input.id, true); + }), removeFile: roleProcedure["activeClient"] - .input( - z.object({ - id: z.int().positive(), - }), - ) + .input(IdInputSchema) .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; - } + await FileService.setFileFavorite(ctx.session.userId, input.id, false); }), }); diff --git a/src/trpc/routers/file.ts b/src/trpc/routers/file.ts index 6151120..022f37a 100644 --- a/src/trpc/routers/file.ts +++ b/src/trpc/routers/file.ts @@ -1,97 +1,44 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { FileRepo, MediaRepo, IntegrityError } from "#lib/server/db/index.js"; -import { safeUnlink } from "#lib/server/modules/filesystem.js"; -import { router, roleProcedure } from "../init.server"; +import { FileService } from "#lib/server/services/index.js"; +import { toFileDto, toFileWithCategoriesDto } from "#trpc/dto.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { Base64Schema, EncryptedNameSchema, IdSchema, toCiphertext } from "#trpc/schemas.js"; const fileRouter = router({ get: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, }), ) .query(async ({ ctx, input }) => { - const file = await FileRepo.getFile(ctx.session.userId, input.id); - if (!file) { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" }); - } - - const categories = await FileRepo.getAllFileCategories(input.id); - return { - isLegacy: !!file.encContentIv, - 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, - isFavorite: file.isFavorite, - categories: categories.map((category) => ({ - id: category.id, - parent: category.parentId, - mekVersion: category.mekVersion, - dek: category.encDek, - dekVersion: category.dekVersion, - name: category.encName.ciphertext, - nameIv: category.encName.iv, - })), - }; + return toFileWithCategoriesDto(await FileService.getFile(ctx.session.userId, input.id)); }), bulkGet: roleProcedure["activeClient"] .input( z.object({ - ids: z.number().positive().array(), + ids: IdSchema.array(), }), ) .query(async ({ ctx, input }) => { - const files = await FileRepo.getFilesWithCategories(ctx.session.userId, input.ids); - return files.map((file) => ({ - id: file.id, - isLegacy: !!file.encContentIv, - 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, - isFavorite: file.isFavorite, - categories: file.categories.map((category) => ({ - id: category.id, - parent: category.parentId, - mekVersion: category.mekVersion, - dek: category.encDek, - dekVersion: category.dekVersion, - name: category.encName.ciphertext, - nameIv: category.encName.iv, - })), - })); + const files = await FileService.getFiles(ctx.session.userId, input.ids); + return files.map(toFileWithCategoriesDto); }), list: roleProcedure["activeClient"].query(async ({ ctx }) => { - return await FileRepo.getAllFileIds(ctx.session.userId); + return await FileService.getAllFileIds(ctx.session.userId); }), listByHash: roleProcedure["activeClient"] .input( z.object({ - hskVersion: z.int().positive(), - contentHmac: z.base64().nonempty(), + hskVersion: IdSchema, + contentHmac: Base64Schema, }), ) .query(async ({ ctx, input }) => { - return await FileRepo.getAllFileIdsByContentHmac( + return await FileService.getFileIdsByContentHmac( ctx.session.userId, input.hskVersion, input.contentHmac, @@ -99,84 +46,46 @@ const fileRouter = router({ }), listWithoutThumbnail: roleProcedure["activeClient"].query(async ({ ctx }) => { - const files = await FileRepo.getFilesWithoutThumbnail(ctx.session.userId); - return files.map((file) => ({ - id: file.id, - isLegacy: !!file.encContentIv, - 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, - isFavorite: file.isFavorite, - })); + const files = await FileService.getFilesWithoutThumbnail(ctx.session.userId); + return files.map(toFileDto); }), rename: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, dekVersion: z.date(), - name: z.base64().nonempty(), - nameIv: z.base64().nonempty(), + ...EncryptedNameSchema, }), ) .mutation(async ({ ctx, input }) => { - try { - await FileRepo.setFileEncName(ctx.session.userId, input.id, input.dekVersion, { - ciphertext: input.name, - iv: input.nameIv, - }); - } 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 === "Invalid DEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); - } - } - throw e; - } + await FileService.renameFile( + ctx.session.userId, + input.id, + input.dekVersion, + toCiphertext(input.name, input.nameIv), + ); }), delete: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, }), ) .mutation(async ({ ctx, input }) => { - try { - const { path, thumbnailPath } = await FileRepo.unregisterFile(ctx.session.userId, input.id); - safeUnlink(path); // Intended - safeUnlink(thumbnailPath); // Intended - } catch (e) { - if (e instanceof IntegrityError && e.message === "File not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" }); - } - throw e; - } + await FileService.deleteFile(ctx.session.userId, input.id); }), thumbnail: roleProcedure["activeClient"] .input( z.object({ - id: z.int().positive(), + id: IdSchema, }), ) .query(async ({ ctx, input }) => { - const thumbnail = await MediaRepo.getFileThumbnail(ctx.session.userId, input.id); - if (!thumbnail) { - throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" }); - } - - return { updatedAt: thumbnail.updatedAt }; + const { updatedAt } = await FileService.getFileThumbnail(ctx.session.userId, input.id); + return { updatedAt }; }), }); diff --git a/src/trpc/routers/hsk.ts b/src/trpc/routers/hsk.ts index cafa8a7..c9acc52 100644 --- a/src/trpc/routers/hsk.ts +++ b/src/trpc/routers/hsk.ts @@ -1,11 +1,11 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { HskRepo, IntegrityError } from "#lib/server/db/index.js"; -import { router, roleProcedure } from "../init.server"; +import { KeyService } from "#lib/server/services/index.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { Base64Schema, IdSchema } from "#trpc/schemas.js"; const hskRouter = router({ list: roleProcedure["activeClient"].query(async ({ ctx }) => { - const hsks = await HskRepo.getAllValidHsks(ctx.session.userId); + const hsks = await KeyService.getHsks(ctx.session.userId); return hsks.map(({ version, state, mekVersion, encHsk }) => ({ version, state, @@ -17,24 +17,17 @@ const hskRouter = router({ registerInitial: roleProcedure["activeClient"] .input( z.object({ - mekVersion: z.int().positive(), - hsk: z.base64().nonempty(), + mekVersion: IdSchema, + hsk: Base64Schema, }), ) .mutation(async ({ ctx, input }) => { - try { - await HskRepo.registerInitialHsk( - ctx.session.userId, - ctx.session.clientId, - input.mekVersion, - input.hsk, - ); - } catch (e) { - if (e instanceof IntegrityError && e.message === "HSK already registered") { - throw new TRPCError({ code: "CONFLICT", message: "Initial HSK already registered" }); - } - throw e; - } + await KeyService.registerInitialHsk( + ctx.session.userId, + ctx.session.clientId, + input.mekVersion, + input.hsk, + ); }), }); diff --git a/src/trpc/routers/mek.ts b/src/trpc/routers/mek.ts index 7641048..e0259ba 100644 --- a/src/trpc/routers/mek.ts +++ b/src/trpc/routers/mek.ts @@ -1,31 +1,11 @@ -import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { ClientRepo, MekRepo, IntegrityError } from "#lib/server/db/index.js"; -import { verifySignature } from "#lib/server/modules/crypto.js"; -import { router, roleProcedure } from "../init.server"; - -const verifyClientEncMekSig = async ( - userId: number, - clientId: number, - version: number, - encMek: string, - encMekSig: string, -) => { - const userClient = await ClientRepo.getUserClientWithDetails(userId, clientId); - if (!userClient) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" }); - } - - const data = JSON.stringify({ version, key: encMek }); - return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey); -}; +import { KeyService } from "#lib/server/services/index.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { Base64Schema } from "#trpc/schemas.js"; const mekRouter = router({ list: roleProcedure["activeClient"].query(async ({ ctx }) => { - const clientMeks = await MekRepo.getAllValidClientMeks( - ctx.session.userId, - ctx.session.clientId, - ); + const clientMeks = await KeyService.getClientMeks(ctx.session.userId, ctx.session.clientId); return clientMeks.map(({ version, state, encMek, encMekSig }) => ({ version, state, @@ -37,26 +17,17 @@ const mekRouter = router({ registerInitial: roleProcedure["pendingClient"] .input( z.object({ - mek: z.base64().nonempty(), - mekSig: z.base64().nonempty(), + mek: Base64Schema, + mekSig: Base64Schema, }), ) .mutation(async ({ ctx, input }) => { - const { userId, clientId } = ctx.session; - const { mek, mekSig } = input; - if (!(await verifyClientEncMekSig(userId, clientId, 1, mek, mekSig))) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid signature" }); - } - - try { - await MekRepo.registerInitialMek(userId, clientId, mek, mekSig); - await ClientRepo.setUserClientStateToActive(userId, clientId); - } catch (e) { - if (e instanceof IntegrityError && e.message === "MEK already registered") { - throw new TRPCError({ code: "CONFLICT", message: "Initial MEK already registered" }); - } - throw e; - } + await KeyService.registerInitialMek( + ctx.session.userId, + ctx.session.clientId, + input.mek, + input.mekSig, + ); }), }); diff --git a/src/trpc/routers/search.ts b/src/trpc/routers/search.ts index fe7d81a..d43dd9e 100644 --- a/src/trpc/routers/search.ts +++ b/src/trpc/routers/search.ts @@ -1,7 +1,8 @@ import { z } from "zod"; -import { DirectoryIdSchema } from "#lib/schemas/index.js"; -import { DirectoryRepo, FileRepo } from "#lib/server/db/index.js"; -import { router, roleProcedure } from "../init.server"; +import { SearchService } from "#lib/server/services/index.js"; +import { toDirectoryDto, toFileDto } from "#trpc/dto.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { DirectoryIdSchema, IdSchema } from "#trpc/schemas.js"; const searchRouter = router({ search: roleProcedure["activeClient"] @@ -9,51 +10,20 @@ const searchRouter = router({ z.object({ ancestor: DirectoryIdSchema.default("root"), inFavorites: z.boolean().default(false), - includeCategories: z.number().positive().array().default([]), - excludeCategories: z.number().positive().array().default([]), + includeCategories: IdSchema.array().default([]), + excludeCategories: IdSchema.array().default([]), }), ) .query(async ({ ctx, input }) => { - const [directories, files] = await Promise.all([ - input.includeCategories.length === 0 && input.excludeCategories.length === 0 - ? DirectoryRepo.searchDirectories(ctx.session.userId, { - parentId: input.ancestor, - inFavorites: input.inFavorites, - }) - : [], - FileRepo.searchFiles(ctx.session.userId, { - parentId: input.ancestor, - inFavorites: input.inFavorites, - includeCategoryIds: input.includeCategories, - excludeCategoryIds: input.excludeCategories, - }), - ]); + const { directories, files } = await SearchService.search(ctx.session.userId, { + ancestorId: input.ancestor, + inFavorites: input.inFavorites, + includeCategoryIds: input.includeCategories, + excludeCategoryIds: input.excludeCategories, + }); 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, - isFavorite: directory.isFavorite, - })), - 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, - isFavorite: file.isFavorite, - })), + directories: directories.map(toDirectoryDto), + files: files.map(toFileDto), }; }), }); diff --git a/src/trpc/routers/upload.ts b/src/trpc/routers/upload.ts index 99bb902..f7825b9 100644 --- a/src/trpc/routers/upload.ts +++ b/src/trpc/routers/upload.ts @@ -1,206 +1,87 @@ -import { TRPCError } from "@trpc/server"; -import { createHash } from "crypto"; -import { createReadStream } from "fs"; -import { mkdir, open } from "fs/promises"; import mime from "mime"; -import { dirname } from "path"; -import { v4 as uuidv4 } from "uuid"; import { z } from "zod"; -import { DirectoryIdSchema } from "#lib/schemas/index.js"; -import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "#lib/server/db/index.js"; -import db from "#lib/server/db/kysely.js"; -import env from "#lib/server/loadenv.js"; -import { safeUnlink } from "#lib/server/modules/filesystem.js"; -import { router, roleProcedure } from "../init.server"; - -const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours - -const sessionLocks = new Set(); - -const reserveUploadPath = async (path: string) => { - await mkdir(dirname(path), { recursive: true }); - const file = await open(path, "wx", 0o600); - await file.close(); -}; - -const generateFileUploadSession = async (userId: number) => { - const id = uuidv4(); - const path = `${env.libraryPath}/${userId}/${uuidv4()}`; - await reserveUploadPath(path); - return { id, path }; -}; - -const generateThumbnailUploadSession = async (userId: number) => { - const id = uuidv4(); - const path = `${env.thumbnailsPath}/${userId}/${id}`; - await reserveUploadPath(path); - return { id, path }; -}; +import { UploadService } from "#lib/server/services/index.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; +import { + Base64Schema, + DirectoryIdSchema, + EncryptedNameSchema, + IdSchema, + toCiphertext, +} from "#trpc/schemas.js"; const uploadRouter = router({ startFileUpload: roleProcedure["activeClient"] .input( z.object({ - chunks: z.int().positive(), + chunks: IdSchema, parent: DirectoryIdSchema, - mekVersion: z.int().positive(), - dek: z.base64().nonempty(), + mekVersion: IdSchema, + dek: Base64Schema, dekVersion: z.date(), - hskVersion: z.int().positive().optional(), + hskVersion: IdSchema.optional(), contentType: z .string() .trim() .nonempty() .refine((value) => mime.getExtension(value) !== null), - name: z.base64().nonempty(), - nameIv: z.base64().nonempty(), - createdAt: z.base64().nonempty().optional(), - createdAtIv: z.base64().nonempty().optional(), - lastModifiedAt: z.base64().nonempty(), - lastModifiedAtIv: z.base64().nonempty(), + ...EncryptedNameSchema, + createdAt: Base64Schema.optional(), + createdAtIv: Base64Schema.optional(), + lastModifiedAt: Base64Schema, + lastModifiedAtIv: Base64Schema, }), ) .mutation(async ({ ctx, input }) => { - const oneMinuteAgo = new Date(Date.now() - 60 * 1000); - const oneMinuteLater = new Date(Date.now() + 60 * 1000); - if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" }); - } - - const { id, path } = await generateFileUploadSession(ctx.session.userId); - - try { - await UploadRepo.createFileUploadSession({ - id, - userId: ctx.session.userId, - path, - totalChunks: input.chunks, - expiresAt: new Date(Date.now() + UPLOADS_EXPIRES), - parentId: input.parent, - mekVersion: input.mekVersion, - encDek: input.dek, - dekVersion: input.dekVersion, - hskVersion: input.hskVersion ?? null, - contentType: input.contentType, - encName: { ciphertext: input.name, iv: input.nameIv }, - encCreatedAt: - input.createdAt && input.createdAtIv - ? { ciphertext: input.createdAt, iv: input.createdAtIv } - : null, - encLastModifiedAt: { ciphertext: input.lastModifiedAt, iv: input.lastModifiedAtIv }, - }); - return { uploadId: id }; - } catch (e) { - await safeUnlink(path); - - if (e instanceof IntegrityError) { - if (e.message === "Inactive MEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid MEK version" }); - } else if (e.message === "Inactive HSK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid HSK version" }); - } else if (e.message === "Directory not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid parent directory id" }); - } - } - throw e; - } + return await UploadService.startFileUpload({ + userId: ctx.session.userId, + totalChunks: input.chunks, + parentId: input.parent, + mekVersion: input.mekVersion, + encDek: input.dek, + dekVersion: input.dekVersion, + hskVersion: input.hskVersion ?? null, + contentType: input.contentType, + encName: toCiphertext(input.name, input.nameIv), + encCreatedAt: + input.createdAt && input.createdAtIv + ? toCiphertext(input.createdAt, input.createdAtIv) + : null, + encLastModifiedAt: toCiphertext(input.lastModifiedAt, input.lastModifiedAtIv), + }); }), completeFileUpload: roleProcedure["activeClient"] .input( z.object({ uploadId: z.uuidv4(), - contentHmac: z.base64().nonempty().optional(), - encContentHash: z.base64().nonempty(), + contentHmac: Base64Schema.optional(), + encContentHash: Base64Schema, }), ) .mutation(async ({ ctx, input }) => { - const { uploadId } = input; - if (sessionLocks.has(uploadId)) { - throw new TRPCError({ code: "CONFLICT", message: "Completion already in progress" }); - } else { - sessionLocks.add(uploadId); - } - - try { - const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); - if (session?.type !== "file") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" }); - } else if ( - (session.hskVersion && !input.contentHmac) || - (!session.hskVersion && input.contentHmac) - ) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid content HMAC" }); - } else if (session.uploadedChunks < session.totalChunks) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" }); - } - - const hashStream = createHash("sha256"); - - for await (const chunk of createReadStream(session.path)) { - hashStream.update(chunk); - } - - const hash = hashStream.digest("base64"); - if (hash !== input.encContentHash) { - await UploadRepo.deleteUploadSession(db, uploadId); - await safeUnlink(session.path); - throw new TRPCError({ code: "CONFLICT", message: "Uploaded file corrupted" }); - } - - const fileId = await db.transaction().execute(async (trx) => { - const { id: fileId } = await FileRepo.registerFile(trx, { - ...session, - userId: ctx.session.userId, - path: session.path, - contentHmac: input.contentHmac ?? null, - encContentHash: hash, - encContentIv: null, - }); - await UploadRepo.deleteUploadSession(trx, uploadId); - return fileId; - }); - - return { file: fileId }; - } finally { - sessionLocks.delete(uploadId); - } + const { fileId } = await UploadService.completeFileUpload( + ctx.session.userId, + input.uploadId, + input.contentHmac ?? null, + input.encContentHash, + ); + return { file: fileId }; }), startFileThumbnailUpload: roleProcedure["activeClient"] .input( z.object({ - file: z.int().positive(), + file: IdSchema, dekVersion: z.date(), }), ) .mutation(async ({ ctx, input }) => { - const { id, path } = await generateThumbnailUploadSession(ctx.session.userId); - - try { - await UploadRepo.createThumbnailUploadSession({ - id, - type: "thumbnail", - userId: ctx.session.userId, - path, - totalChunks: 1, // Up to 4 MiB - expiresAt: new Date(Date.now() + UPLOADS_EXPIRES), - fileId: input.file, - dekVersion: input.dekVersion, - }); - return { uploadId: id }; - } catch (e) { - await safeUnlink(path); - - if (e instanceof IntegrityError) { - if (e.message === "File not found") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" }); - } else if (e.message === "Invalid DEK version") { - throw new TRPCError({ code: "BAD_REQUEST", message: e.message }); - } - } - throw e; - } + return await UploadService.startFileThumbnailUpload( + ctx.session.userId, + input.file, + input.dekVersion, + ); }), completeFileThumbnailUpload: roleProcedure["activeClient"] @@ -210,43 +91,7 @@ const uploadRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - const { uploadId } = input; - if (sessionLocks.has(uploadId)) { - throw new TRPCError({ code: "CONFLICT", message: "Completion already in progress" }); - } else { - sessionLocks.add(uploadId); - } - - try { - const session = await UploadRepo.getUploadSession(uploadId, ctx.session.userId); - if (session?.type !== "thumbnail") { - throw new TRPCError({ code: "NOT_FOUND", message: "Invalid upload id" }); - } else if (session.uploadedChunks < session.totalChunks) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Upload not completed" }); - } - - const oldThumbnailPath = await db.transaction().execute(async (trx) => { - const oldPath = await MediaRepo.updateFileThumbnail( - trx, - ctx.session.userId, - session.fileId, - session.dekVersion, - session.path, - null, - ); - await UploadRepo.deleteUploadSession(trx, uploadId); - return oldPath; - }); - await safeUnlink(oldThumbnailPath); - } catch (e) { - if (e instanceof IntegrityError && e.message === "Invalid DEK version") { - // DEK rotated after this upload started - throw new TRPCError({ code: "CONFLICT", message: e.message }); - } - throw e; - } finally { - sessionLocks.delete(uploadId); - } + await UploadService.completeFileThumbnailUpload(ctx.session.userId, input.uploadId); }), }); diff --git a/src/trpc/routers/user.ts b/src/trpc/routers/user.ts index f915e13..df83c18 100644 --- a/src/trpc/routers/user.ts +++ b/src/trpc/routers/user.ts @@ -1,15 +1,10 @@ -import { TRPCError } from "@trpc/server"; -import { UserRepo } from "#lib/server/db/index.js"; -import { router, roleProcedure } from "../init.server"; +import { UserService } from "#lib/server/services/index.js"; +import { router, roleProcedure } from "#trpc/init.server.js"; const userRouter = router({ get: roleProcedure["any"].query(async ({ ctx }) => { - const user = await UserRepo.getUser(ctx.session.userId); - if (!user) { - throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" }); - } - - return { email: user.email, nickname: user.nickname }; + const { email, nickname } = await UserService.getUser(ctx.session.userId); + return { email, nickname }; }), }); diff --git a/src/trpc/schemas.ts b/src/trpc/schemas.ts new file mode 100644 index 0000000..5d6f3c2 --- /dev/null +++ b/src/trpc/schemas.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const IdSchema = z.int().positive(); +export const DirectoryIdSchema = z.union([z.literal("root"), IdSchema]); +export const CategoryIdSchema = z.union([z.literal("root"), IdSchema]); +export const Base64Schema = z.base64().nonempty(); + +export const EncryptedNameSchema = { + name: Base64Schema, + nameIv: Base64Schema, +}; + +export const toCiphertext = (ciphertext: string, iv: string) => ({ ciphertext, iv }); diff --git a/tests/helpers/database.ts b/tests/helpers/database.ts index ea23330..f05326e 100644 --- a/tests/helpers/database.ts +++ b/tests/helpers/database.ts @@ -82,3 +82,33 @@ export const newEntryParams = (userId: number, parentId: number | "root") => ({ dekVersion: new Date(), encName: ciphertext(), }); + +export const createFile = async (userId: number, parentId: DirectoryId, path: string) => { + const n = nextSequence(); + const { id } = await db + .insertInto("file") + .values({ + parent_id: parentId === "root" ? null : parentId, + user_id: userId, + path, + master_encryption_key_version: 1, + encrypted_data_encryption_key: `dek${n}`, + data_encryption_key_version: new Date(), + hmac_secret_key_version: null, + content_hmac: null, + content_type: "image/png", + encrypted_content_iv: null, + encrypted_content_hash: "hash", + encrypted_name: ciphertext(), + encrypted_created_at: null, + encrypted_last_modified_at: ciphertext(), + }) + .returning("id") + .executeTakeFirstOrThrow(); + return id; +}; + +export const getQueuedDeletions = async () => { + const rows = await db.selectFrom("file_deletion_queue").select("path").execute(); + return rows.map(({ path }) => path).sort(); +}; diff --git a/tests/helpers/storage.ts b/tests/helpers/storage.ts new file mode 100644 index 0000000..888b228 --- /dev/null +++ b/tests/helpers/storage.ts @@ -0,0 +1,12 @@ +import { mkdir, writeFile } from "fs/promises"; +import { dirname, join } from "path"; +import env from "#lib/server/config.js"; + +let sequence = 0; + +export const createStoredFile = async (content: string | Buffer = "content") => { + const path = join(env.libraryPath, "test", `file${++sequence}`); + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); + return path; +}; diff --git a/tests/server/deletion.test.ts b/tests/server/deletion.test.ts new file mode 100644 index 0000000..78d7b0b --- /dev/null +++ b/tests/server/deletion.test.ts @@ -0,0 +1,92 @@ +import { access } from "fs/promises"; +import { beforeEach, describe, expect, it } from "vitest"; +import { UploadRepo } from "#lib/server/db/index.js"; +import { DirectoryService, FileService } from "#lib/server/services/index.js"; +import { processPendingFileDeletions } from "#lib/server/services/fileDeletion.js"; +import { + ciphertext, + createDirectory, + createFile, + createUser, + getQueuedDeletions, + newEntryParams, + resetDatabase, +} from "../helpers/database"; +import { createStoredFile } from "../helpers/storage"; + +const exists = (path: string) => + access(path).then( + () => true, + () => false, + ); + +beforeEach(resetDatabase); + +describe("file deletion", () => { + it("deletes the stored file after the database row is deleted", async () => { + const { userId } = await createUser(); + const path = await createStoredFile(); + const fileId = await createFile(userId, "root", path); + + await FileService.deleteFile(userId, fileId); + await processPendingFileDeletions(); + + expect(await exists(path)).toBe(false); + expect(await getQueuedDeletions()).toEqual([]); + }); + + it("keeps failed deletions in the queue", async () => { + const { userId } = await createUser(); + const fileId = await createFile(userId, "root", "/proc/version"); // Can't be deleted + + await FileService.deleteFile(userId, fileId); + await processPendingFileDeletions(); + + expect(await getQueuedDeletions()).toEqual(["/proc/version"]); + }); + + it("deletes directories recursively including pending uploads", async () => { + const { userId } = await createUser(); + const rootId = await createDirectory(userId); + const childId = await createDirectory(userId, rootId); + const grandchildId = await createDirectory(userId, childId); + const otherId = await createDirectory(userId); + + const paths = await Promise.all([1, 2, 3, 4].map(() => createStoredFile())); + const fileIds = [ + await createFile(userId, rootId, paths[0]!), + await createFile(userId, grandchildId, paths[1]!), + ]; + const otherFileId = await createFile(userId, otherId, paths[2]!); + await UploadRepo.createFileUploadSession({ + ...newEntryParams(userId, childId), + id: crypto.randomUUID(), + path: paths[3]!, + totalChunks: 1, + expiresAt: new Date(Date.now() + 60_000), + hskVersion: null, + contentType: "image/png", + encCreatedAt: null, + encLastModifiedAt: ciphertext(), + }); + + const { deletedFileIds } = await DirectoryService.deleteDirectory(userId, rootId); + + expect(deletedFileIds.sort()).toEqual(fileIds.sort()); + expect(await getQueuedDeletions()).toEqual([paths[0], paths[1], paths[3]].sort()); + await expect(DirectoryService.getDirectory(userId, grandchildId)).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + expect((await FileService.getFile(userId, otherFileId)).id).toBe(otherFileId); + }); + + it("rejects deleting another user's directory", async () => { + const { userId: ownerId } = await createUser(); + const { userId: otherId } = await createUser(); + const directoryId = await createDirectory(ownerId); + + await expect(DirectoryService.deleteDirectory(otherId, directoryId)).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + }); +}); diff --git a/tests/server/download.test.ts b/tests/server/download.test.ts index 6b4e1ee..1cffa25 100644 --- a/tests/server/download.test.ts +++ b/tests/server/download.test.ts @@ -4,7 +4,7 @@ import { join } from "path"; import { buffer } from "stream/consumers"; import type { ReadableStream } from "stream/web"; import { describe, expect, it } from "vitest"; -import { createEncContentStream } from "#lib/server/services/file.js"; +import { createEncContentStream } from "#lib/server/storage/index.js"; const createFile = async (content: string) => { const path = join(await mkdtemp(join(tmpdir(), "arkvault-test-")), "file"); diff --git a/tests/server/trpc.test.ts b/tests/server/trpc.test.ts new file mode 100644 index 0000000..934325c --- /dev/null +++ b/tests/server/trpc.test.ts @@ -0,0 +1,55 @@ +import type { RequestEvent } from "@sveltejs/kit"; +import { TRPCError } from "@trpc/server"; +import { beforeEach, describe, expect, it } from "vitest"; +import { appRouter } from "#trpc/router.server.js"; +import { createDirectory, createUser, resetDatabase } from "../helpers/database"; + +const createCaller = (userId: number, clientId?: number) => + appRouter.createCaller({ + locals: { ip: "127.0.0.1", userAgent: "test", session: { id: "session", userId, clientId } }, + } as RequestEvent); + +beforeEach(resetDatabase); + +describe("tRPC error mapping", () => { + it("maps integrity errors to tRPC errors", async () => { + const { userId: ownerId } = await createUser(); + const { userId, clientId } = await createUser(); + const directoryId = await createDirectory(ownerId); + + const error = await createCaller(userId, clientId) + .directory.create({ + parent: directoryId, + mekVersion: 1, + dek: "ZGVr", + dekVersion: new Date(), + name: "bmFtZQ==", + nameIv: "aXY=", + }) + .catch((e) => e); + + expect(error).toBeInstanceOf(TRPCError); + expect(error).toMatchObject({ code: "NOT_FOUND", message: "Directory not found" }); + }); + + it("maps service errors to tRPC errors", async () => { + const { userId, clientId } = await createUser(); + + await expect( + createCaller(userId, clientId).directory.create({ + parent: "root", + mekVersion: 1, + dek: "ZGVr", + dekVersion: new Date(Date.now() - 3600 * 1000), + name: "bmFtZQ==", + nameIv: "aXY=", + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST", message: "Invalid DEK version" }); + }); + + it("rejects clients that are not active", async () => { + const { userId } = await createUser(); + + await expect(createCaller(userId).file.list()).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); +}); diff --git a/tests/server/upload.test.ts b/tests/server/upload.test.ts index 320a730..d925757 100644 --- a/tests/server/upload.test.ts +++ b/tests/server/upload.test.ts @@ -1,5 +1,8 @@ +import { createHash } from "crypto"; +import { Readable } from "stream"; import { beforeEach, describe, expect, it } from "vitest"; import { UploadRepo } from "#lib/server/db/index.js"; +import { FileService, UploadService } from "#lib/server/services/index.js"; import { isChunkUploaded } from "#lib/server/services/upload.js"; import { ciphertext, createUser, newEntryParams, resetDatabase } from "../helpers/database"; @@ -33,3 +36,66 @@ describe("upload session bitmap", () => { } }); }); + +describe("upload completion", () => { + it("registers the file only once when completed concurrently", async () => { + const { userId } = await createUser(); + const { uploadId } = await UploadService.startFileUpload({ + ...newEntryParams(userId, "root"), + totalChunks: 1, + hskVersion: null, + contentType: "image/png", + encCreatedAt: null, + encLastModifiedAt: ciphertext(), + }); + + const chunk = Buffer.alloc(100, 1); + await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([chunk])); + const hash = createHash("sha256").update(chunk).digest("base64"); + + const results = await Promise.allSettled([ + UploadService.completeFileUpload(userId, uploadId, null, hash), + UploadService.completeFileUpload(userId, uploadId, null, hash), + ]); + + expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength(1); + expect(results.find(({ status }) => status === "rejected")).toMatchObject({ + reason: { code: "NOT_FOUND" }, + }); + expect(await FileService.getAllFileIds(userId)).toHaveLength(1); + }); + + it("discards corrupted uploads", async () => { + const { userId } = await createUser(); + const { uploadId } = await UploadService.startFileUpload({ + ...newEntryParams(userId, "root"), + totalChunks: 1, + hskVersion: null, + contentType: "image/png", + encCreatedAt: null, + encLastModifiedAt: ciphertext(), + }); + await UploadService.uploadChunk(userId, uploadId, 1, Readable.from([Buffer.alloc(100)])); + + await expect( + UploadService.completeFileUpload(userId, uploadId, null, "invalid"), + ).rejects.toMatchObject({ code: "CONFLICT" }); + expect(await UploadRepo.getUploadSession(uploadId, userId)).toBeNull(); + }); + + it("rejects chunks of invalid sizes", async () => { + const { userId } = await createUser(); + const { uploadId } = await UploadService.startFileUpload({ + ...newEntryParams(userId, "root"), + totalChunks: 2, + hskVersion: null, + contentType: "image/png", + encCreatedAt: null, + encLastModifiedAt: ciphertext(), + }); + + await expect( + UploadService.uploadChunk(userId, uploadId, 1, Readable.from([Buffer.alloc(100)])), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); +}); diff --git a/tests/setup/server.ts b/tests/setup/server.ts index 6e27723..a257d63 100644 --- a/tests/setup/server.ts +++ b/tests/setup/server.ts @@ -5,7 +5,7 @@ import { vi } from "vitest"; const storagePath = await mkdtemp(join(tmpdir(), "arkvault-test-")); -vi.mock("#lib/server/loadenv.js", async () => { +vi.mock("#lib/server/config.js", async () => { const { inject } = await import("vitest"); return { default: {