2 Commits

26 changed files with 412 additions and 70 deletions

View File

@@ -1,7 +1,6 @@
import type { ClientInit } from "@sveltejs/kit"; import type { ClientInit } from "@sveltejs/kit";
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB"; import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { prepareFileCache } from "$lib/modules/file"; import { prepareFileCache } from "$lib/modules/file";
import { prepareOpfs } from "$lib/modules/opfs";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
const requestPersistentStorage = async () => { const requestPersistentStorage = async () => {
@@ -46,7 +45,6 @@ export const init: ClientInit = async () => {
prepareClientKeyStore(), prepareClientKeyStore(),
prepareMasterKeyStore(), prepareMasterKeyStore(),
prepareHmacSecretStore(), prepareHmacSecretStore(),
prepareOpfs(),
]); ]);
cleanupDanglingInfos(); // Intended cleanupDanglingInfos(); // Intended

View File

@@ -1 +1,2 @@
export * from "./serviceWorker";
export * from "./upload"; export * from "./upload";

View File

@@ -0,0 +1 @@
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decrypted-file/";

View File

@@ -1,5 +1,6 @@
export const CHUNK_SIZE = 4 * 1024 * 1024;
export const AES_GCM_IV_SIZE = 12; export const AES_GCM_IV_SIZE = 12;
export const AES_GCM_TAG_SIZE = 16; export const AES_GCM_TAG_SIZE = 16;
export const ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE; export const ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE;
export const CHUNK_SIZE = 4 * 1024 * 1024;
export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD;

View File

