1 Commits

Author SHA1 Message Date
static
b9e6f17b0c IV를 암호화된 파일 및 썸네일 앞에 합쳐서 전송하도록 변경 2026-01-11 00:29:59 +09:00
13 changed files with 161 additions and 76 deletions

View File

@@ -89,11 +89,15 @@ export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
return { ciphertext, iv: encodeToBase64(iv.buffer) }; return { ciphertext, iv: encodeToBase64(iv.buffer) };
}; };
export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => { export const decryptData = async (
ciphertext: BufferSource,
iv: string | BufferSource,
dataKey: CryptoKey,
) => {
return await window.crypto.subtle.decrypt( return await window.crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
iv: decodeFromBase64(iv), iv: typeof iv === "string" ? decodeFromBase64(iv) : iv,
} satisfies AesGcmParams, } satisfies AesGcmParams,
dataKey, dataKey,
ciphertext, ciphertext,

View File

@@ -62,15 +62,14 @@ const requestFileDownload = limitFunction(
); );
const decryptFile = limitFunction( const decryptFile = limitFunction(
async ( async (state: FileDownloadState, fileEncrypted: ArrayBuffer, dataKey: CryptoKey) => {
state: FileDownloadState,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
state.status = "decrypting"; state.status = "decrypting";
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey); const fileBuffer = await decryptData(
fileEncrypted.slice(12),
fileEncrypted.slice(0, 12),
dataKey,
);
state.status = "decrypted"; state.status = "decrypted";
state.result = fileBuffer; state.result = fileBuffer;
@@ -79,7 +78,7 @@ const decryptFile = limitFunction(
{ concurrency: 4 }, { concurrency: 4 },
); );
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => { export const downloadFile = async (id: number, dataKey: CryptoKey) => {
downloadingFiles.push({ downloadingFiles.push({
id, id,
status: "download-pending", status: "download-pending",
@@ -87,7 +86,7 @@ export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey:
const state = downloadingFiles.at(-1)!; const state = downloadingFiles.at(-1)!;
try { try {
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey); return await decryptFile(state, await requestFileDownload(state, id), dataKey);
} catch (e) { } catch (e) {
state.status = "error"; state.status = "error";
throw e; throw e;

View File

@@ -5,7 +5,6 @@ import { decryptData } from "$lib/modules/crypto";
import type { SummarizedFileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs"; import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail"; import { getThumbnailUrl } from "$lib/modules/thumbnail";
import { isTRPCClientError, trpc } from "$trpc/client";
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 }); const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
const loadingThumbnails = new Map<number, Writable<string | undefined>>(); const loadingThumbnails = new Map<number, Writable<string | undefined>>();
@@ -18,25 +17,18 @@ const fetchFromOpfs = async (fileId: number) => {
}; };
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => { const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
try { const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([ if (!res.ok) return null;
fetch(`/api/file/${fileId}/thumbnail/download`),
trpc().file.thumbnail.query({ id: fileId }), const thumbnailEncrypted = await res.arrayBuffer();
]);
const thumbnailBuffer = await decryptData( const thumbnailBuffer = await decryptData(
await thumbnailEncrypted.arrayBuffer(), thumbnailEncrypted.slice(12),
thumbnailEncryptedIv, thumbnailEncrypted.slice(0, 12),
dataKey, dataKey,
); );
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer); void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer); return getThumbnailUrl(thumbnailBuffer);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
return null;
}
throw e;
}
}; };
export const getFileThumbnail = (file: SummarizedFileInfo) => { export const getFileThumbnail = (file: SummarizedFileInfo) => {

View File

@@ -50,7 +50,6 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
parentId: file.parent, parentId: file.parent,
dataKey: metadata.dataKey, dataKey: metadata.dataKey,
contentType: file.contentType, contentType: file.contentType,
contentIv: file.contentIv,
name: metadata.name, name: metadata.name,
createdAt: metadata.createdAt, createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt, lastModifiedAt: metadata.lastModifiedAt,
@@ -118,7 +117,6 @@ const cache = new FilesystemCache<number, MaybeFileInfo>({
exists: true as const, exists: true as const,
parentId: metadataRaw.parent, parentId: metadataRaw.parent,
contentType: metadataRaw.contentType, contentType: metadataRaw.contentType,
contentIv: metadataRaw.contentIv,
categories, categories,
...metadata, ...metadata,
}; };

View File

@@ -31,7 +31,6 @@ export interface FileInfo {
parentId: DirectoryId; parentId: DirectoryId;
dataKey?: DataKey; dataKey?: DataKey;
contentType: string; contentType: string;
contentIv?: string;
name: string; name: string;
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
@@ -42,7 +41,7 @@ export type MaybeFileInfo =
| (FileInfo & { exists: true }) | (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>); | ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "contentIv" | "categories">; export type SummarizedFileInfo = Omit<FileInfo, "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean }; export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo { interface LocalCategoryInfo {

View File

@@ -0,0 +1,14 @@
export const parseRangeHeader = (rangeHeader: string | null) => {
if (!rangeHeader) return undefined;
const firstRange = rangeHeader.split(",")[0]!.trim();
const parts = firstRange.replace(/bytes=/, "").split("-");
return {
start: parts[0] ? parseInt(parts[0], 10) : undefined,
end: parts[1] ? parseInt(parts[1], 10) : undefined,
};
};
export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => {
return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
};

View File

@@ -10,30 +10,69 @@ import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
import { safeUnlink } from "$lib/server/modules/filesystem"; import { safeUnlink } from "$lib/server/modules/filesystem";
export const getFileStream = async (userId: number, fileId: number) => { const createEncContentStream = async (
path: string,
iv: Buffer,
range?: { start?: number; end?: number },
) => {
const { size: fileSize } = await stat(path);
const ivSize = iv.byteLength;
const totalSize = fileSize + ivSize;
const start = range?.start ?? 0;
const end = range?.end ?? totalSize - 1;
if (start > end || start < 0 || end >= totalSize) {
error(416, "Invalid range");
}
return {
encContentStream: Readable.toWeb(
Readable.from(
(async function* () {
if (start < ivSize) {
yield iv.subarray(start, Math.min(end + 1, ivSize));
}
if (end >= ivSize) {
yield* createReadStream(path, {
start: Math.max(0, start - ivSize),
end: end - ivSize,
});
}
})(),
),
),
range: { start, end, total: totalSize },
};
};
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"); error(404, "Invalid file id");
} }
const { size } = await stat(file.path); return createEncContentStream(file.path, Buffer.from(file.encContentIv, "base64"), range);
return {
encContentStream: Readable.toWeb(createReadStream(file.path)),
encContentSize: size,
};
}; };
export const getFileThumbnailStream = async (userId: number, fileId: number) => { export const getFileThumbnailStream = async (
userId: number,
fileId: number,
range?: { start?: number; end?: 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"); error(404, "File or its thumbnail not found");
} }
const { size } = await stat(thumbnail.path); return createEncContentStream(
return { thumbnail.path,
encContentStream: Readable.toWeb(createReadStream(thumbnail.path)), Buffer.from(thumbnail.encContentIv, "base64"),
encContentSize: size, range,
}; );
}; };
export const uploadFileThumbnail = async ( export const uploadFileThumbnail = async (

View File

@@ -9,15 +9,11 @@ import {
import type { FileThumbnailUploadRequest } from "$lib/server/schemas"; import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestFileDownload = async ( export const requestFileDownload = async (fileId: number, dataKey: CryptoKey) => {
fileId: number,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
const cache = await getFileCache(fileId); const cache = await getFileCache(fileId);
if (cache) return cache; if (cache) return cache;
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey); const fileBuffer = await downloadFile(fileId, dataKey);
storeFileCache(fileId, fileBuffer); // Intended storeFileCache(fileId, fileBuffer); // Intended
return fileBuffer; return fileBuffer;
}; };

View File

@@ -5,7 +5,7 @@
import { page } from "$app/state"; import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms"; import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules"; import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getFileInfo, type FileInfo, type MaybeFileInfo } from "$lib/modules/filesystem"; import { getFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { getFileDownloadState } from "$lib/modules/file"; import { getFileDownloadState } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores"; import { masterKeyStore } from "$lib/stores";
@@ -95,14 +95,12 @@
untrack(() => { untrack(() => {
if (!downloadState && !isDownloadRequested) { if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true; isDownloadRequested = true;
requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then( requestFileDownload(data.id, info!.dataKey!.key).then(async (buffer) => {
async (buffer) => {
const blob = await updateViewer(buffer, contentType); const blob = await updateViewer(buffer, contentType);
if (!viewerType) { if (!viewerType) {
FileSaver.saveAs(blob, info!.name); FileSaver.saveAs(blob, info!.name);
} }
}, });
);
} }
}); });
} }
@@ -110,7 +108,9 @@
$effect(() => { $effect(() => {
if (info?.exists && downloadState?.status === "decrypted") { if (info?.exists && downloadState?.status === "decrypted") {
untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!)); untrack(
() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType!),
);
} }
}); });

