파일을 다운로드할 때 전체 파일을 메모리에 올리는 대신 서비스 워커에서 스트리밍으로 복호화하도록 변경

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 00:48:21 +09:00
1 parent 877cf9c42e
commit 3e52fa7a7c
29 files changed
+629 -467

No files matched your search

+50 -85
View File
@@ -1,8 +1,6 @@
import { getFile } from "#lib/client/storage/opfs.js";
import { decryptChunk } from "#lib/shared/crypto/index.js";
import { getFileCache, storeFileCache } from "#lib/client/cache/fileCache.js";
import {
CHUNK_SIZE,
ENCRYPTED_CHUNK_SIZE,
createDecryptionStream,
getDecryptedSize,
getEncryptedRange,
} from "#lib/shared/format/index.js";
@@ -15,18 +13,25 @@ import {
} from "#lib/shared/http.js";
import { DECRYPTED_FILE_URL_PREFIX, type FileMetadata } from "#lib/shared/serviceWorker.js";
import { getFileMetadata } from "../modules/metadata";
import { trackDownloadProgress } from "../modules/progress";
interface StreamRequest {
fileId: number;
clientId: string;
rangeRequest?: RangeRequest;
downloadFilename?: string;
}
const createResponse = (
stream: ReadableStream<Uint8Array>,
isRangeRequest: boolean,
{ rangeRequest, downloadFilename }: StreamRequest,
range: ByteRange,
contentType?: string,
downloadFilename?: string,
) => {
const headers: Record<string, string> = {
"Accept-Ranges": "bytes",
"Content-Length": String(range.end - range.start + 1),
...(isRangeRequest ? getContentRangeHeader(range) : {}),
...(rangeRequest ? getContentRangeHeader(range) : {}),
};
if (contentType) {
@@ -39,7 +44,7 @@ const createResponse = (
}
return new Response(stream, {
status: isRangeRequest ? 206 : 200,
status: rangeRequest ? 206 : 200,
headers,
});
};
@@ -51,102 +56,58 @@ const createRangeNotSatisfiableResponse = (total: number) => {
});
};
const streamFromOpfs = async (
file: File,
metadata?: FileMetadata,
rangeRequest?: RangeRequest,
downloadFilename?: string,
) => {
const range = resolveRange(rangeRequest, file.size);
const streamFromCache = (request: StreamRequest, cache: File, metadata?: FileMetadata) => {
const range = resolveRange(request.rangeRequest, cache.size);
if (!range) {
return createRangeNotSatisfiableResponse(file.size);
return createRangeNotSatisfiableResponse(cache.size);
}
return createResponse(
file.slice(range.start, range.end + 1).stream(),
!!rangeRequest,
cache.slice(range.start, range.end + 1).stream(),
request,
range,
metadata?.contentType,
downloadFilename,
);
};
const streamFromServer = async (
id: number,
metadata: FileMetadata,
rangeRequest?: RangeRequest,
downloadFilename?: string,
) => {
const streamFromServer = async (request: StreamRequest, metadata: FileMetadata) => {
const { fileId, clientId, rangeRequest } = request;
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
const range = resolveRange(rangeRequest, totalSize);
if (!range) {
return createRangeNotSatisfiableResponse(totalSize);
}
const { start, end } = range;
const encryptedRange = getEncryptedRange(start, end, metadata.encContentSize, metadata.isLegacy);
const apiResponse = await fetch(`/api/file/${id}/download`, {
const encryptedRange = getEncryptedRange(
range.start,
range.end,
metadata.encContentSize,
metadata.isLegacy,
);
const apiResponse = await fetch(`/api/file/${fileId}/download`, {
headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` },
});
if (apiResponse.status !== 206 || !apiResponse.body) {
return new Response("Failed to fetch encrypted file", { status: 502 });
}
if (metadata.isLegacy) {
const fileEncrypted = await apiResponse.arrayBuffer();
const decrypted = await decryptChunk(fileEncrypted, metadata.dataKey);
return createResponse(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(decrypted.slice(start, end + 1)));
controller.close();
},
}),
!!rangeRequest,
let stream = apiResponse.body.pipeThrough(
createDecryptionStream({
range,
metadata.contentType,
downloadFilename,
);
chunkCount: encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1,
dataKey: metadata.dataKey,
isLegacy: metadata.isLegacy,
}),
);
// Cache and report the progress only when the whole content is requested (i.e. not seeking)
if (!rangeRequest) {
const [responseStream, cacheStream] = stream.tee();
storeFileCache(fileId, cacheStream).catch(console.error);
stream = trackDownloadProgress(responseStream, fileId, totalSize, clientId);
}
const totalChunks = encryptedRange.lastChunkIndex - encryptedRange.firstChunkIndex + 1;
let currentChunkIndex = 0;
let buffer = new Uint8Array(0);
const decryptingStream = new TransformStream<Uint8Array, Uint8Array>({
async transform(chunk, controller) {
const newBuffer = new Uint8Array(buffer.length + chunk.length);
newBuffer.set(buffer);
newBuffer.set(chunk, buffer.length);
buffer = newBuffer;
while (buffer.length >= ENCRYPTED_CHUNK_SIZE && currentChunkIndex < totalChunks - 1) {
const encryptedChunk = buffer.slice(0, ENCRYPTED_CHUNK_SIZE);
buffer = buffer.slice(ENCRYPTED_CHUNK_SIZE);
const decrypted = await decryptChunk(encryptedChunk.buffer, metadata.dataKey);
const sliceStart = currentChunkIndex === 0 ? start % CHUNK_SIZE : 0;
controller.enqueue(new Uint8Array(decrypted.slice(sliceStart)));
currentChunkIndex++;
}
},
async flush(controller) {
if (buffer.length > 0) {
const decrypted = await decryptChunk(buffer.buffer, metadata.dataKey);
const sliceStart = currentChunkIndex === 0 ? start % CHUNK_SIZE : 0;
const sliceEnd = (end % CHUNK_SIZE) + 1;
controller.enqueue(new Uint8Array(decrypted.slice(sliceStart, sliceEnd)));
}
},
});
return createResponse(
apiResponse.body.pipeThrough(decryptingStream),
!!rangeRequest,
range,
metadata.contentType,
downloadFilename,
);
return createResponse(stream, request, range, metadata.contentType);
};
const decryptFileHandler = async (request: Request, clientId: string) => {
@@ -156,14 +117,18 @@ const decryptFileHandler = async (request: Request, clientId: string) => {
return new Response("Invalid file id", { status: 400 });
}
const downloadFilename = url.searchParams.get("download") ?? undefined;
const streamRequest: StreamRequest = {
fileId,
clientId,
rangeRequest: parseRangeHeader(request.headers.get("Range")),
downloadFilename: url.searchParams.get("download") ?? undefined,
};
const metadata = await getFileMetadata(fileId, clientId);
const range = parseRangeHeader(request.headers.get("Range"));
const cache = await getFile(`/cache/${fileId}`);
const cache = await getFileCache(fileId);
if (cache) {
return streamFromOpfs(cache, metadata, range, downloadFilename);
return streamFromCache(streamRequest, cache, metadata);
} else if (metadata) {
return streamFromServer(fileId, metadata, range, downloadFilename);
return streamFromServer(streamRequest, metadata);
} else {
return new Response("Decryption not prepared", { status: 400 });
}
+10
View File
@@ -0,0 +1,10 @@
import { self } from "$app/service-worker";
import type { ServiceWorkerResponse } from "#lib/shared/serviceWorker.js";
// Posts the message to the client that made the request, or to all windows if unknown
// (e.g. downloads by navigation)
export const notifyClients = async (clientId: string, message: ServiceWorkerResponse) => {
const client = clientId ? await self.clients.get(clientId) : undefined;
const clients = client ? [client] : await self.clients.matchAll({ type: "window" });
clients.forEach((client) => client.postMessage(message));
};
+48
View File
@@ -0,0 +1,48 @@
import type { DownloadProgressMessage } from "#lib/shared/serviceWorker.js";
import { notifyClients } from "./clients";
const REPORT_INTERVAL_MS = 200;
// Reports the progress of reading the stream to the clients
export const trackDownloadProgress = (
stream: ReadableStream<Uint8Array>,
fileId: number,
total: number,
clientId: string,
) => {
const reader = stream.getReader();
let loaded = 0;
let lastReportedAt = 0;
const report = (status: DownloadProgressMessage["status"]) => {
lastReportedAt = Date.now();
void notifyClients(clientId, { type: "download-progress", fileId, status, loaded, total });
};
report("downloading");
return new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
report("completed");
controller.close();
return;
}
loaded += value.byteLength;
if (Date.now() - lastReportedAt >= REPORT_INTERVAL_MS) {
report("downloading");
}
controller.enqueue(value);
} catch (e) {
report("error");
controller.error(e);
}
},
async cancel(reason) {
report("canceled");
await reader.cancel(reason);
},
});
};