mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
파일을 다운로드할 때 전체 파일을 메모리에 올리는 대신 서비스 워커에서 스트리밍으로 복호화하도록 변경
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
877cf9c42e
commit
3e52fa7a7c
29 files changed
+629
-467
No files matched your search
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
Reference in new issue
Block a user