mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
서버 코드를 Router, Service, Repository 계층으로 분리하고 오류 처리 및 파일 삭제 로직 개선
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ed732be999
commit
6a6aa1765b
72 files changed
+1928
-1509
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { ClientRepo, SessionRepo, IntegrityError } from "#lib/server/db/index.js";
|
||||
import env from "#lib/server/loadenv.js";
|
||||
import { verifySessionId } from "#lib/server/modules/crypto.js";
|
||||
import env from "#lib/server/config.js";
|
||||
import { verifySessionId } from "#lib/server/crypto.js";
|
||||
import { AppError } from "#lib/server/errors.js";
|
||||
|
||||
export interface Session {
|
||||
sessionId: string;
|
||||
@@ -26,16 +26,6 @@ export class AuthenticationError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class AuthorizationError extends Error {
|
||||
constructor(
|
||||
public status: 403 | 500,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "AuthorizationError";
|
||||
}
|
||||
}
|
||||
|
||||
export const cookieOptions = {
|
||||
path: "/",
|
||||
maxAge: env.session.exp / 1000,
|
||||
@@ -64,56 +54,21 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
|
||||
}
|
||||
};
|
||||
|
||||
export const authorizeInternal = async (
|
||||
locals: App.Locals,
|
||||
requiredPermission: SessionPermission,
|
||||
): Promise<Session> => {
|
||||
if (!locals.session) {
|
||||
throw new AuthorizationError(500, "Unauthenticated");
|
||||
const ensureUserClientState = async (
|
||||
userId: number,
|
||||
clientId: number | undefined,
|
||||
state: "pending" | "active",
|
||||
) => {
|
||||
if (!clientId) {
|
||||
throw new AppError("FORBIDDEN", "Forbidden");
|
||||
}
|
||||
|
||||
const { id: sessionId, userId, clientId } = locals.session;
|
||||
|
||||
switch (requiredPermission) {
|
||||
case "any":
|
||||
break;
|
||||
case "notClient":
|
||||
if (clientId) {
|
||||
throw new AuthorizationError(403, "Forbidden");
|
||||
}
|
||||
break;
|
||||
case "anyClient":
|
||||
if (!clientId) {
|
||||
throw new AuthorizationError(403, "Forbidden");
|
||||
}
|
||||
break;
|
||||
case "pendingClient": {
|
||||
if (!clientId) {
|
||||
throw new AuthorizationError(403, "Forbidden");
|
||||
}
|
||||
const userClient = await ClientRepo.getUserClient(userId, clientId);
|
||||
if (!userClient) {
|
||||
throw new AuthorizationError(500, "Invalid session id");
|
||||
} else if (userClient.state !== "pending") {
|
||||
throw new AuthorizationError(403, "Forbidden");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "activeClient": {
|
||||
if (!clientId) {
|
||||
throw new AuthorizationError(403, "Forbidden");
|
||||
}
|
||||
const userClient = await ClientRepo.getUserClient(userId, clientId);
|
||||
if (!userClient) {
|
||||
throw new AuthorizationError(500, "Invalid session id");
|
||||
} else if (userClient.state !== "active") {
|
||||
throw new AuthorizationError(403, "Forbidden");
|
||||
}
|
||||
break;
|
||||
}
|
||||
const userClient = await ClientRepo.getUserClient(userId, clientId);
|
||||
if (!userClient) {
|
||||
throw new AppError("INTERNAL_SERVER_ERROR", "Invalid session id");
|
||||
} else if (userClient.state !== state) {
|
||||
throw new AppError("FORBIDDEN", "Forbidden");
|
||||
}
|
||||
|
||||
return { sessionId, userId, clientId };
|
||||
};
|
||||
|
||||
export async function authorize(
|
||||
@@ -130,12 +85,32 @@ export async function authorize(
|
||||
locals: App.Locals,
|
||||
requiredPermission: SessionPermission,
|
||||
): Promise<Session> {
|
||||
try {
|
||||
return await authorizeInternal(locals, requiredPermission);
|
||||
} catch (e) {
|
||||
if (e instanceof AuthorizationError) {
|
||||
error(e.status, e.message);
|
||||
}
|
||||
throw e;
|
||||
if (!locals.session) {
|
||||
throw new AppError("UNAUTHORIZED", "Unauthenticated");
|
||||
}
|
||||
|
||||
const { id: sessionId, userId, clientId } = locals.session;
|
||||
|
||||
switch (requiredPermission) {
|
||||
case "any":
|
||||
break;
|
||||
case "notClient":
|
||||
if (clientId) {
|
||||
throw new AppError("FORBIDDEN", "Forbidden");
|
||||
}
|
||||
break;
|
||||
case "anyClient":
|
||||
if (!clientId) {
|
||||
throw new AppError("FORBIDDEN", "Forbidden");
|
||||
}
|
||||
break;
|
||||
case "pendingClient":
|
||||
await ensureUserClientState(userId, clientId, "pending");
|
||||
break;
|
||||
case "activeClient":
|
||||
await ensureUserClientState(userId, clientId, "active");
|
||||
break;
|
||||
}
|
||||
|
||||
return { sessionId, userId, clientId };
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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
@@ -1,34 +1,41 @@
|
||||
type IntegrityErrorMessages =
|
||||
// Category
|
||||
| "Category not found"
|
||||
// Challenge
|
||||
| "Challenge already registered"
|
||||
// Client
|
||||
| "Public key(s) already registered"
|
||||
| "User client already exists"
|
||||
// File
|
||||
| "Directory not found"
|
||||
| "Directory already favorited"
|
||||
| "Directory not favorited"
|
||||
| "File not found"
|
||||
| "File not found in category"
|
||||
| "File already added to category"
|
||||
| "File already favorited"
|
||||
| "File not favorited"
|
||||
| "Invalid DEK version"
|
||||
// HSK
|
||||
| "HSK already registered"
|
||||
| "Inactive HSK version"
|
||||
// MEK
|
||||
| "MEK already registered"
|
||||
| "Inactive MEK version"
|
||||
// Session
|
||||
| "Session not found"
|
||||
| "Session already exists";
|
||||
import { AppError, type ErrorCode } from "#lib/server/errors.js";
|
||||
|
||||
export class IntegrityError extends Error {
|
||||
constructor(public message: IntegrityErrorMessages) {
|
||||
super(message);
|
||||
const errorCodes = {
|
||||
// Category
|
||||
"Category not found": "NOT_FOUND",
|
||||
// Challenge
|
||||
"Challenge already registered": "CONFLICT",
|
||||
// Client
|
||||
"Public key(s) already registered": "CONFLICT",
|
||||
"User client already exists": "CONFLICT",
|
||||
// File
|
||||
"Directory not found": "NOT_FOUND",
|
||||
"Directory already favorited": "BAD_REQUEST",
|
||||
"Directory not favorited": "BAD_REQUEST",
|
||||
"File not found": "NOT_FOUND",
|
||||
"File not found in category": "BAD_REQUEST",
|
||||
"File already added to category": "BAD_REQUEST",
|
||||
"File already favorited": "BAD_REQUEST",
|
||||
"File not favorited": "BAD_REQUEST",
|
||||
"Invalid DEK version": "CONFLICT",
|
||||
// HSK
|
||||
"HSK already registered": "CONFLICT",
|
||||
"Inactive HSK version": "BAD_REQUEST",
|
||||
// MEK
|
||||
"MEK already registered": "CONFLICT",
|
||||
"Inactive MEK version": "BAD_REQUEST",
|
||||
// Session
|
||||
"Session not found": "UNAUTHORIZED",
|
||||
"Session already exists": "CONFLICT",
|
||||
// Upload
|
||||
"Upload session not found": "NOT_FOUND",
|
||||
} as const satisfies Record<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";
|
||||
}
|
||||
}
|
||||
+12
-10
@@ -1,12 +1,14 @@
|
||||
export * as CategoryRepo from "./category";
|
||||
export * as ClientRepo from "./client";
|
||||
export * as DirectoryRepo from "./directory";
|
||||
export * as FileRepo from "./file";
|
||||
export * as HskRepo from "./hsk";
|
||||
export * as MediaRepo from "./media";
|
||||
export * as MekRepo from "./mek";
|
||||
export * as SessionRepo from "./session";
|
||||
export * as UploadRepo from "./upload";
|
||||
export * as UserRepo from "./user";
|
||||
export * as CategoryRepo from "./repositories/category";
|
||||
export * as ClientRepo from "./repositories/client";
|
||||
export * as DirectoryRepo from "./repositories/directory";
|
||||
export * as FileRepo from "./repositories/file";
|
||||
export * as FileDeletionRepo from "./repositories/fileDeletion";
|
||||
export * as HskRepo from "./repositories/hsk";
|
||||
export * as MediaRepo from "./repositories/media";
|
||||
export * as MekRepo from "./repositories/mek";
|
||||
export * as SessionRepo from "./repositories/session";
|
||||
export * as UploadRepo from "./repositories/upload";
|
||||
export * as UserRepo from "./repositories/user";
|
||||
|
||||
export * from "./error";
|
||||
export { default as db } from "./kysely";
|
||||
@@ -1,21 +1,11 @@
|
||||
import { Kysely, PostgresDialect } from "kysely";
|
||||
import { Kysely } from "kysely";
|
||||
import { Migrator } from "kysely/migration";
|
||||
import pg from "pg";
|
||||
import env from "#lib/server/loadenv.js";
|
||||
import env from "#lib/server/config.js";
|
||||
import { createDialect } from "./dialect";
|
||||
import migrations from "./migrations";
|
||||
import type { Database } from "./schema";
|
||||
|
||||
const dialect = new PostgresDialect({
|
||||
pool: new pg.Pool({
|
||||
host: env.database.host,
|
||||
port: env.database.port,
|
||||
user: env.database.user,
|
||||
password: env.database.password,
|
||||
database: env.database.name,
|
||||
}),
|
||||
});
|
||||
|
||||
const db = new Kysely<Database>({ dialect });
|
||||
const db = new Kysely<Database>({ dialect: createDialect(env.database) });
|
||||
|
||||
export const migrateDB = async () => {
|
||||
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();
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
|
||||
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
|
||||
import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload";
|
||||
import * as AddFavorites1768643000 from "./1768643000-AddFavorites";
|
||||
import * as AddFileDeletionQueue1791730000 from "./1791730000-AddFileDeletionQueue";
|
||||
|
||||
export default {
|
||||
"1737357000-Initial": Initial1737357000,
|
||||
@@ -10,4 +11,5 @@ export default {
|
||||
"1738409340-AddThumbnail": AddThumbnail1738409340,
|
||||
"1768062380-AddChunkedUpload": AddChunkedUpload1768062380,
|
||||
"1768643000-AddFavorites": AddFavorites1768643000,
|
||||
"1791730000-AddFileDeletionQueue": AddFileDeletionQueue1791730000,
|
||||
};
|
||||
@@ -1,8 +1,10 @@
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext } from "./schema";
|
||||
import type { Selectable } from "kysely";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import type { CategoryTable, Ciphertext } from "../schema";
|
||||
import { lockActiveMekVersion } from "./mek";
|
||||
|
||||
interface Category {
|
||||
export interface Category {
|
||||
id: number;
|
||||
parentId: CategoryId;
|
||||
userId: number;
|
||||
@@ -14,6 +16,21 @@ interface Category {
|
||||
|
||||
export type NewCategory = Omit<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 (
|
||||
trx: typeof db,
|
||||
userId: number,
|
||||
@@ -36,17 +53,7 @@ export const ensureCategoryExists = async (
|
||||
|
||||
export const registerCategory = async (params: NewCategory) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const mek = await trx
|
||||
.selectFrom("master_encryption_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", params.userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (mek?.version !== params.mekVersion) {
|
||||
throw new IntegrityError("Inactive MEK version");
|
||||
}
|
||||
await lockActiveMekVersion(trx, params.userId, params.mekVersion);
|
||||
|
||||
await ensureCategoryExists(trx, params.userId, params.parentId);
|
||||
|
||||
@@ -81,18 +88,7 @@ export const getAllCategoriesByParent = async (userId: number, parentId: Categor
|
||||
? query.where("parent_id", "is", null)
|
||||
: query.where("parent_id", "=", parentId);
|
||||
const categories = await query.execute();
|
||||
return categories.map(
|
||||
(category) =>
|
||||
({
|
||||
id: category.id,
|
||||
parentId: category.parent_id ?? "root",
|
||||
userId: category.user_id,
|
||||
mekVersion: category.master_encryption_key_version,
|
||||
encDek: category.encrypted_data_encryption_key,
|
||||
dekVersion: category.data_encryption_key_version,
|
||||
encName: category.encrypted_name,
|
||||
}) satisfies Category,
|
||||
);
|
||||
return categories.map(toCategory);
|
||||
};
|
||||
|
||||
export const getCategory = async (userId: number, categoryId: number) => {
|
||||
@@ -103,17 +99,7 @@ export const getCategory = async (userId: number, categoryId: number) => {
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return category
|
||||
? ({
|
||||
id: category.id,
|
||||
parentId: category.parent_id ?? "root",
|
||||
userId: category.user_id,
|
||||
mekVersion: category.master_encryption_key_version,
|
||||
encDek: category.encrypted_data_encryption_key,
|
||||
dekVersion: category.data_encryption_key_version,
|
||||
encName: category.encrypted_name,
|
||||
} satisfies Category)
|
||||
: null;
|
||||
return category ? toCategory(category) : null;
|
||||
};
|
||||
|
||||
export const setCategoryEncName = async (
|
||||
@@ -1,7 +1,7 @@
|
||||
import pg from "pg";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { UserClientState } from "./schema";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import type { UserClientState } from "../schema";
|
||||
|
||||
interface Client {
|
||||
id: number;
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Selectable } from "kysely";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext, DirectoryTable } from "./schema";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import type { Ciphertext, DirectoryTable } from "../schema";
|
||||
import { enqueueFileDeletions } from "./fileDeletion";
|
||||
import { lockActiveMekVersion } from "./mek";
|
||||
|
||||
interface Directory {
|
||||
export interface Directory {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
userId: number;
|
||||
@@ -47,17 +49,7 @@ export const ensureDirectoryExists = async (
|
||||
|
||||
export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const mek = await trx
|
||||
.selectFrom("master_encryption_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", params.userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (mek?.version !== params.mekVersion) {
|
||||
throw new IntegrityError("Inactive MEK version");
|
||||
}
|
||||
await lockActiveMekVersion(trx, params.userId, params.mekVersion);
|
||||
|
||||
await ensureDirectoryExists(trx, params.userId, params.parentId);
|
||||
|
||||
@@ -251,50 +243,59 @@ export const setDirectoryFavorite = async (
|
||||
};
|
||||
|
||||
export const unregisterDirectory = async (userId: number, directoryId: number) => {
|
||||
return await db
|
||||
.transaction()
|
||||
.setIsolationLevel("repeatable read") // TODO: Sufficient?
|
||||
.execute(async (trx) => {
|
||||
const unregisterFiles = async (parentId: number) => {
|
||||
const files = await trx
|
||||
.selectFrom("file")
|
||||
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
|
||||
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
|
||||
.where("file.parent_id", "=", parentId)
|
||||
.where("file.user_id", "=", userId)
|
||||
.forUpdate("file")
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("file")
|
||||
.where("parent_id", "=", parentId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
return files;
|
||||
};
|
||||
const unregisterDirectoryRecursively = async (
|
||||
directoryId: number,
|
||||
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
|
||||
const files = await unregisterFiles(directoryId);
|
||||
const subDirectories = await trx
|
||||
return await db.transaction().execute(async (trx) => {
|
||||
const directories = await trx
|
||||
.withRecursive("directory_tree", (db) =>
|
||||
db
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("parent_id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.execute();
|
||||
const subDirectoryFilePaths = await Promise.all(
|
||||
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
|
||||
);
|
||||
|
||||
const deleteRes = await trx
|
||||
.deleteFrom("directory")
|
||||
.where("id", "=", directoryId)
|
||||
.where("user_id", "=", userId)
|
||||
.executeTakeFirst();
|
||||
if (deleteRes.numDeletedRows === 0n) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
}
|
||||
return files.concat(...subDirectoryFilePaths);
|
||||
};
|
||||
return await unregisterDirectoryRecursively(directoryId);
|
||||
});
|
||||
.unionAll(
|
||||
db
|
||||
.selectFrom("directory as d")
|
||||
.innerJoin("directory_tree as dt", "d.parent_id", "dt.id")
|
||||
.select("d.id"),
|
||||
),
|
||||
)
|
||||
.selectFrom("directory")
|
||||
.select("id")
|
||||
.where("id", "in", (eb) => eb.selectFrom("directory_tree").select("id"))
|
||||
.forUpdate()
|
||||
.execute();
|
||||
if (directories.length === 0) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
}
|
||||
|
||||
const directoryIds = directories.map(({ id }) => id);
|
||||
const files = await trx
|
||||
.selectFrom("file")
|
||||
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
|
||||
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
|
||||
.where("file.parent_id", "in", directoryIds)
|
||||
.forUpdate("file")
|
||||
.execute();
|
||||
const fileIds = files.map(({ id }) => id);
|
||||
|
||||
const uploadSessions = await trx
|
||||
.deleteFrom("upload_session")
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb("parent_id", "in", directoryIds),
|
||||
...(fileIds.length > 0 ? [eb("file_id", "in", fileIds)] : []),
|
||||
]),
|
||||
)
|
||||
.returning("path")
|
||||
.execute();
|
||||
if (fileIds.length > 0) {
|
||||
await trx.deleteFrom("file").where("id", "in", fileIds).execute();
|
||||
}
|
||||
await trx.deleteFrom("directory").where("id", "in", directoryIds).execute();
|
||||
|
||||
await enqueueFileDeletions(trx, [
|
||||
...files.flatMap(({ path, thumbnailPath }) => [path, thumbnailPath]),
|
||||
...uploadSessions.map(({ path }) => path),
|
||||
]);
|
||||
return { deletedFileIds: fileIds };
|
||||
});
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { sql, type Selectable } from "kysely";
|
||||
import { jsonArrayFrom } from "kysely/helpers/postgres";
|
||||
import pg from "pg";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext, FileTable } from "./schema";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import type { Ciphertext, FileTable } from "../schema";
|
||||
import { toCategory } from "./category";
|
||||
import { enqueueFileDeletions } from "./fileDeletion";
|
||||
|
||||
interface File {
|
||||
export interface File {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
userId: number;
|
||||
@@ -24,15 +26,6 @@ interface File {
|
||||
isFavorite: boolean;
|
||||
}
|
||||
|
||||
interface FileCategory {
|
||||
id: number;
|
||||
parentId: CategoryId;
|
||||
mekVersion: number;
|
||||
encDek: string;
|
||||
dekVersion: Date;
|
||||
encName: Ciphertext;
|
||||
}
|
||||
|
||||
const toFile = (row: Selectable<FileTable>): File => ({
|
||||
id: row.id,
|
||||
parentId: row.parent_id ?? "root",
|
||||
@@ -209,17 +202,7 @@ export const getFilesWithCategories = async (userId: number, fileIds: number[])
|
||||
.execute();
|
||||
return files.map((file) => ({
|
||||
...toFile(file),
|
||||
categories: file.categories.map(
|
||||
(category) =>
|
||||
({
|
||||
id: category.id,
|
||||
parentId: category.parent_id ?? "root",
|
||||
mekVersion: category.master_encryption_key_version,
|
||||
encDek: category.encrypted_data_encryption_key,
|
||||
dekVersion: new Date(category.data_encryption_key_version),
|
||||
encName: category.encrypted_name,
|
||||
}) satisfies FileCategory,
|
||||
),
|
||||
categories: file.categories.map(toCategory),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -387,7 +370,7 @@ export const setFileEncName = async (
|
||||
};
|
||||
|
||||
export const unregisterFile = async (userId: number, fileId: number) => {
|
||||
return await db.transaction().execute(async (trx) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const file = await trx
|
||||
.selectFrom("file")
|
||||
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
|
||||
@@ -400,8 +383,17 @@ export const unregisterFile = async (userId: number, fileId: number) => {
|
||||
throw new IntegrityError("File not found");
|
||||
}
|
||||
|
||||
const uploadSessions = await trx
|
||||
.deleteFrom("upload_session")
|
||||
.where("file_id", "=", fileId)
|
||||
.returning("path")
|
||||
.execute();
|
||||
await trx.deleteFrom("file").where("id", "=", fileId).execute();
|
||||
return file;
|
||||
await enqueueFileDeletions(trx, [
|
||||
file.path,
|
||||
file.thumbnailPath,
|
||||
...uploadSessions.map(({ path }) => path),
|
||||
]);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -437,17 +429,7 @@ export const getAllFileCategories = async (fileId: number) => {
|
||||
.selectAll("category")
|
||||
.where("file_id", "=", fileId)
|
||||
.execute();
|
||||
return categories.map(
|
||||
(category) =>
|
||||
({
|
||||
id: category.id,
|
||||
parentId: category.parent_id ?? "root",
|
||||
mekVersion: category.master_encryption_key_version,
|
||||
encDek: category.encrypted_data_encryption_key,
|
||||
dekVersion: category.data_encryption_key_version,
|
||||
encName: category.encrypted_name,
|
||||
}) satisfies FileCategory,
|
||||
);
|
||||
return categories.map(toCategory);
|
||||
};
|
||||
|
||||
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
|
||||
@@ -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 { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { HskState } from "./schema";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import type { HskState } from "../schema";
|
||||
|
||||
interface Hsk {
|
||||
userId: number;
|
||||
@@ -11,6 +11,20 @@ interface Hsk {
|
||||
encHsk: string;
|
||||
}
|
||||
|
||||
export const lockActiveHskVersion = async (trx: typeof db, userId: number, version: number) => {
|
||||
const hsk = await trx
|
||||
.selectFrom("hmac_secret_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (hsk?.version !== version) {
|
||||
throw new IntegrityError("Inactive HSK version");
|
||||
}
|
||||
};
|
||||
|
||||
export const registerInitialHsk = async (
|
||||
userId: number,
|
||||
createdBy: number,
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NotNull } from "kysely";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import { enqueueFileDeletions } from "./fileDeletion";
|
||||
|
||||
interface Thumbnail {
|
||||
id: number;
|
||||
@@ -60,7 +61,7 @@ export const updateFileThumbnail = async (
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
return thumbnail?.oldPath ?? null;
|
||||
await enqueueFileDeletions(trx, [thumbnail?.oldPath]);
|
||||
};
|
||||
|
||||
export const getFileThumbnail = async (userId: number, fileId: number) => {
|
||||
@@ -1,7 +1,7 @@
|
||||
import pg from "pg";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import type { MekState } from "./schema";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import type { MekState } from "../schema";
|
||||
|
||||
interface Mek {
|
||||
userId: number;
|
||||
@@ -15,6 +15,20 @@ interface ClientMekWithDetails extends Mek {
|
||||
encMekSig: string;
|
||||
}
|
||||
|
||||
export const lockActiveMekVersion = async (trx: typeof db, userId: number, version: number) => {
|
||||
const mek = await trx
|
||||
.selectFrom("master_encryption_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (mek?.version !== version) {
|
||||
throw new IntegrityError("Inactive MEK version");
|
||||
}
|
||||
};
|
||||
|
||||
export const registerInitialMek = async (
|
||||
userId: number,
|
||||
createdBy: number,
|
||||
@@ -1,7 +1,7 @@
|
||||
import pg from "pg";
|
||||
import env from "#lib/server/loadenv.js";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
import env from "#lib/server/config.js";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
|
||||
export const createSession = async (
|
||||
userId: number,
|
||||
@@ -1,8 +1,11 @@
|
||||
import { sql } from "kysely";
|
||||
import { IntegrityError } from "./error";
|
||||
import { sql, type Selectable } from "kysely";
|
||||
import { IntegrityError } from "../error";
|
||||
import db from "../kysely";
|
||||
import type { Ciphertext, UploadSessionTable } from "../schema";
|
||||
import { ensureDirectoryExists } from "./directory";
|
||||
import db from "./kysely";
|
||||
import type { Ciphertext } from "./schema";
|
||||
import { enqueueFileDeletions } from "./fileDeletion";
|
||||
import { lockActiveHskVersion } from "./hsk";
|
||||
import { lockActiveMekVersion } from "./mek";
|
||||
|
||||
interface BaseUploadSession {
|
||||
id: string;
|
||||
@@ -37,30 +40,10 @@ export const createFileUploadSession = async (
|
||||
params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
|
||||
) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const mek = await trx
|
||||
.selectFrom("master_encryption_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", params.userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (mek?.version !== params.mekVersion) {
|
||||
throw new IntegrityError("Inactive MEK version");
|
||||
}
|
||||
await lockActiveMekVersion(trx, params.userId, params.mekVersion);
|
||||
|
||||
if (params.hskVersion) {
|
||||
const hsk = await trx
|
||||
.selectFrom("hmac_secret_key")
|
||||
.select("version")
|
||||
.where("user_id", "=", params.userId)
|
||||
.where("state", "=", "active")
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (hsk?.version !== params.hskVersion) {
|
||||
throw new IntegrityError("Inactive HSK version");
|
||||
}
|
||||
await lockActiveHskVersion(trx, params.userId, params.hskVersion);
|
||||
}
|
||||
|
||||
await ensureDirectoryExists(trx, params.userId, params.parentId);
|
||||
@@ -124,18 +107,8 @@ export const createThumbnailUploadSession = async (
|
||||
});
|
||||
};
|
||||
|
||||
export const getUploadSession = async (sessionId: string, userId: number) => {
|
||||
const session = await db
|
||||
.selectFrom("upload_session")
|
||||
.selectAll()
|
||||
.where("id", "=", sessionId)
|
||||
.where("user_id", "=", userId)
|
||||
.where("expires_at", ">", new Date())
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
if (!session) {
|
||||
return null;
|
||||
} else if (session.type === "file") {
|
||||
const toUploadSession = (session: Selectable<UploadSessionTable>) => {
|
||||
if (session.type === "file") {
|
||||
return {
|
||||
type: "file",
|
||||
id: session.id,
|
||||
@@ -171,6 +144,35 @@ export const getUploadSession = async (sessionId: string, userId: number) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getUploadSession = async (sessionId: string, userId: number) => {
|
||||
const session = await db
|
||||
.selectFrom("upload_session")
|
||||
.selectAll()
|
||||
.where("id", "=", sessionId)
|
||||
.where("user_id", "=", userId)
|
||||
.where("expires_at", ">", new Date())
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return session ? toUploadSession(session) : null;
|
||||
};
|
||||
|
||||
// Locks the session until the transaction ends so that it can be completed only once
|
||||
export const lockUploadSession = async (trx: typeof db, sessionId: string, userId: number) => {
|
||||
const session = await trx
|
||||
.selectFrom("upload_session")
|
||||
.selectAll()
|
||||
.where("id", "=", sessionId)
|
||||
.where("user_id", "=", userId)
|
||||
.where("expires_at", ">", new Date())
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!session) {
|
||||
throw new IntegrityError("Upload session not found");
|
||||
}
|
||||
return toUploadSession(session);
|
||||
};
|
||||
|
||||
export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
|
||||
await db
|
||||
.updateTable("upload_session")
|
||||
@@ -185,11 +187,30 @@ export const deleteUploadSession = async (trx: typeof db, sessionId: string) =>
|
||||
await trx.deleteFrom("upload_session").where("id", "=", sessionId).execute();
|
||||
};
|
||||
|
||||
export const discardUploadSession = async (sessionId: string) => {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const sessions = await trx
|
||||
.deleteFrom("upload_session")
|
||||
.where("id", "=", sessionId)
|
||||
.returning("path")
|
||||
.execute();
|
||||
await enqueueFileDeletions(
|
||||
trx,
|
||||
sessions.map(({ path }) => path),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const cleanupExpiredUploadSessions = async () => {
|
||||
const sessions = await db
|
||||
.deleteFrom("upload_session")
|
||||
.where("expires_at", "<=", new Date())
|
||||
.returning("path")
|
||||
.execute();
|
||||
return sessions.map(({ path }) => path);
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const sessions = await trx
|
||||
.deleteFrom("upload_session")
|
||||
.where("expires_at", "<=", new Date())
|
||||
.returning("path")
|
||||
.execute();
|
||||
await enqueueFileDeletions(
|
||||
trx,
|
||||
sessions.map(({ path }) => path),
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import db from "./kysely";
|
||||
import db from "../kysely";
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export * from "./category";
|
||||
export * from "./client";
|
||||
export * from "./directory";
|
||||
export * from "./file";
|
||||
export * from "./fileDeletion";
|
||||
export * from "./hsk";
|
||||
export * from "./media";
|
||||
export * from "./mek";
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
import { error, redirect } from "@sveltejs/kit";
|
||||
import type { Handle } from "@sveltejs/kit/hooks";
|
||||
import { cookieOptions, authenticate, AuthenticationError } from "#lib/server/modules/auth.js";
|
||||
import { cookieOptions, authenticate, AuthenticationError } from "#lib/server/auth.js";
|
||||
|
||||
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
|
||||
try {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -1,75 +1,82 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { createReadStream } from "fs";
|
||||
import { stat } from "fs/promises";
|
||||
import { Readable } from "stream";
|
||||
import { FileRepo, MediaRepo } from "#lib/server/db/index.js";
|
||||
import type { Ciphertext } from "#lib/server/db/schema/index.js";
|
||||
import { AppError } from "#lib/server/errors.js";
|
||||
import { createEncContentStream } from "#lib/server/storage/index.js";
|
||||
import { schedulePendingFileDeletions } from "./fileDeletion";
|
||||
|
||||
export const createEncContentStream = async (
|
||||
path: string,
|
||||
iv?: Buffer,
|
||||
range?: { start?: number; end?: number },
|
||||
) => {
|
||||
const { size: fileSize } = await stat(path);
|
||||
const ivSize = iv?.byteLength ?? 0;
|
||||
const totalSize = fileSize + ivSize;
|
||||
type Range = { start?: number; end?: number };
|
||||
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? totalSize - 1;
|
||||
if (start > end || start < 0 || end >= totalSize) {
|
||||
error(416, "Invalid range");
|
||||
}
|
||||
|
||||
return {
|
||||
encContentStream: Readable.toWeb(
|
||||
Readable.from(
|
||||
(async function* () {
|
||||
if (start < ivSize) {
|
||||
yield iv!.subarray(start, Math.min(end + 1, ivSize));
|
||||
}
|
||||
if (end >= ivSize) {
|
||||
yield* createReadStream(path, {
|
||||
start: Math.max(0, start - ivSize),
|
||||
end: end - ivSize,
|
||||
});
|
||||
}
|
||||
})(),
|
||||
),
|
||||
),
|
||||
range: { start, end, total: totalSize },
|
||||
isRangeRequest: !!range,
|
||||
};
|
||||
};
|
||||
|
||||
export const getFileStream = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
range?: { start?: number; end?: number },
|
||||
) => {
|
||||
export const getFile = async (userId: number, fileId: number) => {
|
||||
const file = await FileRepo.getFile(userId, fileId);
|
||||
if (!file) {
|
||||
error(404, "Invalid file id");
|
||||
throw new AppError("NOT_FOUND", "File not found");
|
||||
}
|
||||
|
||||
return createEncContentStream(
|
||||
file.path,
|
||||
file.encContentIv ? Buffer.from(file.encContentIv, "base64") : undefined,
|
||||
range,
|
||||
);
|
||||
const categories = await FileRepo.getAllFileCategories(fileId);
|
||||
return { ...file, categories };
|
||||
};
|
||||
|
||||
export const getFileThumbnailStream = async (
|
||||
export const getFiles = async (userId: number, fileIds: number[]) => {
|
||||
return await FileRepo.getFilesWithCategories(userId, fileIds);
|
||||
};
|
||||
|
||||
export const getAllFileIds = async (userId: number) => {
|
||||
return await FileRepo.getAllFileIds(userId);
|
||||
};
|
||||
|
||||
export const getFileIdsByContentHmac = async (
|
||||
userId: number,
|
||||
hskVersion: number,
|
||||
contentHmac: string,
|
||||
) => {
|
||||
return await FileRepo.getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
|
||||
};
|
||||
|
||||
export const getFilesWithoutThumbnail = async (userId: number) => {
|
||||
return await FileRepo.getFilesWithoutThumbnail(userId);
|
||||
};
|
||||
|
||||
export const renameFile = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
range?: { start?: number; end?: number },
|
||||
dekVersion: Date,
|
||||
encName: Ciphertext,
|
||||
) => {
|
||||
await FileRepo.setFileEncName(userId, fileId, dekVersion, encName);
|
||||
};
|
||||
|
||||
export const deleteFile = async (userId: number, fileId: number) => {
|
||||
await FileRepo.unregisterFile(userId, fileId);
|
||||
schedulePendingFileDeletions();
|
||||
};
|
||||
|
||||
export const getFavoriteFiles = async (userId: number) => {
|
||||
return await FileRepo.getAllFavoriteFiles(userId);
|
||||
};
|
||||
|
||||
export const setFileFavorite = async (userId: number, fileId: number, isFavorite: boolean) => {
|
||||
await FileRepo.setFileFavorite(userId, fileId, isFavorite);
|
||||
};
|
||||
|
||||
export const getFileThumbnail = async (userId: number, fileId: number) => {
|
||||
const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId);
|
||||
if (!thumbnail) {
|
||||
error(404, "File or its thumbnail not found");
|
||||
throw new AppError("NOT_FOUND", "File or its thumbnail not found");
|
||||
}
|
||||
return thumbnail;
|
||||
};
|
||||
|
||||
return createEncContentStream(
|
||||
thumbnail.path,
|
||||
thumbnail.encContentIv ? Buffer.from(thumbnail.encContentIv, "base64") : undefined,
|
||||
range,
|
||||
);
|
||||
const decodeIv = (iv: string | null) => (iv ? Buffer.from(iv, "base64") : undefined);
|
||||
|
||||
export const getFileStream = async (userId: number, fileId: number, range?: Range) => {
|
||||
const file = await FileRepo.getFile(userId, fileId);
|
||||
if (!file) {
|
||||
throw new AppError("NOT_FOUND", "File not found");
|
||||
}
|
||||
return await createEncContentStream(file.path, decodeIv(file.encContentIv), range);
|
||||
};
|
||||
|
||||
export const getFileThumbnailStream = async (userId: number, fileId: number, range?: Range) => {
|
||||
const thumbnail = await getFileThumbnail(userId, fileId);
|
||||
return await createEncContentStream(thumbnail.path, decodeIv(thumbnail.encContentIv), range);
|
||||
};
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -1,11 +1,18 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { open } from "fs/promises";
|
||||
import { Readable } from "stream";
|
||||
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js";
|
||||
import { UploadRepo } from "#lib/server/db/index.js";
|
||||
import { safeUnlink } from "#lib/server/modules/filesystem.js";
|
||||
import type { Readable } from "stream";
|
||||
import { db, FileRepo, MediaRepo, UploadRepo } from "#lib/server/db/index.js";
|
||||
import type { Ciphertext } from "#lib/server/db/schema/index.js";
|
||||
import { AppError } from "#lib/server/errors.js";
|
||||
import {
|
||||
deleteFile,
|
||||
hashFile,
|
||||
reserveFile,
|
||||
writeChunkAtOffset,
|
||||
} from "#lib/server/storage/index.js";
|
||||
import { ensureFreshDekVersion } from "./common";
|
||||
import { schedulePendingFileDeletions } from "./fileDeletion";
|
||||
|
||||
const chunkLocks = new Set<string>();
|
||||
const UPLOAD_EXPIRES = 24 * 3600 * 1000; // 24 hours
|
||||
const THUMBNAIL_CHUNKS = 1; // Up to 4 MiB
|
||||
|
||||
export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
|
||||
chunkIndex -= 1;
|
||||
@@ -13,93 +20,173 @@ export const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
|
||||
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
|
||||
};
|
||||
|
||||
const writeChunkAtOffset = async (
|
||||
path: string,
|
||||
encChunkStream: Readable,
|
||||
chunkIndex: number,
|
||||
isLastChunk: boolean,
|
||||
const withReservedFile = async <T>(
|
||||
kind: "library" | "thumbnails",
|
||||
userId: number,
|
||||
callback: (path: string) => Promise<T>,
|
||||
) => {
|
||||
const offset = (chunkIndex - 1) * ENCRYPTED_CHUNK_SIZE;
|
||||
const file = await open(path, "r+");
|
||||
let written = 0;
|
||||
|
||||
const path = await reserveFile(kind, userId);
|
||||
try {
|
||||
for await (const chunk of encChunkStream) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
||||
written += buffer.length;
|
||||
if (written > ENCRYPTED_CHUNK_SIZE) {
|
||||
throw new Error("Invalid chunk size");
|
||||
}
|
||||
|
||||
let chunkOffset = 0;
|
||||
while (chunkOffset < buffer.length) {
|
||||
const { bytesWritten } = await file.write(
|
||||
buffer,
|
||||
chunkOffset,
|
||||
buffer.length - chunkOffset,
|
||||
offset + written - buffer.length + chunkOffset,
|
||||
);
|
||||
if (bytesWritten <= 0) {
|
||||
throw new Error("Failed to write chunk");
|
||||
}
|
||||
chunkOffset += bytesWritten;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(!isLastChunk && written !== ENCRYPTED_CHUNK_SIZE) ||
|
||||
(isLastChunk && (written <= ENCRYPTION_OVERHEAD || written > ENCRYPTED_CHUNK_SIZE))
|
||||
) {
|
||||
throw new Error("Invalid chunk size");
|
||||
}
|
||||
|
||||
if (isLastChunk) {
|
||||
await file.truncate(offset + written);
|
||||
}
|
||||
|
||||
return written;
|
||||
} finally {
|
||||
await file.close();
|
||||
return await callback(path);
|
||||
} catch (e) {
|
||||
await deleteFile(path).catch(console.error);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const startFileUpload = async (params: {
|
||||
userId: number;
|
||||
totalChunks: number;
|
||||
parentId: DirectoryId;
|
||||
mekVersion: number;
|
||||
encDek: string;
|
||||
dekVersion: Date;
|
||||
hskVersion: number | null;
|
||||
contentType: string;
|
||||
encName: Ciphertext;
|
||||
encCreatedAt: Ciphertext | null;
|
||||
encLastModifiedAt: Ciphertext;
|
||||
}) => {
|
||||
ensureFreshDekVersion(params.dekVersion);
|
||||
|
||||
return await withReservedFile("library", params.userId, async (path) => {
|
||||
const id = crypto.randomUUID();
|
||||
await UploadRepo.createFileUploadSession({
|
||||
...params,
|
||||
id,
|
||||
path,
|
||||
expiresAt: new Date(Date.now() + UPLOAD_EXPIRES),
|
||||
});
|
||||
return { uploadId: id };
|
||||
});
|
||||
};
|
||||
|
||||
export const startFileThumbnailUpload = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
dekVersion: Date,
|
||||
) => {
|
||||
return await withReservedFile("thumbnails", userId, async (path) => {
|
||||
const id = crypto.randomUUID();
|
||||
await UploadRepo.createThumbnailUploadSession({
|
||||
id,
|
||||
type: "thumbnail",
|
||||
userId,
|
||||
path,
|
||||
totalChunks: THUMBNAIL_CHUNKS,
|
||||
expiresAt: new Date(Date.now() + UPLOAD_EXPIRES),
|
||||
fileId,
|
||||
dekVersion,
|
||||
});
|
||||
return { uploadId: id };
|
||||
});
|
||||
};
|
||||
|
||||
export const uploadChunk = async (
|
||||
userId: number,
|
||||
sessionId: string,
|
||||
chunkIndex: number,
|
||||
encChunkStream: Readable,
|
||||
) => {
|
||||
const lockKey = `${sessionId}/${chunkIndex}`;
|
||||
if (chunkLocks.has(lockKey)) {
|
||||
error(409, "Chunk upload already in progress");
|
||||
} else {
|
||||
chunkLocks.add(lockKey);
|
||||
const session = await UploadRepo.getUploadSession(sessionId, userId);
|
||||
if (!session) {
|
||||
throw new AppError("NOT_FOUND", "Invalid upload id");
|
||||
} else if (chunkIndex > session.totalChunks) {
|
||||
throw new AppError("BAD_REQUEST", "Invalid chunk index");
|
||||
} else if (isChunkUploaded(session.bitmap, chunkIndex)) {
|
||||
throw new AppError("CONFLICT", "Chunk already uploaded");
|
||||
}
|
||||
|
||||
try {
|
||||
const session = await UploadRepo.getUploadSession(sessionId, userId);
|
||||
if (!session) {
|
||||
error(404, "Invalid upload id");
|
||||
} else if (chunkIndex > session.totalChunks) {
|
||||
error(400, "Invalid chunk index");
|
||||
} else if (isChunkUploaded(session.bitmap, chunkIndex)) {
|
||||
error(409, "Chunk already uploaded");
|
||||
// Concurrent uploads of the same chunk may corrupt the file, which will be detected by the
|
||||
// hash verification on completion
|
||||
const isLastChunk = chunkIndex === session.totalChunks;
|
||||
await writeChunkAtOffset(session.path, encChunkStream, chunkIndex, isLastChunk);
|
||||
await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex);
|
||||
};
|
||||
|
||||
const getCompletedUploadSession = async <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;
|
||||
await writeChunkAtOffset(session.path, encChunkStream, chunkIndex, isLastChunk);
|
||||
await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex);
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message === "Invalid chunk size") {
|
||||
error(400, "Invalid request body");
|
||||
const { id: fileId } = await FileRepo.registerFile(trx, {
|
||||
parentId: lockedSession.parentId,
|
||||
userId,
|
||||
path: lockedSession.path,
|
||||
mekVersion: lockedSession.mekVersion,
|
||||
encDek: lockedSession.encDek,
|
||||
dekVersion: lockedSession.dekVersion,
|
||||
hskVersion: lockedSession.hskVersion,
|
||||
contentHmac,
|
||||
contentType: lockedSession.contentType,
|
||||
encContentIv: null,
|
||||
encContentHash: hash,
|
||||
encName: lockedSession.encName,
|
||||
encCreatedAt: lockedSession.encCreatedAt,
|
||||
encLastModifiedAt: lockedSession.encLastModifiedAt,
|
||||
});
|
||||
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||
return { fileId };
|
||||
});
|
||||
};
|
||||
|
||||
export const completeFileThumbnailUpload = async (userId: number, uploadId: string) => {
|
||||
await getCompletedUploadSession(userId, uploadId, "thumbnail");
|
||||
|
||||
await db.transaction().execute(async (trx) => {
|
||||
const session = await UploadRepo.lockUploadSession(trx, uploadId, userId);
|
||||
if (session.type !== "thumbnail") {
|
||||
throw new AppError("NOT_FOUND", "Invalid upload id");
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
chunkLocks.delete(lockKey);
|
||||
}
|
||||
|
||||
await MediaRepo.updateFileThumbnail(
|
||||
trx,
|
||||
userId,
|
||||
session.fileId,
|
||||
session.dekVersion,
|
||||
session.path,
|
||||
null,
|
||||
);
|
||||
await UploadRepo.deleteUploadSession(trx, uploadId);
|
||||
});
|
||||
schedulePendingFileDeletions();
|
||||
};
|
||||
|
||||
export const cleanupExpiredUploadSessions = async () => {
|
||||
const paths = await UploadRepo.cleanupExpiredUploadSessions();
|
||||
await Promise.all(paths.map(safeUnlink));
|
||||
await UploadRepo.cleanupExpiredUploadSessions();
|
||||
schedulePendingFileDeletions();
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./chunks";
|
||||
export * from "./files";
|
||||
export * from "./streams";
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user