View File

@@ -77,7 +77,7 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
await scheduler.schedule( await scheduler.schedule(
async () => { async () => {
statuses.set(fileInfo.id, "generation-pending"); statuses.set(fileInfo.id, "generation-pending");
file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!); file = await requestFileDownload(fileInfo.id, fileInfo.dataKey?.key!);
return file.byteLength; return file.byteLength;
}, },
async () => { async () => {

View File

@@ -1,10 +1,15 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { z } from "zod"; import { z } from "zod";
import { authorize } from "$lib/server/modules/auth"; import { authorize } from "$lib/server/modules/auth";
import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http";
import { getFileStream } from "$lib/server/services/file"; import { getFileStream } from "$lib/server/services/file";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => { const downloadHandler = async (
locals: App.Locals,
params: Record<string, string>,
request: Request,
) => {
const { userId } = await authorize(locals, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
@@ -15,11 +20,29 @@ export const GET: RequestHandler = async ({ locals, params }) => {
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 { encContentStream, encContentSize } = await getFileStream(userId, id); const { encContentStream, range } = await getFileStream(
return new Response(encContentStream as ReadableStream, { userId,
id,
parseRangeHeader(request.headers.get("Range")),
);
return {
stream: encContentStream,
headers: { headers: {
"Accept-Ranges": "bytes",
"Content-Length": (range.end - range.start + 1).toString(),
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Length": encContentSize.toString(), ...getContentRangeHeader(range),
}, },
}); isRangeRequest: !!range,
};
};
export const GET: RequestHandler = async ({ locals, params, request }) => {
const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers });
};
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
const { headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(null, { status: isRangeRequest ? 206 : 200, headers });
}; };

View File

@@ -1,10 +1,15 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { z } from "zod"; import { z } from "zod";
import { authorize } from "$lib/server/modules/auth"; import { authorize } from "$lib/server/modules/auth";
import { parseRangeHeader, getContentRangeHeader } from "$lib/server/modules/http";
import { getFileThumbnailStream } from "$lib/server/services/file"; import { getFileThumbnailStream } from "$lib/server/services/file";
import type { RequestHandler } from "./$types"; import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => { const downloadHandler = async (
locals: App.Locals,
params: Record<string, string>,
request: Request,
) => {
const { userId } = await authorize(locals, "activeClient"); const { userId } = await authorize(locals, "activeClient");
const zodRes = z const zodRes = z
@@ -15,11 +20,29 @@ export const GET: RequestHandler = async ({ locals, params }) => {
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 { encContentStream, encContentSize } = await getFileThumbnailStream(userId, id); const { encContentStream, range } = await getFileThumbnailStream(
return new Response(encContentStream as ReadableStream, { userId,
id,
parseRangeHeader(request.headers.get("Range")),
);
return {
stream: encContentStream,
headers: { headers: {
"Accept-Ranges": "bytes",
"Content-Length": (range.end - range.start + 1).toString(),
"Content-Type": "application/octet-stream", "Content-Type": "application/octet-stream",
"Content-Length": encContentSize.toString(), ...getContentRangeHeader(range),
}, },
}); isRangeRequest: !!range,
};
};
export const GET: RequestHandler = async ({ locals, params, request }) => {
const { stream, headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(stream as ReadableStream, { status: isRangeRequest ? 206 : 200, headers });
};
export const HEAD: RequestHandler = async ({ locals, params, request }) => {
const { headers, isRangeRequest } = await downloadHandler(locals, params, request);
return new Response(null, { status: isRangeRequest ? 206 : 200, headers });
}; };

View File

@@ -24,7 +24,6 @@ const fileRouter = router({
dek: file.encDek, dek: file.encDek,
dekVersion: file.dekVersion, dekVersion: file.dekVersion,
contentType: file.contentType, contentType: file.contentType,
contentIv: file.encContentIv,
name: file.encName.ciphertext, name: file.encName.ciphertext,
nameIv: file.encName.iv, nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext, createdAt: file.encCreatedAt?.ciphertext,
@@ -58,7 +57,6 @@ const fileRouter = router({
dek: file.encDek, dek: file.encDek,
dekVersion: file.dekVersion, dekVersion: file.dekVersion,
contentType: file.contentType, contentType: file.contentType,
contentIv: file.encContentIv,
name: file.encName.ciphertext, name: file.encName.ciphertext,
nameIv: file.encName.iv, nameIv: file.encName.iv,
createdAt: file.encCreatedAt?.ciphertext, createdAt: file.encCreatedAt?.ciphertext,
@@ -158,7 +156,7 @@ const fileRouter = router({
throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" }); throw new TRPCError({ code: "NOT_FOUND", message: "File or its thumbnail not found" });
} }
return { updatedAt: thumbnail.updatedAt, contentIv: thumbnail.encContentIv }; return { updatedAt: thumbnail.updatedAt };
}), }),
}); });