mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 08:06:56 +00:00
Compare commits
2 Commits
4b783a36e9
...
1efcdd68f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1efcdd68f1 | ||
|
|
0c295a2ffa |
@@ -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
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from "./serviceWorker";
|
||||||
export * from "./upload";
|
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_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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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,
|
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!)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
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