@@ -9,7 +9,7 @@ import {
export const generateMasterKey = async () => { export const generateMasterKey = async () => {
return { return {
masterKey: await window.crypto.subtle.generateKey( masterKey: await crypto.subtle.generateKey(
{ {
name: "AES-KW", name: "AES-KW",
length: 256, length: 256,
@@ -22,7 +22,7 @@ export const generateMasterKey = async () => {
export const generateDataKey = async () => { export const generateDataKey = async () => {
return { return {
dataKey: await window.crypto.subtle.generateKey( dataKey: await crypto.subtle.generateKey(
{ {
name: "AES-GCM", name: "AES-GCM",
length: 256, length: 256,
@@ -35,9 +35,9 @@ export const generateDataKey = async () => {
}; };
export const makeAESKeyNonextractable = async (key: CryptoKey) => { export const makeAESKeyNonextractable = async (key: CryptoKey) => {
return await window.crypto.subtle.importKey( return await crypto.subtle.importKey(
"raw", "raw",
await window.crypto.subtle.exportKey("raw", key), await crypto.subtle.exportKey("raw", key),
key.algorithm, key.algorithm,
false, false,
key.usages, key.usages,
@@ -45,12 +45,12 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => {
}; };
export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => { export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await window.crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW")); return encodeToBase64(await crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW"));
}; };
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => { export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
return { return {
dataKey: await window.crypto.subtle.unwrapKey( dataKey: await crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(dataKeyWrapped), decodeFromBase64(dataKeyWrapped),
masterKey, masterKey,
@@ -63,12 +63,12 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
}; };
export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => { export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await window.crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW")); return encodeToBase64(await crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
}; };
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => { export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
return { return {
hmacSecret: await window.crypto.subtle.unwrapKey( hmacSecret: await crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(hmacSecretWrapped), decodeFromBase64(hmacSecretWrapped),
masterKey, masterKey,
@@ -84,8 +84,8 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
}; };
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
const iv = window.crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await window.crypto.subtle.encrypt( const ciphertext = await crypto.subtle.encrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
iv, iv,
@@ -101,7 +101,7 @@ export const decryptData = async (
iv: string | BufferSource, iv: string | BufferSource,
dataKey: CryptoKey, dataKey: CryptoKey,
) => { ) => {
return await window.crypto.subtle.decrypt( return await crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
iv: typeof iv === "string" ? decodeFromBase64(iv) : iv, iv: typeof iv === "string" ? decodeFromBase64(iv) : iv,

View File

@@ -1,7 +1,7 @@
import { encodeString, encodeToBase64, decodeFromBase64 } from "./util"; import { encodeString, encodeToBase64, decodeFromBase64 } from "./util";
export const generateEncryptionKeyPair = async () => { export const generateEncryptionKeyPair = async () => {
const keyPair = await window.crypto.subtle.generateKey( const keyPair = await crypto.subtle.generateKey(
{ {
name: "RSA-OAEP", name: "RSA-OAEP",
modulusLength: 4096, modulusLength: 4096,
@@ -18,7 +18,7 @@ export const generateEncryptionKeyPair = async () => {
}; };
export const generateSigningKeyPair = async () => { export const generateSigningKeyPair = async () => {
const keyPair = await window.crypto.subtle.generateKey( const keyPair = await crypto.subtle.generateKey(
{ {
name: "RSA-PSS", name: "RSA-PSS",
modulusLength: 4096, modulusLength: 4096,
@@ -37,7 +37,7 @@ export const generateSigningKeyPair = async () => {
export const exportRSAKey = async (key: CryptoKey) => { export const exportRSAKey = async (key: CryptoKey) => {
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const); const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
return { return {
key: await window.crypto.subtle.exportKey(format, key), key: await crypto.subtle.exportKey(format, key),
format, format,
}; };
}; };
@@ -54,14 +54,14 @@ export const importEncryptionKeyPairFromBase64 = async (
name: "RSA-OAEP", name: "RSA-OAEP",
hash: "SHA-256", hash: "SHA-256",
}; };
const encryptKey = await window.crypto.subtle.importKey( const encryptKey = await crypto.subtle.importKey(
"spki", "spki",
decodeFromBase64(encryptKeyBase64), decodeFromBase64(encryptKeyBase64),
algorithm, algorithm,
true, true,
["encrypt", "wrapKey"], ["encrypt", "wrapKey"],
); );
const decryptKey = await window.crypto.subtle.importKey( const decryptKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
decodeFromBase64(decryptKeyBase64), decodeFromBase64(decryptKeyBase64),
algorithm, algorithm,
@@ -79,14 +79,14 @@ export const importSigningKeyPairFromBase64 = async (
name: "RSA-PSS", name: "RSA-PSS",
hash: "SHA-256", hash: "SHA-256",
}; };
const signKey = await window.crypto.subtle.importKey( const signKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
decodeFromBase64(signKeyBase64), decodeFromBase64(signKeyBase64),
algorithm, algorithm,
true, true,
["sign"], ["sign"],
); );
const verifyKey = await window.crypto.subtle.importKey( const verifyKey = await crypto.subtle.importKey(
"spki", "spki",
decodeFromBase64(verifyKeyBase64), decodeFromBase64(verifyKeyBase64),
algorithm, algorithm,
@@ -98,17 +98,11 @@ export const importSigningKeyPairFromBase64 = async (
export const makeRSAKeyNonextractable = async (key: CryptoKey) => { export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
const { key: exportedKey, format } = await exportRSAKey(key); const { key: exportedKey, format } = await exportRSAKey(key);
return await window.crypto.subtle.importKey( return await crypto.subtle.importKey(format, exportedKey, key.algorithm, false, key.usages);
format,
exportedKey,
key.algorithm,
false,
key.usages,
);
}; };
export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => { export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => {
return await window.crypto.subtle.decrypt( return await crypto.subtle.decrypt(
{ {
name: "RSA-OAEP", name: "RSA-OAEP",
} satisfies RsaOaepParams, } satisfies RsaOaepParams,
@@ -119,7 +113,7 @@ export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey)
export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => { export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => {
return encodeToBase64( return encodeToBase64(
await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, { await crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
name: "RSA-OAEP", name: "RSA-OAEP",
} satisfies RsaOaepParams), } satisfies RsaOaepParams),
); );
@@ -131,7 +125,7 @@ export const unwrapMasterKey = async (
extractable = false, extractable = false,
) => { ) => {
return { return {
masterKey: await window.crypto.subtle.unwrapKey( masterKey: await crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(masterKeyWrapped), decodeFromBase64(masterKeyWrapped),
decryptKey, decryptKey,
@@ -146,7 +140,7 @@ export const unwrapMasterKey = async (
}; };
export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => { export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
return await window.crypto.subtle.sign( return await crypto.subtle.sign(
{ {
name: "RSA-PSS", name: "RSA-PSS",
saltLength: 32, // SHA-256 saltLength: 32, // SHA-256
@@ -161,7 +155,7 @@ export const verifySignatureRSA = async (
signature: BufferSource, signature: BufferSource,
verifyKey: CryptoKey, verifyKey: CryptoKey,
) => { ) => {
return await window.crypto.subtle.verify( return await crypto.subtle.verify(
{ {
name: "RSA-PSS", name: "RSA-PSS",
saltLength: 32, // SHA-256 saltLength: 32, // SHA-256

View File

@@ -1,10 +1,10 @@
export const digestMessage = async (message: BufferSource) => { export const digestMessage = async (message: BufferSource) => {
return await window.crypto.subtle.digest("SHA-256", message); return await crypto.subtle.digest("SHA-256", message);
}; };
export const generateHmacSecret = async () => { export const generateHmacSecret = async () => {
return { return {
hmacSecret: await window.crypto.subtle.generateKey( hmacSecret: await crypto.subtle.generateKey(
{ {
name: "HMAC", name: "HMAC",
hash: "SHA-256", hash: "SHA-256",
@@ -16,5 +16,5 @@ export const generateHmacSecret = async () => {
}; };
export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => { export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => {
return await window.crypto.subtle.sign("HMAC", hmacSecret, message); return await crypto.subtle.sign("HMAC", hmacSecret, message);
}; };

View File

@@ -1,13 +1,5 @@
let rootHandle: FileSystemDirectoryHandle | null = null;
export const prepareOpfs = async () => {
rootHandle = await navigator.storage.getDirectory();
};
const getFileHandle = async (path: string, create = true) => { const getFileHandle = async (path: string, create = true) => {
if (!rootHandle) { if (path[0] !== "/") {
throw new Error("OPFS not prepared");
} else if (path[0] !== "/") {
throw new Error("Path must be absolute"); throw new Error("Path must be absolute");
} }
@@ -17,7 +9,7 @@ const getFileHandle = async (path: string, create = true) => {
} }
try { try {
let directoryHandle = rootHandle; let directoryHandle = await navigator.storage.getDirectory();
for (const part of parts.slice(0, -1)) { for (const part of parts.slice(0, -1)) {
if (!part) continue; if (!part) continue;
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create }); directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
@@ -34,12 +26,15 @@ const getFileHandle = async (path: string, create = true) => {
} }
}; };
export const readFile = async (path: string) => { export const getFile = async (path: string) => {
const { fileHandle } = await getFileHandle(path, false); const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) return null; if (!fileHandle) return null;
const file = await fileHandle.getFile(); return await fileHandle.getFile();
return await file.arrayBuffer(); };
export const readFile = async (path: string) => {
return (await getFile(path))?.arrayBuffer() ?? null;
}; };
export const writeFile = async (path: string, data: ArrayBuffer) => { export const writeFile = async (path: string, data: ArrayBuffer) => {
@@ -61,9 +56,7 @@ export const deleteFile = async (path: string) => {
}; };
const getDirectoryHandle = async (path: string) => { const getDirectoryHandle = async (path: string) => {
if (!rootHandle) { if (path[0] !== "/") {
throw new Error("OPFS not prepared");
} else if (path[0] !== "/") {
throw new Error("Path must be absolute"); throw new Error("Path must be absolute");
} }
@@ -73,7 +66,7 @@ const getDirectoryHandle = async (path: string) => {
} }
try { try {
let directoryHandle = rootHandle; let directoryHandle = await navigator.storage.getDirectory();
let parentHandle; let parentHandle;
for (const part of parts.slice(1)) { for (const part of parts.slice(1)) {
if (!part) continue; if (!part) continue;

View File

@@ -0,0 +1,39 @@
import { DECRYPTED_FILE_URL_PREFIX } from "$lib/constants";
import type { FileMetadata, ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
const PREPARE_TIMEOUT_MS = 5000;
const getServiceWorker = async () => {
const registration = await navigator.serviceWorker.ready;
const sw = registration.active;
if (!sw) {
throw new Error("Service worker not activated");
}
return sw;
};
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
const sw = await getServiceWorker();
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Service worker timeout")),
PREPARE_TIMEOUT_MS,
);
const handler = (event: MessageEvent<ServiceWorkerResponse>) => {
if (event.data.type === "decryption-ready" && event.data.fileId === id) {
clearTimeout(timeout);
navigator.serviceWorker.removeEventListener("message", handler);
resolve();
}
};
navigator.serviceWorker.addEventListener("message", handler);
sw.postMessage({
type: "decryption-prepare",
fileId: id,
...metadata,
} satisfies ServiceWorkerMessage);
});
};
export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`;

View File

@@ -0,0 +1,2 @@
export * from "./client";
export * from "./types";

View File

@@ -0,0 +1,19 @@
export interface FileMetadata {
isLegacy: boolean;
dataKey: CryptoKey;
encContentSize: number;
contentType: string;
}
export interface DecryptionPrepareMessage extends FileMetadata {
type: "decryption-prepare";
fileId: number;
}
export interface DecryptionReadyMessage {
type: "decryption-ready";
fileId: number;
}
export type ServiceWorkerMessage = DecryptionPrepareMessage;
export type ServiceWorkerResponse = DecryptionReadyMessage;

View File

@@ -17,6 +17,7 @@
requestFileDownload, requestFileDownload,
requestThumbnailUpload, requestThumbnailUpload,
requestFileAdditionToCategory, requestFileAdditionToCategory,
requestVideoStream,
} from "./service"; } from "./service";
import TopBarMenu from "./TopBarMenu.svelte"; import TopBarMenu from "./TopBarMenu.svelte";
@@ -37,6 +38,7 @@
let viewerType: "image" | "video" | undefined = $state(); let viewerType: "image" | "video" | undefined = $state();
let fileBlob: Blob | undefined = $state(); let fileBlob: Blob | undefined = $state();
let fileBlobUrl: string | undefined = $state(); let fileBlobUrl: string | undefined = $state();
let videoStreamUrl: string | undefined = $state();
let videoElement: HTMLVideoElement | undefined = $state(); let videoElement: HTMLVideoElement | undefined = $state();
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => { const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
@@ -95,12 +97,27 @@
untrack(() => { untrack(() => {
if (!downloadState && !isDownloadRequested) { if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true; isDownloadRequested = true;
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then(async (buffer) => {
const blob = await updateViewer(buffer, contentType); if (viewerType === "video" && !info!.isLegacy) {
if (!viewerType) { requestVideoStream(data.id, info!.dataKey!.key, contentType).then((streamUrl) => {
FileSaver.saveAs(blob, info!.name); if (streamUrl) {
} videoStreamUrl = streamUrl;
}); } else {
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then((buffer) =>
updateViewer(buffer, contentType),
);
}
});
} else {
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then(
async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, info!.name);
}
},
);
}
} }
}); });
} }
@@ -137,6 +154,7 @@
? info?.parentId ? info?.parentId
: undefined} : undefined}
{fileBlob} {fileBlob}
downloadUrl={videoStreamUrl}
filename={info?.name} filename={info?.name}
/> />
</div> </div>
@@ -159,9 +177,10 @@
{@render viewerLoading("이미지를 불러오고 있어요.")} {@render viewerLoading("이미지를 불러오고 있어요.")}
{/if} {/if}
{:else if viewerType === "video"} {:else if viewerType === "video"}
{#if fileBlobUrl} {#if videoStreamUrl || fileBlobUrl}
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video> <video bind:this={videoElement} src={videoStreamUrl ?? fileBlobUrl} controls muted
></video>
<IconEntryButton <IconEntryButton
icon={IconCamera} icon={IconCamera}
onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)} onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}

View File

@@ -10,17 +10,29 @@
interface Props { interface Props {
directoryId?: "root" | number; directoryId?: "root" | number;
downloadUrl?: string;
fileBlob?: Blob; fileBlob?: Blob;
filename?: string; filename?: string;
isOpen: boolean; isOpen: boolean;
} }
let { directoryId, fileBlob, filename, isOpen = $bindable() }: Props = $props(); let { directoryId, downloadUrl, fileBlob, filename, isOpen = $bindable() }: Props = $props();
const handleDownload = () => {
if (fileBlob && filename) {
FileSaver.saveAs(fileBlob, filename);
} else if (downloadUrl && filename) {
// Use streaming download via Content-Disposition header
const url = new URL(downloadUrl, window.location.origin);
url.searchParams.set("download", filename);
window.open(url.toString(), "_blank");
}
};
</script> </script>
<svelte:window onclick={() => (isOpen = false)} /> <svelte:window onclick={() => (isOpen = false)} />
{#if isOpen && (directoryId || fileBlob)} {#if isOpen && (directoryId || downloadUrl || fileBlob)}
<div <div
class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl" class="absolute right-2 top-full z-20 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
transition:fly={{ y: -8, duration: 200 }} transition:fly={{ y: -8, duration: 200 }}
@@ -49,10 +61,8 @@
), ),
)} )}
{/if} {/if}
{#if fileBlob} {#if fileBlob || downloadUrl}
{@render menuButton(IconCloudDownload, "다운로드", () => { {@render menuButton(IconCloudDownload, "다운로드", handleDownload)}
FileSaver.saveAs(fileBlob, filename);
})}
{/if} {/if}
</div> </div>
</div> </div>

View File

@@ -1,11 +1,32 @@
import { encryptData } from "$lib/modules/crypto"; import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file"; import { storeFileThumbnailCache } from "$lib/modules/file";
import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker";
import { requestFileThumbnailUpload } from "$lib/services/file"; import { requestFileThumbnailUpload } from "$lib/services/file";
import { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export { requestFileDownload } from "$lib/services/file"; export { requestFileDownload } from "$lib/services/file";
export const requestVideoStream = async (
fileId: number,
dataKey: CryptoKey,
contentType: string,
) => {
const res = await fetch(`/api/file/${fileId}/download`, { method: "HEAD" });
if (!res.ok) return null;
const encContentSize = parseInt(res.headers.get("Content-Length") ?? "0", 10);
if (encContentSize <= 0) return null;
try {
await prepareFileDecryption(fileId, { isLegacy: false, dataKey, encContentSize, contentType });
return getDecryptedFileUrl(fileId);
} catch {
// TODO: Error Handling
return null;
}
};
export const requestThumbnailUpload = async ( export const requestThumbnailUpload = async (
fileId: number, fileId: number,
thumbnail: Blob, thumbnail: Blob,

View File

@@ -1,7 +1,7 @@
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 { parseRangeHeader, getContentRangeHeader } from "$lib/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";

View File

@@ -1,7 +1,7 @@
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 { parseRangeHeader, getContentRangeHeader } from "$lib/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";

View File

@@ -0,0 +1,153 @@
import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../modules/constants";
import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto";
import { parseRangeHeader, getContentRangeHeader } from "../modules/http";
import { getFile } from "../modules/opfs";
import { fileMetadataStore } from "../stores";
import type { FileMetadata } from "../types";
const createResponse = (
stream: ReadableStream<Uint8Array>,
isRangeRequest: boolean,
range: { start: number; end: number; total: number },
contentType?: string,
downloadFilename?: string,
) => {
const headers: Record<string, string> = {
"Accept-Ranges": "bytes",
"Content-Length": String(range.end - range.start + 1),
"Content-Type": contentType ?? "application/octet-stream",
...(isRangeRequest ? getContentRangeHeader(range) : {}),
};
if (downloadFilename) {
headers["Content-Disposition"] =
`attachment; filename*=UTF-8''${encodeURIComponent(downloadFilename)}`;
}
return new Response(stream, {
status: isRangeRequest ? 206 : 200,
headers,
});
};
const streamFromOpfs = async (
file: File,
metadata?: FileMetadata,
range?: { start?: number; end?: number },
downloadFilename?: string,
) => {
const start = range?.start ?? 0;
const end = range?.end ?? file.size - 1;
if (start > end || start < 0 || end >= file.size) {
return new Response("Invalid range", { status: 416 });
}
return createResponse(
file.slice(start, end + 1).stream(),
!!range,
{ start, end, total: file.size },
metadata?.contentType,
downloadFilename,
);
};
const streamFromServer = async (
id: number,
metadata: FileMetadata,
range?: { start?: number; end?: number },
downloadFilename?: string,
) => {
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
const start = range?.start ?? 0;
const end =
range?.end ??
(range && !metadata.isLegacy ? Math.min(start + CHUNK_SIZE, totalSize) : totalSize) - 1;
if (start > end || start < 0 || end >= totalSize) {
return new Response("Invalid range", { status: 416 });
}
const encryptedRange = getEncryptedRange(start, end, metadata.encContentSize, metadata.isLegacy);
const apiResponse = await fetch(`/api/file/${id}/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();
},
}),
!!range,
{ start, end, total: totalSize },
metadata.contentType,
);
}
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),
!!range,
{ start, end, total: totalSize },
metadata.contentType,
downloadFilename,
);
};
const decryptFileHandler = async (request: Request) => {
const url = new URL(request.url);
const fileId = parseInt(url.pathname.slice(DECRYPTED_FILE_URL_PREFIX.length), 10);
if (isNaN(fileId)) {
throw new Response("Invalid file id", { status: 400 });
}
const downloadFilename = url.searchParams.get("download") ?? undefined;
const metadata = fileMetadataStore.get(fileId);
const range = parseRangeHeader(request.headers.get("Range"));
const cache = await getFile(`/cache/${fileId}`);
if (cache) {
return streamFromOpfs(cache, metadata, range, downloadFilename);
} else if (metadata) {
return streamFromServer(fileId, metadata, range, downloadFilename);
} else {
return new Response("Decryption not prepared", { status: 400 });
}
};
export default decryptFileHandler;

View File

@@ -0,0 +1 @@
export { default as decryptFile } from "./decryptFile";

View File

@@ -0,0 +1,43 @@
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" />
import { DECRYPTED_FILE_URL_PREFIX } from "./modules/constants";
import { decryptFile } from "./handlers";
import { fileMetadataStore } from "./stores";
import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
const self = globalThis.self as unknown as ServiceWorkerGlobalScope;
self.addEventListener("message", (event) => {
const message: ServiceWorkerMessage = event.data;
switch (message.type) {
case "decryption-prepare":
fileMetadataStore.set(message.fileId, message);
event.source?.postMessage({
type: "decryption-ready",
fileId: message.fileId,
} satisfies ServiceWorkerResponse);
break;
default: {
const exhaustive: never = message.type;
return exhaustive;
}
}
});
self.addEventListener("fetch", (event) => {
const url = new URL(event.request.url);
if (url.pathname.startsWith(DECRYPTED_FILE_URL_PREFIX)) {
event.respondWith(decryptFile(event.request));
}
});
self.addEventListener("install", () => {
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(self.clients.claim());
});

View File

@@ -0,0 +1 @@
export * from "../../lib/constants";

View File

@@ -0,0 +1,40 @@
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "./constants";
export * from "../../lib/modules/crypto";
export const getEncryptedRange = (
start: number,
end: number,
totalEncryptedSize: number,
isLegacy: boolean,
) => {
if (isLegacy) {
return {
firstChunkIndex: 0,
lastChunkIndex: 0,
start: 0,
end: totalEncryptedSize - 1,
};
}
const firstChunkIndex = Math.floor(start / CHUNK_SIZE);
const lastChunkIndex = Math.floor(end / CHUNK_SIZE);
return {
firstChunkIndex,
lastChunkIndex,
start: firstChunkIndex * ENCRYPTED_CHUNK_SIZE,
end: Math.min((lastChunkIndex + 1) * ENCRYPTED_CHUNK_SIZE - 1, totalEncryptedSize - 1),
};
};
export const getDecryptedSize = (encryptedSize: number, isLegacy: boolean) => {
if (isLegacy) {
return encryptedSize - ENCRYPTION_OVERHEAD;
}
const fullChunks = Math.floor(encryptedSize / ENCRYPTED_CHUNK_SIZE);
const lastChunkEncSize = encryptedSize % ENCRYPTED_CHUNK_SIZE;
return (
fullChunks * CHUNK_SIZE + (lastChunkEncSize > 0 ? lastChunkEncSize - ENCRYPTION_OVERHEAD : 0)
);
};

View File

@@ -0,0 +1 @@
export * from "../../lib/modules/http";

View File

@@ -0,0 +1 @@
export * from "../../lib/modules/opfs";

View File

@@ -0,0 +1,3 @@
import type { FileMetadata } from "./types";
export const fileMetadataStore = new Map<number, FileMetadata>();

View File

@@ -0,0 +1 @@
export * from "../lib/serviceWorker/types";