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
+11
-11
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Generated
-9
@@ -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
@@ -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);
|
||||||
@@ -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 +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.
@@ -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 =
|
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
@@ -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";
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 "./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";
|
||||||
|
|||||||
@@ -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 { 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 {
|
||||||
|
|||||||
@@ -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 { 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);
|
||||||
};
|
};
|
||||||
@@ -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 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();
|
||||||
};
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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" } });
|
||||||
};
|
});
|
||||||
@@ -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
@@ -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"),
|
||||||
};
|
};
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
@@ -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();
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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: {
|
||||||
|
|||||||
Reference in new issue
Block a user