mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 16:16:55 +00:00
Compare commits
2 Commits
4b783a36e9
...
1efcdd68f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1efcdd68f1 | ||
|
|
0c295a2ffa |
@@ -1,7 +1,6 @@
|
||||
import type { ClientInit } from "@sveltejs/kit";
|
||||
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
|
||||
import { prepareFileCache } from "$lib/modules/file";
|
||||
import { prepareOpfs } from "$lib/modules/opfs";
|
||||
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||
|
||||
const requestPersistentStorage = async () => {
|
||||
@@ -46,7 +45,6 @@ export const init: ClientInit = async () => {
|
||||
prepareClientKeyStore(),
|
||||
prepareMasterKeyStore(),
|
||||
prepareHmacSecretStore(),
|
||||
prepareOpfs(),
|
||||
]);
|
||||
|
||||
cleanupDanglingInfos(); // Intended
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./serviceWorker";
|
||||
export * from "./upload";
|
||||
|
||||
1
src/lib/constants/serviceWorker.ts
Normal file
1
src/lib/constants/serviceWorker.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decrypted-file/";
|
||||
@@ -1,5 +1,6 @@
|
||||
export const CHUNK_SIZE = 4 * 1024 * 1024;
|
||||
|
||||
export const AES_GCM_IV_SIZE = 12;
|
||||
export const AES_GCM_TAG_SIZE = 16;
|
||||
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;
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
|
||||
export const generateMasterKey = async () => {
|
||||
return {
|
||||
masterKey: await window.crypto.subtle.generateKey(
|
||||
masterKey: await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-KW",
|
||||
length: 256,
|
||||
@@ -22,7 +22,7 @@ export const generateMasterKey = async () => {
|
||||
|
||||
export const generateDataKey = async () => {
|
||||
return {
|
||||
dataKey: await window.crypto.subtle.generateKey(
|
||||
dataKey: await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
length: 256,
|
||||
@@ -35,9 +35,9 @@ export const generateDataKey = async () => {
|
||||
};
|
||||
|
||||
export const makeAESKeyNonextractable = async (key: CryptoKey) => {
|
||||
return await window.crypto.subtle.importKey(
|
||||
return await crypto.subtle.importKey(
|
||||
"raw",
|
||||
await window.crypto.subtle.exportKey("raw", key),
|
||||
await crypto.subtle.exportKey("raw", key),
|
||||
key.algorithm,
|
||||
false,
|
||||
key.usages,
|
||||
@@ -45,12 +45,12 @@ export const makeAESKeyNonextractable = async (key: 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) => {
|
||||
return {
|
||||
dataKey: await window.crypto.subtle.unwrapKey(
|
||||
dataKey: await crypto.subtle.unwrapKey(
|
||||
"raw",
|
||||
decodeFromBase64(dataKeyWrapped),
|
||||
masterKey,
|
||||
@@ -63,12 +63,12 @@ export const unwrapDataKey = async (dataKeyWrapped: string, 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) => {
|
||||
return {
|
||||
hmacSecret: await window.crypto.subtle.unwrapKey(
|
||||
hmacSecret: await crypto.subtle.unwrapKey(
|
||||
"raw",
|
||||
decodeFromBase64(hmacSecretWrapped),
|
||||
masterKey,
|
||||
@@ -84,8 +84,8 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
|
||||
};
|
||||
|
||||
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv,
|
||||
@@ -101,7 +101,7 @@ export const decryptData = async (
|
||||
iv: string | BufferSource,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
return await window.crypto.subtle.decrypt(
|
||||
return await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "AES-GCM",
|
||||
iv: typeof iv === "string" ? decodeFromBase64(iv) : iv,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { encodeString, encodeToBase64, decodeFromBase64 } from "./util";
|
||||
|
||||
export const generateEncryptionKeyPair = async () => {
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
modulusLength: 4096,
|
||||
@@ -18,7 +18,7 @@ export const generateEncryptionKeyPair = async () => {
|
||||
};
|
||||
|
||||
export const generateSigningKeyPair = async () => {
|
||||
const keyPair = await window.crypto.subtle.generateKey(
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "RSA-PSS",
|
||||
modulusLength: 4096,
|
||||
@@ -37,7 +37,7 @@ export const generateSigningKeyPair = async () => {
|
||||
export const exportRSAKey = async (key: CryptoKey) => {
|
||||
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
|
||||
return {
|
||||
key: await window.crypto.subtle.exportKey(format, key),
|
||||
key: await crypto.subtle.exportKey(format, key),
|
||||
format,
|
||||
};
|
||||
};
|
||||
@@ -54,14 +54,14 @@ export const importEncryptionKeyPairFromBase64 = async (
|
||||
name: "RSA-OAEP",
|
||||
hash: "SHA-256",
|
||||
};
|
||||
const encryptKey = await window.crypto.subtle.importKey(
|
||||
const encryptKey = await crypto.subtle.importKey(
|
||||
"spki",
|
||||
decodeFromBase64(encryptKeyBase64),
|
||||
algorithm,
|
||||
true,
|
||||
["encrypt", "wrapKey"],
|
||||
);
|
||||
const decryptKey = await window.crypto.subtle.importKey(
|
||||
const decryptKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
decodeFromBase64(decryptKeyBase64),
|
||||
algorithm,
|
||||
@@ -79,14 +79,14 @@ export const importSigningKeyPairFromBase64 = async (
|
||||
name: "RSA-PSS",
|
||||
hash: "SHA-256",
|
||||
};
|
||||
const signKey = await window.crypto.subtle.importKey(
|
||||
const signKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
decodeFromBase64(signKeyBase64),
|
||||
algorithm,
|
||||
true,
|
||||
["sign"],
|
||||
);
|
||||
const verifyKey = await window.crypto.subtle.importKey(
|
||||
const verifyKey = await crypto.subtle.importKey(
|
||||
"spki",
|
||||
decodeFromBase64(verifyKeyBase64),
|
||||
algorithm,
|
||||
@@ -98,17 +98,11 @@ export const importSigningKeyPairFromBase64 = async (
|
||||
|
||||
export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
|
||||
const { key: exportedKey, format } = await exportRSAKey(key);
|
||||
return await window.crypto.subtle.importKey(
|
||||
format,
|
||||
exportedKey,
|
||||
key.algorithm,
|
||||
false,
|
||||
key.usages,
|
||||
);
|
||||
return await crypto.subtle.importKey(format, exportedKey, key.algorithm, false, key.usages);
|
||||
};
|
||||
|
||||
export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => {
|
||||
return await window.crypto.subtle.decrypt(
|
||||
return await crypto.subtle.decrypt(
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
} satisfies RsaOaepParams,
|
||||
@@ -119,7 +113,7 @@ export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey)
|
||||
|
||||
export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => {
|
||||
return encodeToBase64(
|
||||
await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
|
||||
await crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
|
||||
name: "RSA-OAEP",
|
||||
} satisfies RsaOaepParams),
|
||||
);
|
||||
@@ -131,7 +125,7 @@ export const unwrapMasterKey = async (
|
||||
extractable = false,
|
||||
) => {
|
||||
return {
|
||||
masterKey: await window.crypto.subtle.unwrapKey(
|
||||
masterKey: await crypto.subtle.unwrapKey(
|
||||
"raw",
|
||||
decodeFromBase64(masterKeyWrapped),
|
||||
decryptKey,
|
||||
@@ -146,7 +140,7 @@ export const unwrapMasterKey = async (
|
||||
};
|
||||
|
||||
export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
|
||||
return await window.crypto.subtle.sign(
|
||||
return await crypto.subtle.sign(
|
||||
{
|
||||
name: "RSA-PSS",
|
||||
saltLength: 32, // SHA-256
|
||||
@@ -161,7 +155,7 @@ export const verifySignatureRSA = async (
|
||||
signature: BufferSource,
|
||||
verifyKey: CryptoKey,
|
||||
) => {
|
||||
return await window.crypto.subtle.verify(
|
||||
return await crypto.subtle.verify(
|
||||
{
|
||||
name: "RSA-PSS",
|
||||
saltLength: 32, // SHA-256
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 () => {
|
||||
return {
|
||||
hmacSecret: await window.crypto.subtle.generateKey(
|
||||
hmacSecret: await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-256",
|
||||
@@ -16,5 +16,5 @@ export const generateHmacSecret = async () => {
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
if (!rootHandle) {
|
||||
throw new Error("OPFS not prepared");
|
||||
} else if (path[0] !== "/") {
|
||||
if (path[0] !== "/") {
|
||||
throw new Error("Path must be absolute");
|
||||
}
|
||||
|
||||
@@ -17,7 +9,7 @@ const getFileHandle = async (path: string, create = true) => {
|
||||
}
|
||||
|
||||
try {
|
||||
let directoryHandle = rootHandle;
|
||||
let directoryHandle = await navigator.storage.getDirectory();
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!part) continue;
|
||||
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);
|
||||
if (!fileHandle) return null;
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
return await file.arrayBuffer();
|
||||
return await fileHandle.getFile();
|
||||
};
|
||||
|
||||
export const readFile = async (path: string) => {
|
||||
return (await getFile(path))?.arrayBuffer() ?? null;
|
||||
};
|
||||
|
||||
export const writeFile = async (path: string, data: ArrayBuffer) => {
|
||||
@@ -61,9 +56,7 @@ export const deleteFile = async (path: string) => {
|
||||
};
|
||||
|
||||
const getDirectoryHandle = async (path: string) => {
|
||||
if (!rootHandle) {
|
||||
throw new Error("OPFS not prepared");
|
||||
} else if (path[0] !== "/") {
|
||||
if (path[0] !== "/") {
|
||||
throw new Error("Path must be absolute");
|
||||
}
|
||||
|
||||
@@ -73,7 +66,7 @@ const getDirectoryHandle = async (path: string) => {
|
||||
}
|
||||
|
||||
try {
|
||||
let directoryHandle = rootHandle;
|
||||
let directoryHandle = await navigator.storage.getDirectory();
|
||||
let parentHandle;
|
||||
for (const part of parts.slice(1)) {
|
||||
if (!part) continue;
|
||||
|
||||
39
src/lib/serviceWorker/client.ts
Normal file
39
src/lib/serviceWorker/client.ts
Normal 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}`;
|
||||
2
src/lib/serviceWorker/index.ts
Normal file
2
src/lib/serviceWorker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./client";
|
||||
export * from "./types";
|
||||
19
src/lib/serviceWorker/types.ts
Normal file
19
src/lib/serviceWorker/types.ts
Normal 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;
|
||||
@@ -17,6 +17,7 @@
|
||||
requestFileDownload,
|
||||
requestThumbnailUpload,
|
||||
requestFileAdditionToCategory,
|
||||
requestVideoStream,
|
||||
} from "./service";
|
||||
import TopBarMenu from "./TopBarMenu.svelte";
|
||||
|
||||
@@ -37,6 +38,7 @@
|
||||
let viewerType: "image" | "video" | undefined = $state();
|
||||
let fileBlob: Blob | undefined = $state();
|
||||
let fileBlobUrl: string | undefined = $state();
|
||||
let videoStreamUrl: string | undefined = $state();
|
||||
let videoElement: HTMLVideoElement | undefined = $state();
|
||||
|
||||
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
|
||||
@@ -95,12 +97,27 @@
|
||||
untrack(() => {
|
||||
if (!downloadState && !isDownloadRequested) {
|
||||
isDownloadRequested = true;
|
||||
requestFileDownload(data.id, info!.dataKey!.key, info!.isLegacy!).then(async (buffer) => {
|
||||
const blob = await updateViewer(buffer, contentType);
|
||||
if (!viewerType) {
|
||||
FileSaver.saveAs(blob, info!.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (viewerType === "video" && !info!.isLegacy) {
|
||||
requestVideoStream(data.id, info!.dataKey!.key, contentType).then((streamUrl) => {
|
||||
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
|
||||
: undefined}
|
||||
{fileBlob}
|
||||
downloadUrl={videoStreamUrl}
|
||||
filename={info?.name}
|
||||
/>
|
||||
</div>
|
||||
@@ -159,9 +177,10 @@
|
||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||
{/if}
|
||||
{:else if viewerType === "video"}
|
||||
{#if fileBlobUrl}
|
||||
{#if videoStreamUrl || fileBlobUrl}
|
||||
<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
|
||||
icon={IconCamera}
|
||||
onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
|
||||
|
||||
@@ -10,17 +10,29 @@
|
||||
|
||||
interface Props {
|
||||
directoryId?: "root" | number;
|
||||
downloadUrl?: string;
|
||||
fileBlob?: Blob;
|
||||
filename?: string;
|
||||
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>
|
||||
|
||||
<svelte:window onclick={() => (isOpen = false)} />
|
||||
|
||||
{#if isOpen && (directoryId || fileBlob)}
|
||||
{#if isOpen && (directoryId || downloadUrl || fileBlob)}
|
||||
<div
|
||||
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 }}
|
||||
@@ -49,10 +61,8 @@
|
||||
),
|
||||
)}
|
||||
{/if}
|
||||
{#if fileBlob}
|
||||
{@render menuButton(IconCloudDownload, "다운로드", () => {
|
||||
FileSaver.saveAs(fileBlob, filename);
|
||||
})}
|
||||
{#if fileBlob || downloadUrl}
|
||||
{@render menuButton(IconCloudDownload, "다운로드", handleDownload)}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,32 @@
|
||||
import { encryptData } from "$lib/modules/crypto";
|
||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||
import { prepareFileDecryption, getDecryptedFileUrl } from "$lib/serviceWorker";
|
||||
import { requestFileThumbnailUpload } from "$lib/services/file";
|
||||
import { trpc } from "$trpc/client";
|
||||
|
||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
|
||||
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 (
|
||||
fileId: number,
|
||||
thumbnail: Blob,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
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 type { RequestHandler } from "./$types";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
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 type { RequestHandler } from "./$types";
|
||||
|
||||
|
||||
153
src/service-worker/handlers/decryptFile.ts
Normal file
153
src/service-worker/handlers/decryptFile.ts
Normal 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;
|
||||
1
src/service-worker/handlers/index.ts
Normal file
1
src/service-worker/handlers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as decryptFile } from "./decryptFile";
|
||||
43
src/service-worker/index.ts
Normal file
43
src/service-worker/index.ts
Normal 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());
|
||||
});
|
||||
1
src/service-worker/modules/constants.ts
Normal file
1
src/service-worker/modules/constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../lib/constants";
|
||||
40
src/service-worker/modules/crypto.ts
Normal file
40
src/service-worker/modules/crypto.ts
Normal 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)
|
||||
);
|
||||
};
|
||||
1
src/service-worker/modules/http.ts
Normal file
1
src/service-worker/modules/http.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../lib/modules/http";
|
||||
1
src/service-worker/modules/opfs.ts
Normal file
1
src/service-worker/modules/opfs.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../../lib/modules/opfs";
|
||||
3
src/service-worker/stores.ts
Normal file
3
src/service-worker/stores.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { FileMetadata } from "./types";
|
||||
|
||||
export const fileMetadataStore = new Map<number, FileMetadata>();
|
||||
1
src/service-worker/types.ts
Normal file
1
src/service-worker/types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "../lib/serviceWorker/types";
|
||||
Reference in New Issue
Block a user