서버 코드를 Router, Service, Repository 계층으로 분리하고 오류 처리 및 파일 삭제 로직 개선

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 00:00:21 +09:00
1 parent ed732be999
commit 6a6aa1765b
72 files changed
+1928 -1509

No files matched your search

+11 -11
View File
@@ -1,17 +1,17 @@
import { defineConfig } from "kysely-ctl"; 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({ export default defineConfig({
dialect: "pg", dialect: createDialect({
dialectConfig: { host: optional("DATABASE_HOST"),
pool: new Pool({ port: optional("DATABASE_PORT") ? Number(optional("DATABASE_PORT")) : undefined,
host: process.env.DATABASE_HOST, user: optional("DATABASE_USER"),
port: process.env.DATABASE_PORT ? parseInt(process.env.DATABASE_PORT) : undefined, password: optional("DATABASE_PASSWORD"),
user: process.env.DATABASE_USER, name: optional("DATABASE_NAME"),
password: process.env.DATABASE_PASSWORD, }),
database: process.env.DATABASE_NAME,
}),
},
migrations: { migrations: {
migrationFolder: "./src/lib/server/db/migrations", migrationFolder: "./src/lib/server/db/migrations",
}, },
-1
View File
@@ -70,7 +70,6 @@
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.23.1", "pg": "^8.23.1",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"uuid": "^14.0.3",
"zod": "^4.6.5" "zod": "^4.6.5"
}, },
"engines": { "engines": {
-9
View File
@@ -29,9 +29,6 @@ importers:
superjson: superjson:
specifier: ^2.2.6 specifier: ^2.2.6
version: 2.2.6 version: 2.2.6
uuid:
specifier: ^14.0.3
version: 14.0.3
zod: zod:
specifier: ^4.6.5 specifier: ^4.6.5
version: 4.6.5 version: 4.6.5
@@ -2470,10 +2467,6 @@ packages:
util-deprecate@1.0.2: util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@14.0.3:
resolution: {integrity: sha512-Sc5u1VVln93y1Xnz4R+CunOZc7R9LUcTmhj7Rc7Eg3HVcf7rVn2FOoa812fe0W8p3jD7xeoGSYbfqCGGH2B6xw==}
hasBin: true
vite@8.3.4: vite@8.3.4:
resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -4771,8 +4764,6 @@ snapshots:
util-deprecate@1.0.2: {} 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): vite@8.3.4(@types/node@26.6.5)(jiti@1.21.7)(yaml@2.9.1):
dependencies: dependencies:
lightningcss: 1.33.0 lightningcss: 1.33.0
+8 -10
View File
@@ -1,23 +1,21 @@
import { sequence, type ServerInit } from "@sveltejs/kit/hooks"; import { sequence, type ServerInit } from "@sveltejs/kit/hooks";
import schedule from "node-schedule"; 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 { 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 { 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 () => { export const init: ServerInit = async () => {
await migrateDB(); await migrateDB();
schedule.scheduleJob("0 * * * *", () => { schedule.scheduleJob("0 * * * *", () => {
cleanupExpiredUserClientChallenges(); ClientRepo.cleanupExpiredUserClientChallenges();
cleanupExpiredSessions(); SessionRepo.cleanupExpiredSessions();
cleanupExpiredSessionUpgradeChallenges(); SessionRepo.cleanupExpiredSessionUpgradeChallenges();
cleanupExpiredUploadSessions(); UploadService.cleanupExpiredUploadSessions();
FileDeletionService.schedulePendingFileDeletions();
}); });
FileDeletionService.schedulePendingFileDeletions();
}; };
export const handle = sequence(setAgentInfo, authenticate); export const handle = sequence(setAgentInfo, authenticate);
-4
View File
@@ -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()]);
-1
View File
@@ -1 +0,0 @@
export * from "./filesystem";
@@ -1,7 +1,7 @@
import { error } from "@sveltejs/kit";
import { ClientRepo, SessionRepo, IntegrityError } from "#lib/server/db/index.js"; import { ClientRepo, SessionRepo, IntegrityError } from "#lib/server/db/index.js";
import env from "#lib/server/loadenv.js"; import env from "#lib/server/config.js";
import { verifySessionId } from "#lib/server/modules/crypto.js"; import { verifySessionId } from "#lib/server/crypto.js";
import { AppError } from "#lib/server/errors.js";
export interface Session { export interface Session {
sessionId: string; 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 = { export const cookieOptions = {
path: "/", path: "/",
maxAge: env.session.exp / 1000, maxAge: env.session.exp / 1000,
@@ -64,56 +54,21 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
} }
}; };
export const authorizeInternal = async ( const ensureUserClientState = async (
locals: App.Locals, userId: number,
requiredPermission: SessionPermission, clientId: number | undefined,
): Promise<Session> => { state: "pending" | "active",
if (!locals.session) { ) => {
throw new AuthorizationError(500, "Unauthenticated"); if (!clientId) {
throw new AppError("FORBIDDEN", "Forbidden");
} }
const { id: sessionId, userId, clientId } = locals.session; const userClient = await ClientRepo.getUserClient(userId, clientId);
if (!userClient) {
switch (requiredPermission) { throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
case "any": } else if (userClient.state !== state) {
break; throw new AppError("FORBIDDEN", "Forbidden");
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;
}
} }
return { sessionId, userId, clientId };
}; };
export async function authorize( export async function authorize(
@@ -130,12 +85,32 @@ export async function authorize(
locals: App.Locals, locals: App.Locals,
requiredPermission: SessionPermission, requiredPermission: SessionPermission,
): Promise<Session> { ): Promise<Session> {
try { if (!locals.session) {
return await authorizeInternal(locals, requiredPermission); throw new AppError("UNAUTHORIZED", "Unauthenticated");
} catch (e) {
if (e instanceof AuthorizationError) {
error(e.status, e.message);
}
throw e;
} }
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 };
} }
File renamed without changes.
File renamed without changes.
+22
View File
@@ -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,
}),
});
};
+37 -30
View File
@@ -1,34 +1,41 @@
type IntegrityErrorMessages = import { AppError, type ErrorCode } from "#lib/server/errors.js";
// 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";
export class IntegrityError extends Error { const errorCodes = {
constructor(public message: IntegrityErrorMessages) { // Category
super(message); "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<string, ErrorCode>;
type IntegrityErrorMessage = keyof typeof errorCodes;
export class IntegrityError extends AppError {
constructor(public override readonly message: IntegrityErrorMessage) {
super(errorCodes[message], message);
this.name = "IntegrityError"; this.name = "IntegrityError";
} }
} }
+12 -10
View File
@@ -1,12 +1,14 @@
export * as CategoryRepo from "./category"; export * as CategoryRepo from "./repositories/category";
export * as ClientRepo from "./client"; export * as ClientRepo from "./repositories/client";
export * as DirectoryRepo from "./directory"; export * as DirectoryRepo from "./repositories/directory";
export * as FileRepo from "./file"; export * as FileRepo from "./repositories/file";
export * as HskRepo from "./hsk"; export * as FileDeletionRepo from "./repositories/fileDeletion";
export * as MediaRepo from "./media"; export * as HskRepo from "./repositories/hsk";
export * as MekRepo from "./mek"; export * as MediaRepo from "./repositories/media";
export * as SessionRepo from "./session"; export * as MekRepo from "./repositories/mek";
export * as UploadRepo from "./upload"; export * as SessionRepo from "./repositories/session";
export * as UserRepo from "./user"; export * as UploadRepo from "./repositories/upload";
export * as UserRepo from "./repositories/user";
export * from "./error"; export * from "./error";
export { default as db } from "./kysely";
+4 -14
View File
@@ -1,21 +1,11 @@
import { Kysely, PostgresDialect } from "kysely"; import { Kysely } from "kysely";
import { Migrator } from "kysely/migration"; import { Migrator } from "kysely/migration";
import pg from "pg"; import env from "#lib/server/config.js";
import env from "#lib/server/loadenv.js"; import { createDialect } from "./dialect";
import migrations from "./migrations"; import migrations from "./migrations";
import type { Database } from "./schema"; import type { Database } from "./schema";
const dialect = new PostgresDialect({ const db = new Kysely<Database>({ dialect: createDialect(env.database) });
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<Database>({ dialect });
export const migrateDB = async () => { export const migrateDB = async () => {
if (env.nodeEnv !== "production") return; if (env.nodeEnv !== "production") return;
@@ -0,0 +1,17 @@
import { Kysely } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// 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<any>) => {
await db.schema.dropTable("file_deletion_queue").execute();
};
+2
View File
@@ -3,6 +3,7 @@ import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail"; import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload"; import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload";
import * as AddFavorites1768643000 from "./1768643000-AddFavorites"; import * as AddFavorites1768643000 from "./1768643000-AddFavorites";
import * as AddFileDeletionQueue1791730000 from "./1791730000-AddFileDeletionQueue";
export default { export default {
"1737357000-Initial": Initial1737357000, "1737357000-Initial": Initial1737357000,
@@ -10,4 +11,5 @@ export default {
"1738409340-AddThumbnail": AddThumbnail1738409340, "1738409340-AddThumbnail": AddThumbnail1738409340,
"1768062380-AddChunkedUpload": AddChunkedUpload1768062380, "1768062380-AddChunkedUpload": AddChunkedUpload1768062380,
"1768643000-AddFavorites": AddFavorites1768643000, "1768643000-AddFavorites": AddFavorites1768643000,
"1791730000-AddFileDeletionQueue": AddFileDeletionQueue1791730000,
}; };
@@ -1,8 +1,10 @@
import { IntegrityError } from "./error"; import type { Selectable } from "kysely";
import db from "./kysely"; import { IntegrityError } from "../error";
import type { Ciphertext } from "./schema"; import db from "../kysely";
import type { CategoryTable, Ciphertext } from "../schema";
import { lockActiveMekVersion } from "./mek";
interface Category { export interface Category {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;
userId: number; userId: number;
@@ -14,6 +16,21 @@ interface Category {
export type NewCategory = Omit<Category, "id">; export type NewCategory = Omit<Category, "id">;
// Timestamps are serialized as strings when the row is selected as JSON
type CategoryRow = Omit<Selectable<CategoryTable>, "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 ( export const ensureCategoryExists = async (
trx: typeof db, trx: typeof db,
userId: number, userId: number,
@@ -36,17 +53,7 @@ export const ensureCategoryExists = async (
export const registerCategory = async (params: NewCategory) => { export const registerCategory = async (params: NewCategory) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const mek = await trx await lockActiveMekVersion(trx, params.userId, params.mekVersion);
.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 ensureCategoryExists(trx, params.userId, params.parentId); 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", "is", null)
: query.where("parent_id", "=", parentId); : query.where("parent_id", "=", parentId);
const categories = await query.execute(); const categories = await query.execute();
return categories.map( return categories.map(toCategory);
(category) =>
({
id: category.id,
parentId: category.parent_id ?? "root",
userId: category.user_id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}) satisfies Category,
);
}; };
export const getCategory = async (userId: number, categoryId: number) => { export const getCategory = async (userId: number, categoryId: number) => {
@@ -103,17 +99,7 @@ export const getCategory = async (userId: number, categoryId: number) => {
.where("user_id", "=", userId) .where("user_id", "=", userId)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
return category return category ? toCategory(category) : null;
? ({
id: category.id,
parentId: category.parent_id ?? "root",
userId: category.user_id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
} satisfies Category)
: null;
}; };
export const setCategoryEncName = async ( export const setCategoryEncName = async (
@@ -1,7 +1,7 @@
import pg from "pg"; import pg from "pg";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "./kysely"; import db from "../kysely";
import type { UserClientState } from "./schema"; import type { UserClientState } from "../schema";
interface Client { interface Client {
id: number; id: number;
@@ -1,9 +1,11 @@
import type { Selectable } from "kysely"; import type { Selectable } from "kysely";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "./kysely"; import db from "../kysely";
import type { Ciphertext, DirectoryTable } from "./schema"; import type { Ciphertext, DirectoryTable } from "../schema";
import { enqueueFileDeletions } from "./fileDeletion";
import { lockActiveMekVersion } from "./mek";
interface Directory { export interface Directory {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
userId: number; userId: number;
@@ -47,17 +49,7 @@ export const ensureDirectoryExists = async (
export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => { export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const mek = await trx await lockActiveMekVersion(trx, params.userId, params.mekVersion);
.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 ensureDirectoryExists(trx, params.userId, params.parentId); await ensureDirectoryExists(trx, params.userId, params.parentId);
@@ -251,50 +243,59 @@ export const setDirectoryFavorite = async (
}; };
export const unregisterDirectory = async (userId: number, directoryId: number) => { export const unregisterDirectory = async (userId: number, directoryId: number) => {
return await db return await db.transaction().execute(async (trx) => {
.transaction() const directories = await trx
.setIsolationLevel("repeatable read") // TODO: Sufficient? .withRecursive("directory_tree", (db) =>
.execute(async (trx) => { db
const unregisterFiles = async (parentId: number) => {
const files = await trx
.selectFrom("file")
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
.where("file.parent_id", "=", parentId)
.where("file.user_id", "=", userId)
.forUpdate("file")
.execute();
await trx
.deleteFrom("file")
.where("parent_id", "=", parentId)
.where("user_id", "=", userId)
.execute();
return files;
};
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await trx
.selectFrom("directory") .selectFrom("directory")
.select("id") .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("id", "=", directoryId)
.where("user_id", "=", userId) .where("user_id", "=", userId)
.executeTakeFirst(); .unionAll(
if (deleteRes.numDeletedRows === 0n) { db
throw new IntegrityError("Directory not found"); .selectFrom("directory as d")
} .innerJoin("directory_tree as dt", "d.parent_id", "dt.id")
return files.concat(...subDirectoryFilePaths); .select("d.id"),
}; ),
return await unregisterDirectoryRecursively(directoryId); )
}); .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 };
});
}; };
@@ -1,11 +1,13 @@
import { sql, type Selectable } from "kysely"; import { sql, type Selectable } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres"; import { jsonArrayFrom } from "kysely/helpers/postgres";
import pg from "pg"; import pg from "pg";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "./kysely"; import db from "../kysely";
import type { Ciphertext, FileTable } from "./schema"; import type { Ciphertext, FileTable } from "../schema";
import { toCategory } from "./category";
import { enqueueFileDeletions } from "./fileDeletion";
interface File { export interface File {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
userId: number; userId: number;
@@ -24,15 +26,6 @@ interface File {
isFavorite: boolean; isFavorite: boolean;
} }
interface FileCategory {
id: number;
parentId: CategoryId;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
const toFile = (row: Selectable<FileTable>): File => ({ const toFile = (row: Selectable<FileTable>): File => ({
id: row.id, id: row.id,
parentId: row.parent_id ?? "root", parentId: row.parent_id ?? "root",
@@ -209,17 +202,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[])
.execute(); .execute();
return files.map((file) => ({ return files.map((file) => ({
...toFile(file), ...toFile(file),
categories: file.categories.map( categories: file.categories.map(toCategory),
(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,
),
})); }));
}; };
@@ -387,7 +370,7 @@ export const setFileEncName = async (
}; };
export const unregisterFile = async (userId: number, fileId: number) => { 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 const file = await trx
.selectFrom("file") .selectFrom("file")
.leftJoin("thumbnail", "file.id", "thumbnail.file_id") .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"); 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(); 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") .selectAll("category")
.where("file_id", "=", fileId) .where("file_id", "=", fileId)
.execute(); .execute();
return categories.map( return categories.map(toCategory);
(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,
);
}; };
export const removeFileFromCategory = async (fileId: number, categoryId: number) => { export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
@@ -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();
}
};
@@ -1,7 +1,7 @@
import pg from "pg"; import pg from "pg";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "./kysely"; import db from "../kysely";
import type { HskState } from "./schema"; import type { HskState } from "../schema";
interface Hsk { interface Hsk {
userId: number; userId: number;
@@ -11,6 +11,20 @@ interface Hsk {
encHsk: string; 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 ( export const registerInitialHsk = async (
userId: number, userId: number,
createdBy: number, createdBy: number,
@@ -1,6 +1,7 @@
import type { NotNull } from "kysely"; import type { NotNull } from "kysely";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "./kysely"; import db from "../kysely";
import { enqueueFileDeletions } from "./fileDeletion";
interface Thumbnail { interface Thumbnail {
id: number; id: number;
@@ -60,7 +61,7 @@ export const updateFileThumbnail = async (
}), }),
) )
.execute(); .execute();
return thumbnail?.oldPath ?? null; await enqueueFileDeletions(trx, [thumbnail?.oldPath]);
}; };
export const getFileThumbnail = async (userId: number, fileId: number) => { export const getFileThumbnail = async (userId: number, fileId: number) => {
@@ -1,7 +1,7 @@
import pg from "pg"; import pg from "pg";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "./kysely"; import db from "../kysely";
import type { MekState } from "./schema"; import type { MekState } from "../schema";
interface Mek { interface Mek {
userId: number; userId: number;
@@ -15,6 +15,20 @@ interface ClientMekWithDetails extends Mek {
encMekSig: string; 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 ( export const registerInitialMek = async (
userId: number, userId: number,
createdBy: number, createdBy: number,
@@ -1,7 +1,7 @@
import pg from "pg"; import pg from "pg";
import env from "#lib/server/loadenv.js"; import env from "#lib/server/config.js";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "./kysely"; import db from "../kysely";
export const createSession = async ( export const createSession = async (
userId: number, userId: number,
@@ -1,8 +1,11 @@
import { sql } from "kysely"; import { sql, type Selectable } from "kysely";
import { IntegrityError } from "./error"; import { IntegrityError } from "../error";
import db from "../kysely";
import type { Ciphertext, UploadSessionTable } from "../schema";
import { ensureDirectoryExists } from "./directory"; import { ensureDirectoryExists } from "./directory";
import db from "./kysely"; import { enqueueFileDeletions } from "./fileDeletion";
import type { Ciphertext } from "./schema"; import { lockActiveHskVersion } from "./hsk";
import { lockActiveMekVersion } from "./mek";
interface BaseUploadSession { interface BaseUploadSession {
id: string; id: string;
@@ -37,30 +40,10 @@ export const createFileUploadSession = async (
params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">, params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
) => { ) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
const mek = await trx await lockActiveMekVersion(trx, params.userId, params.mekVersion);
.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");
}
if (params.hskVersion) { if (params.hskVersion) {
const hsk = await trx await lockActiveHskVersion(trx, params.userId, params.hskVersion);
.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 ensureDirectoryExists(trx, params.userId, params.parentId); await ensureDirectoryExists(trx, params.userId, params.parentId);
@@ -124,18 +107,8 @@ export const createThumbnailUploadSession = async (
}); });
}; };
export const getUploadSession = async (sessionId: string, userId: number) => { const toUploadSession = (session: Selectable<UploadSessionTable>) => {
const session = await db if (session.type === "file") {
.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") {
return { return {
type: "file", type: "file",
id: session.id, 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) => { export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
await db await db
.updateTable("upload_session") .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(); 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 () => { export const cleanupExpiredUploadSessions = async () => {
const sessions = await db await db.transaction().execute(async (trx) => {
.deleteFrom("upload_session") const sessions = await trx
.where("expires_at", "<=", new Date()) .deleteFrom("upload_session")
.returning("path") .where("expires_at", "<=", new Date())
.execute(); .returning("path")
return sessions.map(({ path }) => path); .execute();
await enqueueFileDeletions(
trx,
sessions.map(({ path }) => path),
);
});
}; };
@@ -1,4 +1,4 @@
import db from "./kysely"; import db from "../kysely";
interface User { interface User {
id: number; id: number;
+13
View File
@@ -0,0 +1,13 @@
import type { ColumnType, Generated } from "kysely";
export interface FileDeletionQueueTable {
id: Generated<number>;
path: string;
enqueued_at: ColumnType<Date, Date, never>;
}
declare module "./index" {
interface Database {
file_deletion_queue: FileDeletionQueueTable;
}
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./category";
export * from "./client"; export * from "./client";
export * from "./directory"; export * from "./directory";
export * from "./file"; export * from "./file";
export * from "./fileDeletion";
export * from "./hsk"; export * from "./hsk";
export * from "./media"; export * from "./media";
export * from "./mek"; export * from "./mek";
+33
View File
@@ -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<ErrorCode, number>;
// 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];
}
}
+18
View File
@@ -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 = <Event extends RequestEvent>(
handler: (event: Event) => Promise<Response>,
): ((event: Event) => Promise<Response>) => {
return async (event) => {
try {
return await handler(event);
} catch (e) {
if (e instanceof AppError) {
error(e.httpStatus, e.message);
}
throw e;
}
};
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { error, redirect } from "@sveltejs/kit"; import { error, redirect } from "@sveltejs/kit";
import type { Handle } from "@sveltejs/kit/hooks"; 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 }) => { export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
try { try {
-7
View File
@@ -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);
}
};
+99
View File
@@ -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;
}
};
+62
View File
@@ -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);
};
+76
View File
@@ -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);
};
+10
View File
@@ -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");
}
};
+58
View File
@@ -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);
};
+65 -58
View File
@@ -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 { 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 ( type Range = { start?: number; end?: number };
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; export const getFile = async (userId: number, fileId: number) => {
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 },
) => {
const file = await FileRepo.getFile(userId, fileId); const file = await FileRepo.getFile(userId, fileId);
if (!file) { if (!file) {
error(404, "Invalid file id"); throw new AppError("NOT_FOUND", "File not found");
} }
return createEncContentStream( const categories = await FileRepo.getAllFileCategories(fileId);
file.path, return { ...file, categories };
file.encContentIv ? Buffer.from(file.encContentIv, "base64") : undefined,
range,
);
}; };
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, userId: number,
fileId: 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); const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId);
if (!thumbnail) { 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( const decodeIv = (iv: string | null) => (iv ? Buffer.from(iv, "base64") : undefined);
thumbnail.path,
thumbnail.encContentIv ? Buffer.from(thumbnail.encContentIv, "base64") : undefined, export const getFileStream = async (userId: number, fileId: number, range?: 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);
}; };
+44
View File
@@ -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<void> | 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;
});
};
+10
View File
@@ -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";
+50
View File
@@ -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);
};
+31
View File
@@ -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 };
};
+164 -77
View File
@@ -1,11 +1,18 @@
import { error } from "@sveltejs/kit"; import type { Readable } from "stream";
import { open } from "fs/promises"; import { db, FileRepo, MediaRepo, UploadRepo } from "#lib/server/db/index.js";
import { Readable } from "stream"; import type { Ciphertext } from "#lib/server/db/schema/index.js";
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js"; import { AppError } from "#lib/server/errors.js";
import { UploadRepo } from "#lib/server/db/index.js"; import {
import { safeUnlink } from "#lib/server/modules/filesystem.js"; deleteFile,
hashFile,
reserveFile,
writeChunkAtOffset,
} from "#lib/server/storage/index.js";
import { ensureFreshDekVersion } from "./common";
import { schedulePendingFileDeletions } from "./fileDeletion";
const chunkLocks = new Set<string>(); const UPLOAD_EXPIRES = 24 * 3600 * 1000; // 24 hours
const THUMBNAIL_CHUNKS = 1; // Up to 4 MiB
export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => { export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
chunkIndex -= 1; chunkIndex -= 1;
@@ -13,93 +20,173 @@ export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
}; };
const writeChunkAtOffset = async ( const withReservedFile = async <T>(
path: string, kind: "library" | "thumbnails",
encChunkStream: Readable, userId: number,
chunkIndex: number, callback: (path: string) => Promise<T>,
isLastChunk: boolean,
) => { ) => {
const offset = (chunkIndex - 1) * ENCRYPTED_CHUNK_SIZE; const path = await reserveFile(kind, userId);
const file = await open(path, "r+");
let written = 0;
try { try {
for await (const chunk of encChunkStream) { return await callback(path);
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); } catch (e) {
written += buffer.length; await deleteFile(path).catch(console.error);
if (written > ENCRYPTED_CHUNK_SIZE) { throw e;
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();
} }
}; };
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 ( export const uploadChunk = async (
userId: number, userId: number,
sessionId: string, sessionId: string,
chunkIndex: number, chunkIndex: number,
encChunkStream: Readable, encChunkStream: Readable,
) => { ) => {
const lockKey = `${sessionId}/${chunkIndex}`; const session = await UploadRepo.getUploadSession(sessionId, userId);
if (chunkLocks.has(lockKey)) { if (!session) {
error(409, "Chunk upload already in progress"); throw new AppError("NOT_FOUND", "Invalid upload id");
} else { } else if (chunkIndex > session.totalChunks) {
chunkLocks.add(lockKey); throw new AppError("BAD_REQUEST", "Invalid chunk index");
} else if (isChunkUploaded(session.bitmap, chunkIndex)) {
throw new AppError("CONFLICT", "Chunk already uploaded");
} }
try { // Concurrent uploads of the same chunk may corrupt the file, which will be detected by the
const session = await UploadRepo.getUploadSession(sessionId, userId); // hash verification on completion
if (!session) { const isLastChunk = chunkIndex === session.totalChunks;
error(404, "Invalid upload id"); await writeChunkAtOffset(session.path, encChunkStream, chunkIndex, isLastChunk);
} else if (chunkIndex > session.totalChunks) { await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex);
error(400, "Invalid chunk index"); };
} else if (isChunkUploaded(session.bitmap, chunkIndex)) {
error(409, "Chunk already uploaded"); const getCompletedUploadSession = async <T extends "file" | "thumbnail">(
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<NonNullable<typeof session>, { 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; const { id: fileId } = await FileRepo.registerFile(trx, {
await writeChunkAtOffset(session.path, encChunkStream, chunkIndex, isLastChunk); parentId: lockedSession.parentId,
await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex); userId,
} catch (e) { path: lockedSession.path,
if (e instanceof Error && e.message === "Invalid chunk size") { mekVersion: lockedSession.mekVersion,
error(400, "Invalid request body"); 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 { await MediaRepo.updateFileThumbnail(
chunkLocks.delete(lockKey); trx,
} userId,
session.fileId,
session.dekVersion,
session.path,
null,
);
await UploadRepo.deleteUploadSession(trx, uploadId);
});
schedulePendingFileDeletions();
}; };
export const cleanupExpiredUploadSessions = async () => { export const cleanupExpiredUploadSessions = async () => {
const paths = await UploadRepo.cleanupExpiredUploadSessions(); await UploadRepo.cleanupExpiredUploadSessions();
await Promise.all(paths.map(safeUnlink)); schedulePendingFileDeletions();
}; };
+10
View File
@@ -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;
};
+56
View File
@@ -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();
}
};
+37
View File
@@ -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");
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./chunks";
export * from "./files";
export * from "./streams";
+40
View File
@@ -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,
};
};
@@ -1,11 +1,12 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { z } from "zod"; import { z } from "zod";
import { parseRangeHeader, getContentRangeHeader } from "#lib/modules/http.js"; import { parseRangeHeader, getContentRangeHeader } from "#lib/modules/http.js";
import { authorize } from "#lib/server/modules/auth.js"; import { authorize } from "#lib/server/auth.js";
import { getFileStream, getFileThumbnailStream } from "#lib/server/services/file.js"; import { handleAppErrors } from "#lib/server/http.js";
import type { RequestHandler, RouteParams } from "./$types"; 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 { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
@@ -16,7 +17,9 @@ const downloadHandler = async (locals: App.Locals, params: RouteParams, request:
if (!zodRes.success) error(400, "Invalid path parameters"); if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data; const { id } = zodRes.data;
const getStream = params.thumbnail ? getFileThumbnailStream : getFileStream; const getStream = params.thumbnail
? FileService.getFileThumbnailStream
: FileService.getFileStream;
const { encContentStream, range, isRangeRequest } = await getStream( const { encContentStream, range, isRangeRequest } = await getStream(
userId, userId,
id, id,
@@ -34,11 +37,13 @@ const downloadHandler = async (locals: App.Locals, params: RouteParams, request:
}; };
}; };
export const GET: RequestHandler = async ({ locals, params, request }) => { export const GET: RequestHandler = handleAppErrors(async (event) => {
const { stream, ...init } = await downloadHandler(locals, params, request); const { stream, ...init } = await downloadHandler(event);
return new Response(stream as ReadableStream, init); return new Response(stream as ReadableStream, init);
}; });
export const HEAD: RequestHandler = async ({ locals, params, request }) => { export const HEAD: RequestHandler = handleAppErrors(async (event) => {
return new Response(null, await downloadHandler(locals, params, request)); const { stream, ...init } = await downloadHandler(event);
}; await stream.cancel();
return new Response(null, init);
});
@@ -2,11 +2,12 @@ import { error } from "@sveltejs/kit";
import { Readable } from "stream"; import { Readable } from "stream";
import type { ReadableStream } from "stream/web"; import type { ReadableStream } from "stream/web";
import { z } from "zod"; import { z } from "zod";
import { authorize } from "#lib/server/modules/auth.js"; import { authorize } from "#lib/server/auth.js";
import { uploadChunk } from "#lib/server/services/upload.js"; import { handleAppErrors } from "#lib/server/http.js";
import { UploadService } from "#lib/server/services/index.js";
import type { RequestHandler } from "./$types"; 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 { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
@@ -22,11 +23,11 @@ export const POST: RequestHandler = async ({ locals, params, request }) => {
error(400, "Invalid request body"); error(400, "Invalid request body");
} }
await uploadChunk( await UploadService.uploadChunk(
userId, userId,
sessionId, sessionId,
chunkIndex, chunkIndex,
Readable.fromWeb(request.body as ReadableStream), Readable.fromWeb(request.body as ReadableStream),
); );
return new Response("Chunk uploaded", { headers: { "Content-Type": "text/plain" } }); return new Response("Chunk uploaded", { headers: { "Content-Type": "text/plain" } });
}; });
+48
View File
@@ -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),
});
+41 -7
View File
@@ -1,14 +1,34 @@
import type { RequestEvent } from "@sveltejs/kit"; import type { RequestEvent } from "@sveltejs/kit";
import { initTRPC, TRPCError } from "@trpc/server"; import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson"; 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 const createContext = (event: RequestEvent) => event;
export type Context = Awaited<ReturnType<typeof createContext>>; export type Context = Awaited<ReturnType<typeof createContext>>;
export const t = initTRPC.context<Context>().create({ transformer: superjson }); export const t = initTRPC.context<Context>().create({ transformer: superjson });
export const router = t.router; 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<ErrorCode, TRPCError["code"]>;
// 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 }) => { const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
if (!ctx.locals.session) { if (!ctx.locals.session) {
@@ -17,10 +37,24 @@ const authedProcedure = publicProcedure.use(async ({ ctx, next }) => {
return 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 = { export const roleProcedure = {
any: authedProcedure.use(authorizeMiddleware("any")), any: sessionProcedure("any"),
notClient: authedProcedure.use(authorizeMiddleware("notClient")), notClient: sessionProcedure("notClient"),
anyClient: authedProcedure.use(authorizeClientMiddleware("anyClient")), anyClient: clientSessionProcedure("anyClient"),
pendingClient: authedProcedure.use(authorizeClientMiddleware("pendingClient")), pendingClient: clientSessionProcedure("pendingClient"),
activeClient: authedProcedure.use(authorizeClientMiddleware("activeClient")), activeClient: clientSessionProcedure("activeClient"),
}; };
-36
View File
@@ -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 } });
});
+30 -89
View File
@@ -1,11 +1,8 @@
import { TRPCError } from "@trpc/server";
import argon2 from "argon2";
import { z } from "zod"; import { z } from "zod";
import { ClientRepo, SessionRepo, UserRepo, IntegrityError } from "#lib/server/db/index.js"; import { cookieOptions } from "#lib/server/auth.js";
import env from "#lib/server/loadenv.js"; import { AuthService } from "#lib/server/services/index.js";
import { cookieOptions } from "#lib/server/modules/auth.js"; import { router, publicProcedure, roleProcedure } from "#trpc/init.server.js";
import { generateChallenge, verifySignature, issueSessionId } from "#lib/server/modules/crypto.js"; import { Base64Schema, IdSchema } from "#trpc/schemas.js";
import { router, publicProcedure, roleProcedure } from "../init.server";
const authRouter = router({ const authRouter = router({
login: publicProcedure login: publicProcedure
@@ -16,18 +13,17 @@ const authRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const user = await UserRepo.getUserByEmail(input.email); const { sessionIdSigned } = await AuthService.login(
if (!user || !(await argon2.verify(user.password, input.password))) { input.email,
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid email or password" }); input.password,
} ctx.locals.ip,
ctx.locals.userAgent,
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); );
await SessionRepo.createSession(user.id, sessionId, ctx.locals.ip, ctx.locals.userAgent);
ctx.cookies.set("sessionId", sessionIdSigned, cookieOptions); ctx.cookies.set("sessionId", sessionIdSigned, cookieOptions);
}), }),
logout: roleProcedure["any"].mutation(async ({ ctx }) => { logout: roleProcedure["any"].mutation(async ({ ctx }) => {
await SessionRepo.deleteSession(ctx.session.sessionId); await AuthService.logout(ctx.session.sessionId);
ctx.cookies.delete("sessionId", cookieOptions); ctx.cookies.delete("sessionId", cookieOptions);
}), }),
@@ -39,103 +35,48 @@ const authRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (input.oldPassword === input.newPassword) { await AuthService.changePassword(
throw new TRPCError({ code: "BAD_REQUEST", message: "Same passwords" }); ctx.session.userId,
} else if (input.newPassword.length < 8) { ctx.session.sessionId,
throw new TRPCError({ code: "BAD_REQUEST", message: "Too short password" }); input.oldPassword,
} input.newPassword,
);
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);
}), }),
upgrade: roleProcedure["notClient"] upgrade: roleProcedure["notClient"]
.input( .input(
z.object({ z.object({
encPubKey: z.base64().nonempty(), encPubKey: Base64Schema,
sigPubKey: z.base64().nonempty(), sigPubKey: Base64Schema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const client = await ClientRepo.getClientByPubKeys(input.encPubKey, input.sigPubKey); return await AuthService.requestSessionUpgrade(
const userClient = client ctx.session.userId,
? 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(
ctx.session.sessionId, ctx.session.sessionId,
client.id,
answer.toString("base64"),
ctx.locals.ip, ctx.locals.ip,
new Date(Date.now() + env.challenge.sessionUpgradeExp), input.encPubKey,
input.sigPubKey,
); );
return { id, challenge: challenge.toString("base64") };
}), }),
verifyUpgrade: roleProcedure["notClient"] verifyUpgrade: roleProcedure["notClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
answerSig: z.base64().nonempty(), answerSig: Base64Schema,
force: z.boolean().default(false), force: z.boolean().default(false),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const challenge = await SessionRepo.consumeSessionUpgradeChallenge( await AuthService.verifySessionUpgrade(
input.id, ctx.session.userId,
ctx.session.sessionId, ctx.session.sessionId,
ctx.locals.ip, 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;
}
}), }),
}); });
+45 -146
View File
@@ -1,8 +1,14 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { CategoryIdSchema } from "#lib/schemas/index.js"; import { CategoryService } from "#lib/server/services/index.js";
import { CategoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js"; import { toCategoryDto, toFileDto } from "#trpc/dto.js";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "#trpc/init.server.js";
import {
Base64Schema,
CategoryIdSchema,
EncryptedNameSchema,
IdSchema,
toCiphertext,
} from "#trpc/schemas.js";
const categoryRouter = router({ const categoryRouter = router({
get: roleProcedure["activeClient"] get: roleProcedure["activeClient"]
@@ -13,53 +19,15 @@ const categoryRouter = router({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const category = const { category, subCategories, files } = await CategoryService.getCategory(
input.id !== "root" ctx.session.userId,
? await CategoryRepo.getCategory(ctx.session.userId, input.id) input.id,
: undefined; input.recurse,
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,
]);
return { return {
metadata: category && { metadata: category && toCategoryDto(category),
parent: category.parentId, subCategories: subCategories.map(toCategoryDto),
mekVersion: category.mekVersion, files: files?.map((file) => ({ ...toFileDto(file), isRecursive: file.isRecursive })),
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,
})),
}; };
}), }),
@@ -67,139 +35,70 @@ const categoryRouter = router({
.input( .input(
z.object({ z.object({
parent: CategoryIdSchema, parent: CategoryIdSchema,
mekVersion: z.int().positive(), mekVersion: IdSchema,
dek: z.base64().nonempty(), dek: Base64Schema,
dekVersion: z.date(), dekVersion: z.date(),
name: z.base64().nonempty(), ...EncryptedNameSchema,
nameIv: z.base64().nonempty(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000); await CategoryService.createCategory({
const oneMinuteLater = new Date(Date.now() + 60 * 1000); userId: ctx.session.userId,
if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) { parentId: input.parent,
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" }); mekVersion: input.mekVersion,
} encDek: input.dek,
dekVersion: input.dekVersion,
try { encName: toCiphertext(input.name, input.nameIv),
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;
}
}), }),
rename: roleProcedure["activeClient"] rename: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
dekVersion: z.date(), dekVersion: z.date(),
name: z.base64().nonempty(), ...EncryptedNameSchema,
nameIv: z.base64().nonempty(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await CategoryService.renameCategory(
await CategoryRepo.setCategoryEncName(ctx.session.userId, input.id, input.dekVersion, { ctx.session.userId,
ciphertext: input.name, input.id,
iv: input.nameIv, input.dekVersion,
}); toCiphertext(input.name, 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;
}
}), }),
delete: roleProcedure["activeClient"] delete: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await CategoryService.deleteCategory(ctx.session.userId, input.id);
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;
}
}), }),
addFile: roleProcedure["activeClient"] addFile: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
file: z.int().positive(), file: IdSchema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const [category, file] = await Promise.all([ await CategoryService.addFileToCategory(ctx.session.userId, input.id, input.file);
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;
}
}), }),
removeFile: roleProcedure["activeClient"] removeFile: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
file: z.int().positive(), file: IdSchema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const [category, file] = await Promise.all([ await CategoryService.removeFileFromCategory(ctx.session.userId, input.id, input.file);
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;
}
}), }),
}); });
+16 -73
View File
@@ -1,96 +1,39 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { ClientRepo, IntegrityError } from "#lib/server/db/index.js"; import { ClientService } from "#lib/server/services/index.js";
import env from "#lib/server/loadenv.js"; import { router, roleProcedure } from "#trpc/init.server.js";
import { verifyPubKey, verifySignature, generateChallenge } from "#lib/server/modules/crypto.js"; import { Base64Schema, IdSchema } from "#trpc/schemas.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") };
};
const clientRouter = router({ const clientRouter = router({
register: roleProcedure["notClient"] register: roleProcedure["notClient"]
.input( .input(
z.object({ z.object({
encPubKey: z.base64().nonempty(), encPubKey: Base64Schema,
sigPubKey: z.base64().nonempty(), sigPubKey: Base64Schema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { userId } = ctx.session; return await ClientService.registerClient(
const { encPubKey, sigPubKey } = input; ctx.session.userId,
const client = await ClientRepo.getClientByPubKeys(encPubKey, sigPubKey); ctx.locals.ip,
if (client) { input.encPubKey,
try { input.sigPubKey,
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;
}
}
}), }),
verify: roleProcedure["notClient"] verify: roleProcedure["notClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
answerSig: z.base64().nonempty(), answerSig: Base64Schema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const challenge = await ClientRepo.consumeUserClientChallenge( await ClientService.verifyClient(
input.id,
ctx.session.userId, ctx.session.userId,
ctx.locals.ip, 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);
}), }),
}); });
+42 -112
View File
@@ -1,9 +1,14 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { DirectoryIdSchema } from "#lib/schemas/index.js"; import { DirectoryService } from "#lib/server/services/index.js";
import { DirectoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js"; import { toDirectoryDto, toFileDto } from "#trpc/dto.js";
import { safeUnlink } from "#lib/server/modules/filesystem.js"; import { router, roleProcedure } from "#trpc/init.server.js";
import { router, roleProcedure } from "../init.server"; import {
Base64Schema,
DirectoryIdSchema,
EncryptedNameSchema,
IdSchema,
toCiphertext,
} from "#trpc/schemas.js";
const directoryRouter = router({ const directoryRouter = router({
get: roleProcedure["activeClient"] get: roleProcedure["activeClient"]
@@ -13,51 +18,14 @@ const directoryRouter = router({
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const directory = const { directory, subDirectories, files } = await DirectoryService.getDirectory(
input.id !== "root" ctx.session.userId,
? await DirectoryRepo.getDirectory(ctx.session.userId, input.id) 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),
]);
return { return {
metadata: directory && { metadata: directory && toDirectoryDto(directory),
parent: directory.parentId, subDirectories: subDirectories.map(toDirectoryDto),
mekVersion: directory.mekVersion, files: files.map(toFileDto),
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,
})),
}; };
}), }),
@@ -65,90 +33,52 @@ const directoryRouter = router({
.input( .input(
z.object({ z.object({
parent: DirectoryIdSchema, parent: DirectoryIdSchema,
mekVersion: z.int().positive(), mekVersion: IdSchema,
dek: z.base64().nonempty(), dek: Base64Schema,
dekVersion: z.date(), dekVersion: z.date(),
name: z.base64().nonempty(), ...EncryptedNameSchema,
nameIv: z.base64().nonempty(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000); await DirectoryService.createDirectory({
const oneMinuteLater = new Date(Date.now() + 60 * 1000); userId: ctx.session.userId,
if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) { parentId: input.parent,
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" }); mekVersion: input.mekVersion,
} encDek: input.dek,
dekVersion: input.dekVersion,
try { encName: toCiphertext(input.name, input.nameIv),
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;
}
}), }),
rename: roleProcedure["activeClient"] rename: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
dekVersion: z.date(), dekVersion: z.date(),
name: z.base64().nonempty(), ...EncryptedNameSchema,
nameIv: z.base64().nonempty(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await DirectoryService.renameDirectory(
await DirectoryRepo.setDirectoryEncName(ctx.session.userId, input.id, input.dekVersion, { ctx.session.userId,
ciphertext: input.name, input.id,
iv: input.nameIv, input.dekVersion,
}); toCiphertext(input.name, 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;
}
}), }),
delete: roleProcedure["activeClient"] delete: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { const { deletedFileIds } = await DirectoryService.deleteDirectory(
const files = await DirectoryRepo.unregisterDirectory(ctx.session.userId, input.id); ctx.session.userId,
return { input.id,
deletedFiles: files.map((file) => { );
safeUnlink(file.path); // Intended return { deletedFiles: deletedFileIds };
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;
}
}), }),
}); });
+19 -99
View File
@@ -1,123 +1,43 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { DirectoryRepo, FileRepo, IntegrityError } from "#lib/server/db/index.js"; import { DirectoryService, FileService } from "#lib/server/services/index.js";
import { router, roleProcedure } from "../init.server"; 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({ const favoritesRouter = router({
get: roleProcedure["activeClient"].query(async ({ ctx }) => { get: roleProcedure["activeClient"].query(async ({ ctx }) => {
const [directories, files] = await Promise.all([ const [directories, files] = await Promise.all([
DirectoryRepo.getAllFavoriteDirectories(ctx.session.userId), DirectoryService.getFavoriteDirectories(ctx.session.userId),
FileRepo.getAllFavoriteFiles(ctx.session.userId), FileService.getFavoriteFiles(ctx.session.userId),
]); ]);
return { return {
directories: directories.map((directory) => ({ directories: directories.map(toDirectoryDto),
id: directory.id, files: files.map(toFileDto),
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,
})),
}; };
}), }),
addDirectory: roleProcedure["activeClient"] addDirectory: roleProcedure["activeClient"]
.input( .input(IdInputSchema)
z.object({
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await DirectoryService.setDirectoryFavorite(ctx.session.userId, input.id, true);
await DirectoryRepo.setDirectoryFavorite(ctx.session.userId, input.id, true);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
} else if (e.message === "Directory already favorited") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
}
throw e;
}
}), }),
removeDirectory: roleProcedure["activeClient"] removeDirectory: roleProcedure["activeClient"]
.input( .input(IdInputSchema)
z.object({
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await DirectoryService.setDirectoryFavorite(ctx.session.userId, input.id, false);
await DirectoryRepo.setDirectoryFavorite(ctx.session.userId, input.id, false);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid directory id" });
} else if (e.message === "Directory not favorited") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
}
throw e;
}
}), }),
addFile: roleProcedure["activeClient"] addFile: roleProcedure["activeClient"].input(IdInputSchema).mutation(async ({ ctx, input }) => {
.input( await FileService.setFileFavorite(ctx.session.userId, input.id, true);
z.object({ }),
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => {
try {
await FileRepo.setFileFavorite(ctx.session.userId, input.id, true);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
throw new TRPCError({ code: "NOT_FOUND", message: "Invalid file id" });
} else if (e.message === "File already favorited") {
throw new TRPCError({ code: "BAD_REQUEST", message: e.message });
}
}
throw e;
}
}),
removeFile: roleProcedure["activeClient"] removeFile: roleProcedure["activeClient"]
.input( .input(IdInputSchema)
z.object({
id: z.int().positive(),
}),
)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await FileService.setFileFavorite(ctx.session.userId, input.id, false);
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;
}
}), }),
}); });
+28 -119
View File
@@ -1,97 +1,44 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { FileRepo, MediaRepo, IntegrityError } from "#lib/server/db/index.js"; import { FileService } from "#lib/server/services/index.js";
import { safeUnlink } from "#lib/server/modules/filesystem.js"; import { toFileDto, toFileWithCategoriesDto } from "#trpc/dto.js";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "#trpc/init.server.js";
import { Base64Schema, EncryptedNameSchema, IdSchema, toCiphertext } from "#trpc/schemas.js";
const fileRouter = router({ const fileRouter = router({
get: roleProcedure["activeClient"] get: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const file = await FileRepo.getFile(ctx.session.userId, input.id); return toFileWithCategoriesDto(await FileService.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,
})),
};
}), }),
bulkGet: roleProcedure["activeClient"] bulkGet: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
ids: z.number().positive().array(), ids: IdSchema.array(),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const files = await FileRepo.getFilesWithCategories(ctx.session.userId, input.ids); const files = await FileService.getFiles(ctx.session.userId, input.ids);
return files.map((file) => ({ return files.map(toFileWithCategoriesDto);
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,
})),
}));
}), }),
list: roleProcedure["activeClient"].query(async ({ ctx }) => { list: roleProcedure["activeClient"].query(async ({ ctx }) => {
return await FileRepo.getAllFileIds(ctx.session.userId); return await FileService.getAllFileIds(ctx.session.userId);
}), }),
listByHash: roleProcedure["activeClient"] listByHash: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
hskVersion: z.int().positive(), hskVersion: IdSchema,
contentHmac: z.base64().nonempty(), contentHmac: Base64Schema,
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await FileRepo.getAllFileIdsByContentHmac( return await FileService.getFileIdsByContentHmac(
ctx.session.userId, ctx.session.userId,
input.hskVersion, input.hskVersion,
input.contentHmac, input.contentHmac,
@@ -99,84 +46,46 @@ const fileRouter = router({
}), }),
listWithoutThumbnail: roleProcedure["activeClient"].query(async ({ ctx }) => { listWithoutThumbnail: roleProcedure["activeClient"].query(async ({ ctx }) => {
const files = await FileRepo.getFilesWithoutThumbnail(ctx.session.userId); const files = await FileService.getFilesWithoutThumbnail(ctx.session.userId);
return files.map((file) => ({ return files.map(toFileDto);
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,
}));
}), }),
rename: roleProcedure["activeClient"] rename: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
dekVersion: z.date(), dekVersion: z.date(),
name: z.base64().nonempty(), ...EncryptedNameSchema,
nameIv: z.base64().nonempty(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await FileService.renameFile(
await FileRepo.setFileEncName(ctx.session.userId, input.id, input.dekVersion, { ctx.session.userId,
ciphertext: input.name, input.id,
iv: input.nameIv, input.dekVersion,
}); toCiphertext(input.name, 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;
}
}), }),
delete: roleProcedure["activeClient"] delete: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await FileService.deleteFile(ctx.session.userId, input.id);
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;
}
}), }),
thumbnail: roleProcedure["activeClient"] thumbnail: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
id: z.int().positive(), id: IdSchema,
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const thumbnail = await MediaRepo.getFileThumbnail(ctx.session.userId, input.id); const { updatedAt } = await FileService.getFileThumbnail(ctx.session.userId, input.id);
if (!thumbnail) { return { updatedAt };
throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" });
}
return { updatedAt: thumbnail.updatedAt };
}), }),
}); });
+12 -19
View File
@@ -1,11 +1,11 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { HskRepo, IntegrityError } from "#lib/server/db/index.js"; import { KeyService } from "#lib/server/services/index.js";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "#trpc/init.server.js";
import { Base64Schema, IdSchema } from "#trpc/schemas.js";
const hskRouter = router({ const hskRouter = router({
list: roleProcedure["activeClient"].query(async ({ ctx }) => { 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 }) => ({ return hsks.map(({ version, state, mekVersion, encHsk }) => ({
version, version,
state, state,
@@ -17,24 +17,17 @@ const hskRouter = router({
registerInitial: roleProcedure["activeClient"] registerInitial: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
mekVersion: z.int().positive(), mekVersion: IdSchema,
hsk: z.base64().nonempty(), hsk: Base64Schema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { await KeyService.registerInitialHsk(
await HskRepo.registerInitialHsk( ctx.session.userId,
ctx.session.userId, ctx.session.clientId,
ctx.session.clientId, input.mekVersion,
input.mekVersion, input.hsk,
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;
}
}), }),
}); });
+12 -41
View File
@@ -1,31 +1,11 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { ClientRepo, MekRepo, IntegrityError } from "#lib/server/db/index.js"; import { KeyService } from "#lib/server/services/index.js";
import { verifySignature } from "#lib/server/modules/crypto.js"; import { router, roleProcedure } from "#trpc/init.server.js";
import { router, roleProcedure } from "../init.server"; import { Base64Schema } from "#trpc/schemas.js";
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);
};
const mekRouter = router({ const mekRouter = router({
list: roleProcedure["activeClient"].query(async ({ ctx }) => { list: roleProcedure["activeClient"].query(async ({ ctx }) => {
const clientMeks = await MekRepo.getAllValidClientMeks( const clientMeks = await KeyService.getClientMeks(ctx.session.userId, ctx.session.clientId);
ctx.session.userId,
ctx.session.clientId,
);
return clientMeks.map(({ version, state, encMek, encMekSig }) => ({ return clientMeks.map(({ version, state, encMek, encMekSig }) => ({
version, version,
state, state,
@@ -37,26 +17,17 @@ const mekRouter = router({
registerInitial: roleProcedure["pendingClient"] registerInitial: roleProcedure["pendingClient"]
.input( .input(
z.object({ z.object({
mek: z.base64().nonempty(), mek: Base64Schema,
mekSig: z.base64().nonempty(), mekSig: Base64Schema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { userId, clientId } = ctx.session; await KeyService.registerInitialMek(
const { mek, mekSig } = input; ctx.session.userId,
if (!(await verifyClientEncMekSig(userId, clientId, 1, mek, mekSig))) { ctx.session.clientId,
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid signature" }); input.mek,
} input.mekSig,
);
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;
}
}), }),
}); });
+14 -44
View File
@@ -1,7 +1,8 @@
import { z } from "zod"; import { z } from "zod";
import { DirectoryIdSchema } from "#lib/schemas/index.js"; import { SearchService } from "#lib/server/services/index.js";
import { DirectoryRepo, FileRepo } from "#lib/server/db/index.js"; import { toDirectoryDto, toFileDto } from "#trpc/dto.js";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "#trpc/init.server.js";
import { DirectoryIdSchema, IdSchema } from "#trpc/schemas.js";
const searchRouter = router({ const searchRouter = router({
search: roleProcedure["activeClient"] search: roleProcedure["activeClient"]
@@ -9,51 +10,20 @@ const searchRouter = router({
z.object({ z.object({
ancestor: DirectoryIdSchema.default("root"), ancestor: DirectoryIdSchema.default("root"),
inFavorites: z.boolean().default(false), inFavorites: z.boolean().default(false),
includeCategories: z.number().positive().array().default([]), includeCategories: IdSchema.array().default([]),
excludeCategories: z.number().positive().array().default([]), excludeCategories: IdSchema.array().default([]),
}), }),
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [directories, files] = await Promise.all([ const { directories, files } = await SearchService.search(ctx.session.userId, {
input.includeCategories.length === 0 && input.excludeCategories.length === 0 ancestorId: input.ancestor,
? DirectoryRepo.searchDirectories(ctx.session.userId, { inFavorites: input.inFavorites,
parentId: input.ancestor, includeCategoryIds: input.includeCategories,
inFavorites: input.inFavorites, excludeCategoryIds: input.excludeCategories,
}) });
: [],
FileRepo.searchFiles(ctx.session.userId, {
parentId: input.ancestor,
inFavorites: input.inFavorites,
includeCategoryIds: input.includeCategories,
excludeCategoryIds: input.excludeCategories,
}),
]);
return { return {
directories: directories.map((directory) => ({ directories: directories.map(toDirectoryDto),
id: directory.id, files: files.map(toFileDto),
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,
})),
}; };
}), }),
}); });
+50 -205
View File
@@ -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 mime from "mime";
import { dirname } from "path";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod"; import { z } from "zod";
import { DirectoryIdSchema } from "#lib/schemas/index.js"; import { UploadService } from "#lib/server/services/index.js";
import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "#lib/server/db/index.js"; import { router, roleProcedure } from "#trpc/init.server.js";
import db from "#lib/server/db/kysely.js"; import {
import env from "#lib/server/loadenv.js"; Base64Schema,
import { safeUnlink } from "#lib/server/modules/filesystem.js"; DirectoryIdSchema,
import { router, roleProcedure } from "../init.server"; EncryptedNameSchema,
IdSchema,
const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours toCiphertext,
} from "#trpc/schemas.js";
const sessionLocks = new Set<string>();
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 };
};
const uploadRouter = router({ const uploadRouter = router({
startFileUpload: roleProcedure["activeClient"] startFileUpload: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
chunks: z.int().positive(), chunks: IdSchema,
parent: DirectoryIdSchema, parent: DirectoryIdSchema,
mekVersion: z.int().positive(), mekVersion: IdSchema,
dek: z.base64().nonempty(), dek: Base64Schema,
dekVersion: z.date(), dekVersion: z.date(),
hskVersion: z.int().positive().optional(), hskVersion: IdSchema.optional(),
contentType: z contentType: z
.string() .string()
.trim() .trim()
.nonempty() .nonempty()
.refine((value) => mime.getExtension(value) !== null), .refine((value) => mime.getExtension(value) !== null),
name: z.base64().nonempty(), ...EncryptedNameSchema,
nameIv: z.base64().nonempty(), createdAt: Base64Schema.optional(),
createdAt: z.base64().nonempty().optional(), createdAtIv: Base64Schema.optional(),
createdAtIv: z.base64().nonempty().optional(), lastModifiedAt: Base64Schema,
lastModifiedAt: z.base64().nonempty(), lastModifiedAtIv: Base64Schema,
lastModifiedAtIv: z.base64().nonempty(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000); return await UploadService.startFileUpload({
const oneMinuteLater = new Date(Date.now() + 60 * 1000); userId: ctx.session.userId,
if (input.dekVersion <= oneMinuteAgo || input.dekVersion >= oneMinuteLater) { totalChunks: input.chunks,
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid DEK version" }); parentId: input.parent,
} mekVersion: input.mekVersion,
encDek: input.dek,
const { id, path } = await generateFileUploadSession(ctx.session.userId); dekVersion: input.dekVersion,
hskVersion: input.hskVersion ?? null,
try { contentType: input.contentType,
await UploadRepo.createFileUploadSession({ encName: toCiphertext(input.name, input.nameIv),
id, encCreatedAt:
userId: ctx.session.userId, input.createdAt && input.createdAtIv
path, ? toCiphertext(input.createdAt, input.createdAtIv)
totalChunks: input.chunks, : null,
expiresAt: new Date(Date.now() + UPLOADS_EXPIRES), encLastModifiedAt: toCiphertext(input.lastModifiedAt, input.lastModifiedAtIv),
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;
}
}), }),
completeFileUpload: roleProcedure["activeClient"] completeFileUpload: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
uploadId: z.uuidv4(), uploadId: z.uuidv4(),
contentHmac: z.base64().nonempty().optional(), contentHmac: Base64Schema.optional(),
encContentHash: z.base64().nonempty(), encContentHash: Base64Schema,
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { uploadId } = input; const { fileId } = await UploadService.completeFileUpload(
if (sessionLocks.has(uploadId)) { ctx.session.userId,
throw new TRPCError({ code: "CONFLICT", message: "Completion already in progress" }); input.uploadId,
} else { input.contentHmac ?? null,
sessionLocks.add(uploadId); input.encContentHash,
} );
return { file: fileId };
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);
}
}), }),
startFileThumbnailUpload: roleProcedure["activeClient"] startFileThumbnailUpload: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
file: z.int().positive(), file: IdSchema,
dekVersion: z.date(), dekVersion: z.date(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { id, path } = await generateThumbnailUploadSession(ctx.session.userId); return await UploadService.startFileThumbnailUpload(
ctx.session.userId,
try { input.file,
await UploadRepo.createThumbnailUploadSession({ input.dekVersion,
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;
}
}), }),
completeFileThumbnailUpload: roleProcedure["activeClient"] completeFileThumbnailUpload: roleProcedure["activeClient"]
@@ -210,43 +91,7 @@ const uploadRouter = router({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { uploadId } = input; await UploadService.completeFileThumbnailUpload(ctx.session.userId, input.uploadId);
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);
}
}), }),
}); });
+4 -9
View File
@@ -1,15 +1,10 @@
import { TRPCError } from "@trpc/server"; import { UserService } from "#lib/server/services/index.js";
import { UserRepo } from "#lib/server/db/index.js"; import { router, roleProcedure } from "#trpc/init.server.js";
import { router, roleProcedure } from "../init.server";
const userRouter = router({ const userRouter = router({
get: roleProcedure["any"].query(async ({ ctx }) => { get: roleProcedure["any"].query(async ({ ctx }) => {
const user = await UserRepo.getUser(ctx.session.userId); const { email, nickname } = await UserService.getUser(ctx.session.userId);
if (!user) { return { email, nickname };
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
}
return { email: user.email, nickname: user.nickname };
}), }),
}); });
+13
View File
@@ -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 });
+30
View File
@@ -82,3 +82,33 @@ export const newEntryParams = (userId: number, parentId: number | "root") => ({
dekVersion: new Date(), dekVersion: new Date(),
encName: ciphertext(), 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();
};
+12
View File
@@ -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;
};
+92
View File
@@ -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",
});
});
});
+1 -1
View File
@@ -4,7 +4,7 @@ import { join } from "path";
import { buffer } from "stream/consumers"; import { buffer } from "stream/consumers";
import type { ReadableStream } from "stream/web"; import type { ReadableStream } from "stream/web";
import { describe, expect, it } from "vitest"; 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 createFile = async (content: string) => {
const path = join(await mkdtemp(join(tmpdir(), "arkvault-test-")), "file"); const path = join(await mkdtemp(join(tmpdir(), "arkvault-test-")), "file");
+55
View File
@@ -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" });
});
});
+66
View File
@@ -1,5 +1,8 @@
import { createHash } from "crypto";
import { Readable } from "stream";
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { UploadRepo } from "#lib/server/db/index.js"; 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 { isChunkUploaded } from "#lib/server/services/upload.js";
import { ciphertext, createUser, newEntryParams, resetDatabase } from "../helpers/database"; 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" });
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ import { vi } from "vitest";
const storagePath = await mkdtemp(join(tmpdir(), "arkvault-test-")); 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"); const { inject } = await import("vitest");
return { return {
default: { default: {