diff --git a/.env.example b/.env.example index f492443..e3b6365 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,4 @@ SESSION_EXPIRES= USER_CLIENT_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES= LIBRARY_PATH= +THUMBNAILS_PATH= diff --git a/docker-compose.yaml b/docker-compose.yaml index dc7f392..eba1e94 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,7 @@ services: user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0} volumes: - ./data/library:/app/data/library + - ./data/thumbnails:/app/data/thumbnails environment: # ArkVault - DATABASE_HOST=database @@ -17,6 +18,7 @@ services: - USER_CLIENT_CHALLENGE_EXPIRES - SESSION_UPGRADE_CHALLENGE_EXPIRES - LIBRARY_PATH=/app/data/library + - THUMBNAILS_PATH=/app/data/thumbnails # SvelteKit - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - XFF_DEPTH=${TRUST_PROXY:-} diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 20343b2..2affc7d 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -327,7 +327,8 @@ export const getAllFilesByCategory = async ( .where("user_id", "=", userId) .where("file_id", "is not", null) .$narrowType<{ file_id: NotNull }>() - .orderBy(["file_id", "depth"]) + .orderBy("file_id") + .orderBy("depth") .execute(); return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 })); }; diff --git a/src/lib/server/db/media.ts b/src/lib/server/db/media.ts new file mode 100644 index 0000000..1f1e065 --- /dev/null +++ b/src/lib/server/db/media.ts @@ -0,0 +1,86 @@ +import type { NotNull } from "kysely"; +import { IntegrityError } from "./error"; +import db from "./kysely"; + +interface Thumbnail { + id: number; + path: string; + createdAt: Date; + encContentIv: string; +} + +interface FileThumbnail extends Thumbnail { + fileId: number; +} + +export const updateFileThumbnail = async ( + userId: number, + fileId: number, + dekVersion: Date, + path: string, + encContentIv: string, +) => { + return await db.transaction().execute(async (trx) => { + const file = await trx + .selectFrom("file") + .select("data_encryption_key_version") + .where("id", "=", fileId) + .where("user_id", "=", userId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + if (!file) { + throw new IntegrityError("File not found"); + } else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) { + throw new IntegrityError("Invalid DEK version"); + } + + const thumbnail = await trx + .selectFrom("thumbnail") + .select("path as old_path") + .where("file_id", "=", fileId) + .limit(1) + .forUpdate() + .executeTakeFirst(); + const now = new Date(); + + await trx + .insertInto("thumbnail") + .values({ + file_id: fileId, + path, + created_at: now, + encrypted_content_iv: encContentIv, + }) + .onConflict((oc) => + oc.column("file_id").doUpdateSet({ + path, + created_at: now, + encrypted_content_iv: encContentIv, + }), + ) + .execute(); + return thumbnail?.old_path; + }); +}; + +export const getFileThumbnail = async (userId: number, fileId: number) => { + const thumbnail = await db + .selectFrom("thumbnail") + .innerJoin("file", "thumbnail.file_id", "file.id") + .selectAll("thumbnail") + .where("file.id", "=", fileId) + .where("file.user_id", "=", userId) + .$narrowType<{ file_id: NotNull }>() + .limit(1) + .executeTakeFirst(); + return thumbnail + ? ({ + id: thumbnail.id, + fileId: thumbnail.file_id, + path: thumbnail.path, + encContentIv: thumbnail.encrypted_content_iv, + createdAt: thumbnail.created_at, + } satisfies FileThumbnail) + : null; +}; diff --git a/src/lib/server/db/schema/media.ts b/src/lib/server/db/schema/media.ts index 9eeccf7..ad593b4 100644 --- a/src/lib/server/db/schema/media.ts +++ b/src/lib/server/db/schema/media.ts @@ -1,4 +1,4 @@ -import type { ColumnType, Generated } from "kysely"; +import type { Generated } from "kysely"; interface ThumbnailTable { id: Generated; @@ -6,7 +6,7 @@ interface ThumbnailTable { file_id: number | null; category_id: number | null; path: string; - created_at: ColumnType; + created_at: Date; encrypted_content_iv: string; // Base64 } diff --git a/src/lib/server/loadenv.ts b/src/lib/server/loadenv.ts index d6f4675..3a805d8 100644 --- a/src/lib/server/loadenv.ts +++ b/src/lib/server/loadenv.ts @@ -25,4 +25,5 @@ export default { sessionUpgradeExp: ms(env.SESSION_UPGRADE_CHALLENGE_EXPIRES || "5m"), }, libraryPath: env.LIBRARY_PATH || "library", + thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails", }; diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index b6aa648..4cf140f 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -30,6 +30,17 @@ export const fileRenameRequest = z.object({ }); export type FileRenameRequest = z.infer; +export const fileThumbnailInfoResponse = z.object({ + encContentIv: z.string().base64().nonempty(), +}); +export type FileThumbnailInfoResponse = z.infer; + +export const fileThumbnailUploadRequest = z.object({ + dekVersion: z.string().datetime(), + encContentIv: z.string().base64().nonempty(), +}); +export type FileThumbnailUploadRequest = z.infer; + export const duplicateFileScanRequest = z.object({ hskVersion: z.number().int().positive(), contentHmac: z.string().base64().nonempty(), diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index d0b35ef..83e5750 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -16,6 +16,7 @@ import { getAllFileCategories, type NewFile, } from "$lib/server/db/file"; +import { getFileThumbnail, updateFileThumbnail } from "$lib/server/db/media"; import type { Ciphertext } from "$lib/server/db/schema"; import env from "$lib/server/loadenv"; @@ -85,6 +86,59 @@ export const renameFile = async ( } }; +export const getFileThumbnailInformation = async (userId: number, fileId: number) => { + const thumbnail = await getFileThumbnail(userId, fileId); + if (!thumbnail) { + error(404, "File or its thumbnail not found"); + } + + return { encContentIv: thumbnail.encContentIv }; +}; + +export const getFileThumbnailStream = async (userId: number, fileId: number) => { + const thumbnail = await getFileThumbnail(userId, fileId); + if (!thumbnail) { + error(404, "File or its thumbnail not found"); + } + + const { size } = await stat(thumbnail.path); + return { + encContentStream: Readable.toWeb(createReadStream(thumbnail.path)), + encContentSize: size, + }; +}; + +export const uploadFileThumbnail = async ( + userId: number, + fileId: number, + dekVersion: Date, + encContentIv: string, + encContentStream: Readable, +) => { + const path = `${env.thumbnailsPath}/${userId}/${uuidv4()}`; + await mkdir(dirname(path), { recursive: true }); + + try { + await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 })); + + const oldPath = await updateFileThumbnail(userId, fileId, dekVersion, path, encContentIv); + if (oldPath) { + safeUnlink(oldPath); // Intended + } + } catch (e) { + await safeUnlink(path); + + if (e instanceof IntegrityError) { + if (e.message === "File not found") { + error(404, "File not found"); + } else if (e.message === "Invalid DEK version") { + error(400, "Mismatched DEK version"); + } + } + throw e; + } +}; + export const scanDuplicateFiles = async ( userId: number, hskVersion: number, diff --git a/src/routes/api/file/[id]/thumbnail/+server.ts b/src/routes/api/file/[id]/thumbnail/+server.ts new file mode 100644 index 0000000..ab9d48c --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/+server.ts @@ -0,0 +1,23 @@ +import { error, json } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { fileThumbnailInfoResponse, type FileThumbnailInfoResponse } from "$lib/server/schemas"; +import { getFileThumbnailInformation } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ locals, params }) => { + const { userId } = await authorize(locals, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { encContentIv } = await getFileThumbnailInformation(userId, id); + return json( + fileThumbnailInfoResponse.parse({ encContentIv } satisfies FileThumbnailInfoResponse), + ); +}; diff --git a/src/routes/api/file/[id]/thumbnail/download/+server.ts b/src/routes/api/file/[id]/thumbnail/download/+server.ts new file mode 100644 index 0000000..addd800 --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/download/+server.ts @@ -0,0 +1,25 @@ +import { error } from "@sveltejs/kit"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { getFileThumbnailStream } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ locals, params }) => { + const { userId } = await authorize(locals, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const { encContentStream, encContentSize } = await getFileThumbnailStream(userId, id); + return new Response(encContentStream as ReadableStream, { + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": encContentSize.toString(), + }, + }); +}; diff --git a/src/routes/api/file/[id]/thumbnail/upload/+server.ts b/src/routes/api/file/[id]/thumbnail/upload/+server.ts new file mode 100644 index 0000000..52b99a8 --- /dev/null +++ b/src/routes/api/file/[id]/thumbnail/upload/+server.ts @@ -0,0 +1,72 @@ +import Busboy from "@fastify/busboy"; +import { error, text } from "@sveltejs/kit"; +import { Readable, Writable } from "stream"; +import { z } from "zod"; +import { authorize } from "$lib/server/modules/auth"; +import { fileThumbnailUploadRequest, type FileThumbnailUploadRequest } from "$lib/server/schemas"; +import { uploadFileThumbnail } from "$lib/server/services/file"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ locals, params, request }) => { + const { userId } = await authorize(locals, "activeClient"); + + const zodRes = z + .object({ + id: z.coerce.number().int().positive(), + }) + .safeParse(params); + if (!zodRes.success) error(400, "Invalid path parameters"); + const { id } = zodRes.data; + + const contentType = request.headers.get("Content-Type"); + if (!contentType?.startsWith("multipart/form-data") || !request.body) { + error(400, "Invalid request body"); + } + + return new Promise((resolve, reject) => { + const bb = Busboy({ headers: { "content-type": contentType } }); + const handler = + (f: (...args: T) => Promise) => + (...args: T) => { + f(...args).catch(reject); + }; + + let metadata: FileThumbnailUploadRequest | null = null; + let content: Readable | null = null; + bb.on( + "field", + handler(async (fieldname, val) => { + if (fieldname === "metadata") { + // Ignore subsequent metadata fields + if (!metadata) { + metadata = fileThumbnailUploadRequest.parse(val); + } + } else { + error(400, "Invalid request body"); + } + }), + ); + bb.on( + "file", + handler(async (fieldname, file) => { + if (fieldname !== "content") error(400, "Invalid request body"); + if (!metadata || content) error(400, "Invalid request body"); + content = file; + + await uploadFileThumbnail( + userId, + id, + new Date(metadata.dekVersion), + metadata.encContentIv, + content, + ); + resolve(text("Thumbnail uploaded", { headers: { "Content-Type": "text/plain" } })); + }), + ); + bb.on("error", (e) => { + content?.emit("error", e) ?? reject(e); + }); + + request.body!.pipeTo(Writable.toWeb(bb)).catch(() => {}); // busboy will handle the error + }); +};