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
Vendored
+30
-30
@@ -1,50 +1,50 @@
|
||||
import {
|
||||
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
|
||||
storeFileCacheIndex,
|
||||
deleteFileCacheIndex,
|
||||
type FileCacheIndex,
|
||||
} from "#lib/client/storage/indexedDB/index.js";
|
||||
import { readFile, writeFile, deleteFile } from "#lib/client/storage/opfs.js";
|
||||
getFileCacheIndex as getAllFileCacheIndexes,
|
||||
getFileCacheIndexOf,
|
||||
storeFileCacheIndex,
|
||||
updateFileCacheRetrievedAt,
|
||||
} from "#lib/client/storage/indexedDB/cacheIndex.js";
|
||||
import { createWritable, deleteFile, getFile } from "#lib/client/storage/opfs.js";
|
||||
|
||||
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
||||
// Used by both the pages and the service worker, so the index is always read from IndexedDB
|
||||
|
||||
export const prepareFileCache = async () => {
|
||||
for (const cache of await getFileCacheIndexFromIndexedDB()) {
|
||||
fileCacheIndex.set(cache.fileId, cache);
|
||||
}
|
||||
};
|
||||
const getPath = (fileId: number) => `/cache/${fileId}`;
|
||||
|
||||
export const getFileCacheIndex = () => {
|
||||
return Array.from(fileCacheIndex.values());
|
||||
export const getFileCacheIndex = async () => {
|
||||
return await getAllFileCacheIndexes();
|
||||
};
|
||||
|
||||
// The index is written only after the whole content is written
|
||||
export const getFileCache = async (fileId: number) => {
|
||||
const cacheIndex = fileCacheIndex.get(fileId);
|
||||
if (!cacheIndex) return null;
|
||||
if (!(await getFileCacheIndexOf(fileId))) return null;
|
||||
|
||||
cacheIndex.lastRetrievedAt = new Date();
|
||||
storeFileCacheIndex(cacheIndex); // Intended
|
||||
return await readFile(`/cache/${fileId}`);
|
||||
const file = await getFile(getPath(fileId));
|
||||
if (file) {
|
||||
void updateFileCacheRetrievedAt(fileId, new Date());
|
||||
}
|
||||
return file;
|
||||
};
|
||||
|
||||
export const storeFileCache = async (fileId: number, fileBuffer: ArrayBuffer) => {
|
||||
const now = new Date();
|
||||
await writeFile(`/cache/${fileId}`, fileBuffer);
|
||||
export const storeFileCache = async (fileId: number, stream: ReadableStream<Uint8Array>) => {
|
||||
const path = getPath(fileId);
|
||||
try {
|
||||
await stream.pipeTo(await createWritable(path));
|
||||
} catch (e) {
|
||||
await deleteFile(path).catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
|
||||
const cacheIndex: FileCacheIndex = {
|
||||
const now = new Date();
|
||||
await storeFileCacheIndex({
|
||||
fileId,
|
||||
cachedAt: now,
|
||||
lastRetrievedAt: now,
|
||||
size: fileBuffer.byteLength,
|
||||
};
|
||||
fileCacheIndex.set(fileId, cacheIndex);
|
||||
await storeFileCacheIndex(cacheIndex);
|
||||
size: (await getFile(path))?.size ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteFileCache = async (fileId: number) => {
|
||||
if (!fileCacheIndex.has(fileId)) return;
|
||||
|
||||
fileCacheIndex.delete(fileId);
|
||||
await deleteFile(`/cache/${fileId}`);
|
||||
await deleteFileCacheIndex(fileId);
|
||||
await deleteFile(getPath(fileId));
|
||||
};
|
||||
@@ -25,3 +25,23 @@ export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
||||
return new Date(utcDate - offsetMs);
|
||||
};
|
||||
|
||||
const EXIF_SEARCH_SIZE = 1024 * 1024; // 1 MiB
|
||||
const MAX_FULL_READ_SIZE = 64 * 1024 * 1024; // 64 MiB
|
||||
|
||||
const tryExtractExifDateTime = (buffer: ArrayBuffer) => {
|
||||
try {
|
||||
return extractExifDateTime(buffer);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// EXIF is usually at the beginning of the file, so the whole file is read only if not found
|
||||
export const readExifDateTime = async (file: Blob) => {
|
||||
const dateTime = tryExtractExifDateTime(await file.slice(0, EXIF_SEARCH_SIZE).arrayBuffer());
|
||||
if (dateTime || file.size <= EXIF_SEARCH_SIZE || file.size > MAX_FULL_READ_SIZE) {
|
||||
return dateTime;
|
||||
}
|
||||
return tryExtractExifDateTime(await file.arrayBuffer());
|
||||
};
|
||||
@@ -82,35 +82,42 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const generateThumbnail = async (blob: Blob) => {
|
||||
let url;
|
||||
const convertHeicToPng = async (blob: Blob) => {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
return (await heic2any({ blob, toType: "image/png" })) as Blob;
|
||||
};
|
||||
|
||||
// Generates a thumbnail of the image or video at the URL. Returns `null` if not supported.
|
||||
export const generateThumbnailFromUrl = async (url: string, contentType: string) => {
|
||||
try {
|
||||
if (blob.type.startsWith("image/")) {
|
||||
url = URL.createObjectURL(blob);
|
||||
if (contentType.startsWith("image/")) {
|
||||
try {
|
||||
return await generateImageThumbnail(url);
|
||||
} catch {
|
||||
URL.revokeObjectURL(url);
|
||||
url = undefined;
|
||||
if (contentType !== "image/heic") return null;
|
||||
|
||||
if (blob.type === "image/heic") {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
url = URL.createObjectURL((await heic2any({ blob, toType: "image/png" })) as Blob);
|
||||
return await generateImageThumbnail(url);
|
||||
} else {
|
||||
return null;
|
||||
// Chromium can't decode HEIC images
|
||||
const png = URL.createObjectURL(await convertHeicToPng(await (await fetch(url)).blob()));
|
||||
try {
|
||||
return await generateImageThumbnail(png);
|
||||
} finally {
|
||||
URL.revokeObjectURL(png);
|
||||
}
|
||||
}
|
||||
} else if (blob.type.startsWith("video/")) {
|
||||
url = URL.createObjectURL(blob);
|
||||
} else if (contentType.startsWith("video/")) {
|
||||
return await generateVideoThumbnail(url);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const generateThumbnail = async (blob: Blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
return await generateThumbnailFromUrl(url, blob.type);
|
||||
} finally {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
import { DECRYPTED_FILE_URL_PREFIX } from "#lib/shared/serviceWorker.js";
|
||||
import type {
|
||||
FileMetadata,
|
||||
ServiceWorkerMessage,
|
||||
ServiceWorkerResponse,
|
||||
import { handleDownloadProgress } from "#lib/client/transfer/download.svelte.js";
|
||||
import {
|
||||
DECRYPTED_FILE_URL_PREFIX,
|
||||
type FileMetadata,
|
||||
type ServiceWorkerMessage,
|
||||
type ServiceWorkerResponse,
|
||||
} from "#lib/shared/serviceWorker.js";
|
||||
|
||||
const PREPARE_TIMEOUT_MS = 5000;
|
||||
@@ -10,17 +11,22 @@ const PREPARE_TIMEOUT_MS = 5000;
|
||||
// The service worker may be terminated at any time, losing its in-memory metadata.
|
||||
// Keep a copy here so that it can be re-requested on demand.
|
||||
const preparedFiles = new Map<number, FileMetadata>();
|
||||
let isListeningForMetadataRequests = false;
|
||||
let isListening = false;
|
||||
|
||||
export const listenForMetadataRequests = () => {
|
||||
if (isListeningForMetadataRequests) return;
|
||||
isListeningForMetadataRequests = true;
|
||||
export const listenForServiceWorkerMessages = () => {
|
||||
if (isListening) return;
|
||||
isListening = true;
|
||||
|
||||
navigator.serviceWorker.addEventListener(
|
||||
"message",
|
||||
(event: MessageEvent<ServiceWorkerResponse>) => {
|
||||
if (event.data.type === "decryption-metadata-request") {
|
||||
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
|
||||
switch (event.data.type) {
|
||||
case "decryption-metadata-request":
|
||||
event.ports[0]?.postMessage(preparedFiles.get(event.data.fileId) ?? null);
|
||||
break;
|
||||
case "download-progress":
|
||||
handleDownloadProgress(event.data);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -37,7 +43,7 @@ const getServiceWorker = async () => {
|
||||
|
||||
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
|
||||
preparedFiles.set(id, metadata);
|
||||
listenForMetadataRequests();
|
||||
listenForServiceWorkerMessages();
|
||||
|
||||
const sw = await getServiceWorker();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
@@ -63,3 +69,10 @@ export const prepareFileDecryption = async (id: number, metadata: FileMetadata)
|
||||
};
|
||||
|
||||
export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`;
|
||||
|
||||
// Lets the service worker download, decrypt, and save the file
|
||||
export const saveDecryptedFile = (id: number, filename: string) => {
|
||||
const link = document.createElement("a");
|
||||
link.href = `${getDecryptedFileUrl(id)}?download=${encodeURIComponent(filename)}`;
|
||||
link.click();
|
||||
};
|
||||
@@ -28,6 +28,14 @@ export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
|
||||
});
|
||||
};
|
||||
|
||||
export const getFileCacheIndexOf = async (fileId: number) => {
|
||||
return await cacheIndex.fileCache.get(fileId);
|
||||
};
|
||||
|
||||
export const updateFileCacheRetrievedAt = async (fileId: number, lastRetrievedAt: Date) => {
|
||||
await cacheIndex.fileCache.update(fileId, { lastRetrievedAt });
|
||||
};
|
||||
|
||||
export const deleteFileCacheIndex = async (fileId: number) => {
|
||||
await cacheIndex.fileCache.delete(fileId);
|
||||
};
|
||||
@@ -48,6 +48,12 @@ export const writeFile = async (path: string, data: ArrayBuffer) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Changes are applied atomically when the stream is closed, and discarded if it is aborted
|
||||
export const createWritable = async (path: string) => {
|
||||
const { fileHandle } = await getFileHandle(path);
|
||||
return await fileHandle!.createWritable();
|
||||
};
|
||||
|
||||
export const deleteFile = async (path: string) => {
|
||||
const { parentHandle, filename } = await getFileHandle(path, false);
|
||||
if (!parentHandle) return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import pLimit from "p-limit";
|
||||
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.js";
|
||||
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.js";
|
||||
import { BoundedQueue } from "#lib/utils/concurrency/index.js";
|
||||
import { createSpeedMeter } from "#lib/utils/speedMeter.js";
|
||||
|
||||
interface UploadStats {
|
||||
progress: number;
|
||||
@@ -15,59 +16,6 @@ interface EncryptedChunk {
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
const createSpeedMeter = (timeWindow = 3000, minInterval = 200, warmupPeriod = 500) => {
|
||||
const samples: { t: number; b: number }[] = [];
|
||||
let lastSpeed = 0;
|
||||
let startTime: number | null = null;
|
||||
|
||||
return (bytesNow?: number) => {
|
||||
if (bytesNow === undefined) return lastSpeed;
|
||||
|
||||
const now = performance.now();
|
||||
|
||||
// Initialize start time on first call
|
||||
if (startTime === null) {
|
||||
startTime = now;
|
||||
}
|
||||
|
||||
// Check if enough time has passed since the last sample
|
||||
const lastSample = samples[samples.length - 1];
|
||||
if (lastSample && now - lastSample.t < minInterval) {
|
||||
return lastSpeed;
|
||||
}
|
||||
|
||||
samples.push({ t: now, b: bytesNow });
|
||||
|
||||
// Remove old samples outside the time window
|
||||
const cutoff = now - timeWindow;
|
||||
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
|
||||
|
||||
// Need at least 2 samples to calculate speed
|
||||
if (samples.length < 2) {
|
||||
return lastSpeed;
|
||||
}
|
||||
|
||||
const first = samples[0]!;
|
||||
const dt = now - first.t;
|
||||
const db = bytesNow - first.b;
|
||||
|
||||
if (dt >= minInterval) {
|
||||
const instantSpeed = (db / dt) * 1000;
|
||||
// Apply EMA for smoother speed transitions
|
||||
const alpha = 0.3;
|
||||
const rawSpeed =
|
||||
lastSpeed === 0 ? instantSpeed : alpha * instantSpeed + (1 - alpha) * lastSpeed;
|
||||
|
||||
// Apply warmup ramp to prevent initial overestimation
|
||||
const elapsed = now - startTime;
|
||||
const warmupWeight = Math.min(1, elapsed / warmupPeriod);
|
||||
lastSpeed = rawSpeed * warmupWeight;
|
||||
}
|
||||
|
||||
return lastSpeed;
|
||||
};
|
||||
};
|
||||
|
||||
const encryptChunkData = async (chunk: Blob, dataKey: CryptoKey): Promise<ArrayBuffer> => {
|
||||
return await encryptChunk(await chunk.arrayBuffer(), dataKey);
|
||||
};
|
||||
|
||||
@@ -1,34 +1,21 @@
|
||||
import axios from "axios";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { decryptChunk, concatenateBuffers } from "#lib/shared/crypto/index.js";
|
||||
import { ENCRYPTED_CHUNK_SIZE } from "#lib/shared/format/index.js";
|
||||
import type { DownloadProgressMessage } from "#lib/shared/serviceWorker.js";
|
||||
import { createSpeedMeter } from "#lib/utils/speedMeter.js";
|
||||
|
||||
// Files are downloaded and decrypted by the service worker, which reports the progress
|
||||
|
||||
export interface FileDownloadState {
|
||||
id: number;
|
||||
status:
|
||||
| "download-pending"
|
||||
| "downloading"
|
||||
| "decryption-pending"
|
||||
| "decrypting"
|
||||
| "decrypted"
|
||||
| "canceled"
|
||||
| "error";
|
||||
status: "downloading" | "completed" | "error";
|
||||
progress?: number;
|
||||
rate?: number;
|
||||
estimated?: number;
|
||||
result?: ArrayBuffer;
|
||||
}
|
||||
|
||||
type LiveFileDownloadState = FileDownloadState & {
|
||||
status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
|
||||
};
|
||||
|
||||
let downloadingFiles: FileDownloadState[] = $state([]);
|
||||
const speedMeters = new WeakMap<FileDownloadState, ReturnType<typeof createSpeedMeter>>();
|
||||
|
||||
export const isFileDownloading = (
|
||||
status: FileDownloadState["status"],
|
||||
): status is LiveFileDownloadState["status"] =>
|
||||
["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
|
||||
export const isFileDownloading = (status: FileDownloadState["status"]) => {
|
||||
return status === "downloading";
|
||||
};
|
||||
|
||||
export const getFileDownloadState = (fileId: number) => {
|
||||
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
|
||||
@@ -42,69 +29,27 @@ export const clearDownloadedFiles = () => {
|
||||
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
|
||||
};
|
||||
|
||||
const requestFileDownload = limitFunction(
|
||||
async (state: FileDownloadState, id: number) => {
|
||||
state.status = "downloading";
|
||||
export const handleDownloadProgress = ({
|
||||
fileId,
|
||||
status,
|
||||
loaded,
|
||||
total,
|
||||
}: DownloadProgressMessage) => {
|
||||
let state = getFileDownloadState(fileId);
|
||||
if (!state) {
|
||||
if (status !== "downloading") return;
|
||||
|
||||
const res = await axios.get(`/api/file/${id}/download`, {
|
||||
responseType: "arraybuffer",
|
||||
onDownloadProgress: ({ progress, rate, estimated }) => {
|
||||
state.progress = progress;
|
||||
state.rate = rate;
|
||||
state.estimated = estimated;
|
||||
},
|
||||
});
|
||||
const fileEncrypted: ArrayBuffer = res.data;
|
||||
|
||||
state.status = "decryption-pending";
|
||||
return fileEncrypted;
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
|
||||
const decryptFile = limitFunction(
|
||||
async (
|
||||
state: FileDownloadState,
|
||||
fileEncrypted: ArrayBuffer,
|
||||
encryptedChunkSize: number,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
state.status = "decrypting";
|
||||
|
||||
const chunks: ArrayBuffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < fileEncrypted.byteLength) {
|
||||
const nextOffset = Math.min(offset + encryptedChunkSize, fileEncrypted.byteLength);
|
||||
chunks.push(await decryptChunk(fileEncrypted.slice(offset, nextOffset), dataKey));
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
const fileBuffer = concatenateBuffers(...chunks).buffer;
|
||||
state.status = "decrypted";
|
||||
state.result = fileBuffer;
|
||||
return fileBuffer;
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
export const downloadFile = async (id: number, dataKey: CryptoKey, isLegacy: boolean) => {
|
||||
downloadingFiles.push({
|
||||
id,
|
||||
status: "download-pending",
|
||||
});
|
||||
const state = downloadingFiles.at(-1)!;
|
||||
|
||||
try {
|
||||
const fileEncrypted = await requestFileDownload(state, id);
|
||||
return await decryptFile(
|
||||
state,
|
||||
fileEncrypted,
|
||||
isLegacy ? fileEncrypted.byteLength : ENCRYPTED_CHUNK_SIZE,
|
||||
dataKey,
|
||||
);
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
downloadingFiles.push({ id: fileId, status });
|
||||
state = downloadingFiles.at(-1)!;
|
||||
speedMeters.set(state, createSpeedMeter());
|
||||
}
|
||||
|
||||
if (status === "canceled") {
|
||||
downloadingFiles = downloadingFiles.filter((file) => file !== state);
|
||||
return;
|
||||
}
|
||||
|
||||
state.status = status;
|
||||
state.progress = total > 0 ? loaded / total : 1;
|
||||
state.rate = speedMeters.get(state)?.(loaded);
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type HmacSecret,
|
||||
type MasterKey,
|
||||
} from "#lib/client/keys/index.js";
|
||||
import { extractExifDateTime } from "#lib/client/media/exif.js";
|
||||
import { readExifDateTime } from "#lib/client/media/exif.js";
|
||||
import { generateThumbnail } from "#lib/client/media/thumbnail.js";
|
||||
import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js";
|
||||
import {
|
||||
@@ -41,9 +41,7 @@ export type LiveFileUploadState = FileUploadState & {
|
||||
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
|
||||
};
|
||||
|
||||
const scheduler = new Scheduler<
|
||||
{ fileId: number; fileBuffer?: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
|
||||
>();
|
||||
const scheduler = new Scheduler<{ fileId: number; thumbnailBuffer?: ArrayBuffer } | undefined>();
|
||||
let uploadingFiles: FileUploadState[] = $state([]);
|
||||
|
||||
const isFileUploading = (status: FileUploadState["status"]) =>
|
||||
@@ -213,7 +211,6 @@ export const uploadFile = async (
|
||||
return;
|
||||
}
|
||||
|
||||
let fileBuffer;
|
||||
const fileType = getFileType(file);
|
||||
const fileMetadata: FileMetadata = {
|
||||
parentId,
|
||||
@@ -222,8 +219,7 @@ export const uploadFile = async (
|
||||
};
|
||||
|
||||
if (fileType.startsWith("image/")) {
|
||||
fileBuffer = await file.arrayBuffer();
|
||||
fileMetadata.createdAt = extractExifDateTime(fileBuffer);
|
||||
fileMetadata.createdAt = await readExifDateTime(file);
|
||||
}
|
||||
|
||||
const blob = new Blob([file], { type: fileType });
|
||||
@@ -241,7 +237,7 @@ export const uploadFile = async (
|
||||
dataKeyVersion,
|
||||
);
|
||||
|
||||
return { fileId, fileBuffer, thumbnailBuffer };
|
||||
return { fileId, thumbnailBuffer };
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
|
||||
Reference in new issue
Block a user