mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-11-04 08:10:28 +09:00
서버, 클라이언트, 서비스 워커 공용 코드를 lib/shared로 분리하고 브라우저 전용 모듈을 lib/client로 재구성
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a6aa1765b
commit
aee87fd9b6
82 files changed
+326
-221
No files matched your search
+3
-3
@@ -1,12 +1,12 @@
|
||||
import type { ClientInit } from "@sveltejs/kit/hooks";
|
||||
import { prepareFileCache } from "#lib/client/cache/index.js";
|
||||
import { listenForMetadataRequests } from "#lib/client/serviceWorker.js";
|
||||
import {
|
||||
cleanupDanglingInfos,
|
||||
getClientKey,
|
||||
getMasterKeys,
|
||||
getHmacSecrets,
|
||||
} from "#lib/indexedDB/index.js";
|
||||
import { prepareFileCache } from "#lib/modules/file/index.js";
|
||||
import { listenForMetadataRequests } from "#lib/serviceWorker/index.js";
|
||||
} from "#lib/client/storage/indexedDB/index.js";
|
||||
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "#lib/stores/index.js";
|
||||
|
||||
const requestPersistentStorage = async () => {
|
||||
|
||||
@@ -3,8 +3,8 @@ import {
|
||||
storeFileCacheIndex,
|
||||
deleteFileCacheIndex,
|
||||
type FileCacheIndex,
|
||||
} from "#lib/indexedDB/index.js";
|
||||
import { readFile, writeFile, deleteFile } from "#lib/modules/opfs.js";
|
||||
} from "#lib/client/storage/indexedDB/index.js";
|
||||
import { readFile, writeFile, deleteFile } from "#lib/client/storage/opfs.js";
|
||||
|
||||
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
export * from "./fileCache";
|
||||
export * from "./thumbnailCache";
|
||||
@@ -1,9 +1,9 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { decryptChunk } from "#lib/modules/crypto/index.js";
|
||||
import { getThumbnailUrl } from "#lib/client/media/thumbnail.js";
|
||||
import { readFile, writeFile, deleteFile, deleteDirectory } from "#lib/client/storage/opfs.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { readFile, writeFile, deleteFile, deleteDirectory } from "#lib/modules/opfs.js";
|
||||
import { getThumbnailUrl } from "#lib/modules/thumbnail.js";
|
||||
import { decryptChunk } from "#lib/shared/crypto/index.js";
|
||||
|
||||
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
|
||||
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
|
||||
@@ -1,23 +1,7 @@
|
||||
import HmacWorker from "#workers/hmac.js?worker";
|
||||
import type { ComputeMessage, ResultMessage } from "#workers/hmac.js";
|
||||
import HmacWorker from "#workers/hmac.js?worker";
|
||||
|
||||
export const digestMessage = async (message: BufferSource) => {
|
||||
return await crypto.subtle.digest("SHA-256", message);
|
||||
};
|
||||
|
||||
export const generateHmacSecret = async () => {
|
||||
return {
|
||||
hmacSecret: await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-256",
|
||||
} satisfies HmacKeyGenParams,
|
||||
true,
|
||||
["sign", "verify"],
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// Computes HMAC of large blobs incrementally in a worker, which WebCrypto doesn't support
|
||||
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
|
||||
const stream = message.stream();
|
||||
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
|
||||
@@ -0,0 +1,27 @@
|
||||
import ExifReader from "exifreader";
|
||||
|
||||
export const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||
const exif = ExifReader.load(fileBuffer);
|
||||
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
||||
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
||||
if (!dateTimeOriginal) return undefined;
|
||||
|
||||
const [date, time] = dateTimeOriginal.split(" ");
|
||||
if (!date || !time) return undefined;
|
||||
|
||||
const [year, month, day] = date.split(":").map(Number);
|
||||
const [hour, minute, second] = time.split(":").map(Number);
|
||||
if (!year || !month || !day || !hour || !minute || !second) return undefined;
|
||||
|
||||
if (!offsetTimeOriginal) {
|
||||
// No timezone information.. Assume local timezone
|
||||
return new Date(year, month - 1, day, hour, minute, second);
|
||||
}
|
||||
|
||||
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
||||
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
||||
|
||||
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
||||
return new Date(utcDate - offsetMs);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { encodeToBase64 } from "#lib/modules/crypto/index.js";
|
||||
import { encodeToBase64 } from "#lib/shared/crypto/index.js";
|
||||
|
||||
const scaleSize = (width: number, height: number, targetSize: number) => {
|
||||
if (width <= targetSize || height <= targetSize) {
|
||||
@@ -1,5 +1,9 @@
|
||||
import { DECRYPTED_FILE_URL_PREFIX } from "#lib/constants/index.js";
|
||||
import type { FileMetadata, ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
|
||||
import { DECRYPTED_FILE_URL_PREFIX } from "#lib/shared/serviceWorker.js";
|
||||
import type {
|
||||
FileMetadata,
|
||||
ServiceWorkerMessage,
|
||||
ServiceWorkerResponse,
|
||||
} from "#lib/shared/serviceWorker.js";
|
||||
|
||||
const PREPARE_TIMEOUT_MS = 5000;
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,9 +1,9 @@
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import axios from "axios";
|
||||
import pLimit from "p-limit";
|
||||
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "#lib/constants/index.js";
|
||||
import { encodeToBase64, encryptChunk } from "#lib/modules/crypto/index.js";
|
||||
import { BoundedQueue } from "#lib/utils/index.js";
|
||||
import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.js";
|
||||
import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.js";
|
||||
import { BoundedQueue } from "#lib/utils/concurrency/index.js";
|
||||
|
||||
interface UploadStats {
|
||||
progress: number;
|
||||
@@ -98,8 +98,8 @@ export const uploadBlob = async (
|
||||
const networkConcurrency = options?.concurrency ?? 4;
|
||||
const maxQueueSize = 8;
|
||||
|
||||
const totalChunks = Math.ceil(blob.size / CHUNK_SIZE);
|
||||
const totalBytes = blob.size + totalChunks * ENCRYPTION_OVERHEAD;
|
||||
const totalChunks = getChunkCount(blob.size);
|
||||
const totalBytes = getEncryptedSize(blob.size);
|
||||
|
||||
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
|
||||
const speedMeter = createSpeedMeter(3000, 200);
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import axios from "axios";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js";
|
||||
import { decryptChunk, concatenateBuffers } from "#lib/modules/crypto/index.js";
|
||||
import { decryptChunk, concatenateBuffers } from "#lib/shared/crypto/index.js";
|
||||
import { ENCRYPTED_CHUNK_SIZE } from "#lib/shared/format/index.js";
|
||||
|
||||
export interface FileDownloadState {
|
||||
id: number;
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from "./cache";
|
||||
export * from "./download.svelte";
|
||||
export * from "./thumbnail";
|
||||
export * from "./upload.svelte";
|
||||
@@ -1,15 +1,15 @@
|
||||
import ExifReader from "exifreader";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { CHUNK_SIZE } from "#lib/constants/index.js";
|
||||
import { signMessageHmac } from "#lib/client/crypto/hmac.js";
|
||||
import { extractExifDateTime } from "#lib/client/media/exif.js";
|
||||
import { generateThumbnail } from "#lib/client/media/thumbnail.js";
|
||||
import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js";
|
||||
import {
|
||||
encodeToBase64,
|
||||
generateDataKey,
|
||||
wrapDataKey,
|
||||
encryptString,
|
||||
} from "#lib/modules/crypto/index.js";
|
||||
import { signMessageHmac } from "#lib/modules/crypto/index.js";
|
||||
import { generateThumbnail } from "#lib/modules/thumbnail.js";
|
||||
import { uploadBlob } from "#lib/modules/upload.js";
|
||||
} from "#lib/shared/crypto/index.js";
|
||||
import { getChunkCount } from "#lib/shared/format/index.js";
|
||||
import type { MasterKey, HmacSecret } from "#lib/stores/index.js";
|
||||
import { Scheduler } from "#lib/utils/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
@@ -84,32 +84,6 @@ const getFileType = (file: File) => {
|
||||
throw new Error("Unknown file type");
|
||||
};
|
||||
|
||||
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||
const exif = ExifReader.load(fileBuffer);
|
||||
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
||||
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
||||
if (!dateTimeOriginal) return undefined;
|
||||
|
||||
const [date, time] = dateTimeOriginal.split(" ");
|
||||
if (!date || !time) return undefined;
|
||||
|
||||
const [year, month, day] = date.split(":").map(Number);
|
||||
const [hour, minute, second] = time.split(":").map(Number);
|
||||
if (!year || !month || !day || !hour || !minute || !second) return undefined;
|
||||
|
||||
if (!offsetTimeOriginal) {
|
||||
// No timezone information.. Assume local timezone
|
||||
return new Date(year, month - 1, day, hour, minute, second);
|
||||
}
|
||||
|
||||
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
||||
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
||||
|
||||
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
||||
return new Date(utcDate - offsetMs);
|
||||
};
|
||||
|
||||
interface FileMetadata {
|
||||
parentId: "root" | number;
|
||||
name: string;
|
||||
@@ -140,7 +114,7 @@ const requestFileMetadataEncryption = limitFunction(
|
||||
]);
|
||||
|
||||
const { uploadId } = await trpc.upload.startFileUpload.mutate({
|
||||
chunks: Math.ceil(file.size / CHUNK_SIZE),
|
||||
chunks: getChunkCount(file.size),
|
||||
parent: fileMetadata.parentId,
|
||||
mekVersion: masterKey.version,
|
||||
dek: dataKeyWrapped,
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getFileThumbnail } from "#lib/modules/file/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
|
||||
import IconFavorite from "~icons/material-symbols/favorite";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./serviceWorker";
|
||||
export * from "./upload";
|
||||
@@ -1 +0,0 @@
|
||||
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decryptedFile/";
|
||||
@@ -1,6 +0,0 @@
|
||||
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; // 4 MiB
|
||||
export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD;
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as IndexedDB from "#lib/indexedDB/index.js";
|
||||
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
|
||||
import type { CategoryInfo, MaybeCategoryInfo } from "./types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as IndexedDB from "#lib/indexedDB/index.js";
|
||||
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
|
||||
import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as IndexedDB from "#lib/indexedDB/index.js";
|
||||
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
|
||||
import type { FileInfo, MaybeFileInfo } from "./types";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { untrack } from "svelte";
|
||||
import { unwrapDataKey, decryptString } from "#lib/modules/crypto/index.js";
|
||||
import { unwrapDataKey, decryptString } from "#lib/shared/crypto/index.js";
|
||||
|
||||
interface FilesystemCacheOptions<K, V> {
|
||||
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export const parseRangeHeader = (value: string | null) => {
|
||||
if (!value) return undefined;
|
||||
|
||||
const firstRange = value.split(",")[0]!.trim();
|
||||
const parts = firstRange.replace(/bytes=/, "").split("-");
|
||||
return {
|
||||
start: parts[0] ? parseInt(parts[0], 10) : undefined,
|
||||
end: parts[1] ? parseInt(parts[1], 10) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => {
|
||||
return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { storeClientKey } from "#lib/indexedDB/index.js";
|
||||
import { storeClientKey } from "#lib/client/storage/indexedDB/index.js";
|
||||
import type { ClientKeys } from "#lib/stores/index.js";
|
||||
|
||||
const SerializedClientKeysSchema = z.intersection(
|
||||
|
||||
@@ -2,10 +2,9 @@ import { FileRepo, MediaRepo } from "#lib/server/db/index.js";
|
||||
import type { Ciphertext } from "#lib/server/db/schema/index.js";
|
||||
import { AppError } from "#lib/server/errors.js";
|
||||
import { createEncContentStream } from "#lib/server/storage/index.js";
|
||||
import type { RangeRequest } from "#lib/shared/http.js";
|
||||
import { schedulePendingFileDeletions } from "./fileDeletion";
|
||||
|
||||
type Range = { start?: number; end?: number };
|
||||
|
||||
export const getFile = async (userId: number, fileId: number) => {
|
||||
const file = await FileRepo.getFile(userId, fileId);
|
||||
if (!file) {
|
||||
@@ -68,7 +67,7 @@ export const getFileThumbnail = async (userId: number, fileId: number) => {
|
||||
|
||||
const decodeIv = (iv: string | null) => (iv ? Buffer.from(iv, "base64") : undefined);
|
||||
|
||||
export const getFileStream = async (userId: number, fileId: number, range?: Range) => {
|
||||
export const getFileStream = async (userId: number, fileId: number, range?: RangeRequest) => {
|
||||
const file = await FileRepo.getFile(userId, fileId);
|
||||
if (!file) {
|
||||
throw new AppError("NOT_FOUND", "File not found");
|
||||
@@ -76,7 +75,11 @@ export const getFileStream = async (userId: number, fileId: number, range?: Rang
|
||||
return await createEncContentStream(file.path, decodeIv(file.encContentIv), range);
|
||||
};
|
||||
|
||||
export const getFileThumbnailStream = async (userId: number, fileId: number, range?: Range) => {
|
||||
export const getFileThumbnailStream = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
range?: RangeRequest,
|
||||
) => {
|
||||
const thumbnail = await getFileThumbnail(userId, fileId);
|
||||
return await createEncContentStream(thumbnail.path, decodeIv(thumbnail.encContentIv), range);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { open } from "fs/promises";
|
||||
import type { Readable } from "stream";
|
||||
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js";
|
||||
import { AppError } from "#lib/server/errors.js";
|
||||
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "#lib/shared/format/index.js";
|
||||
|
||||
const invalidChunkSize = () => new AppError("BAD_REQUEST", "Invalid chunk size");
|
||||
|
||||
|
||||
@@ -2,22 +2,22 @@ import { createReadStream } from "fs";
|
||||
import { stat } from "fs/promises";
|
||||
import { Readable } from "stream";
|
||||
import { AppError } from "#lib/server/errors.js";
|
||||
import { resolveRange, type RangeRequest } from "#lib/shared/http.js";
|
||||
|
||||
export const createEncContentStream = async (
|
||||
path: string,
|
||||
iv?: Buffer,
|
||||
range?: { start?: number; end?: number },
|
||||
rangeRequest?: RangeRequest,
|
||||
) => {
|
||||
const { size: fileSize } = await stat(path);
|
||||
const ivSize = iv?.byteLength ?? 0;
|
||||
const totalSize = fileSize + ivSize;
|
||||
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? totalSize - 1;
|
||||
if (start > end || start < 0 || end >= totalSize) {
|
||||
const range = resolveRange(rangeRequest, fileSize + ivSize);
|
||||
if (!range) {
|
||||
throw new AppError("RANGE_NOT_SATISFIABLE", "Invalid range");
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
return {
|
||||
encContentStream: Readable.toWeb(
|
||||
Readable.from(
|
||||
@@ -34,7 +34,7 @@ export const createEncContentStream = async (
|
||||
})(),
|
||||
),
|
||||
),
|
||||
range: { start, end, total: totalSize },
|
||||
isRangeRequest: !!range,
|
||||
range,
|
||||
isRangeRequest: !!rangeRequest,
|
||||
};
|
||||
};
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./client";
|
||||
export * from "./types";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { encodeToBase64, decryptChallenge, signMessageRSA } from "#lib/modules/crypto/index.js";
|
||||
import { encodeToBase64, decryptChallenge, signMessageRSA } from "#lib/shared/crypto/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
|
||||
export const requestSessionUpgrade = async (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { generateDataKey, wrapDataKey, encryptString } from "#lib/modules/crypto/index.js";
|
||||
import { generateDataKey, wrapDataKey, encryptString } from "#lib/shared/crypto/index.js";
|
||||
import type { MasterKey } from "#lib/stores/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getAllFileInfos } from "#lib/indexedDB/filesystem.js";
|
||||
import {
|
||||
getFileCache,
|
||||
storeFileCache,
|
||||
deleteFileCache,
|
||||
downloadFile,
|
||||
deleteFileThumbnailCache,
|
||||
} from "#lib/modules/file/index.js";
|
||||
import { uploadBlob } from "#lib/modules/upload.js";
|
||||
} from "#lib/client/cache/index.js";
|
||||
import { getAllFileInfos } from "#lib/client/storage/indexedDB/filesystem.js";
|
||||
import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js";
|
||||
import { downloadFile } from "#lib/client/transfer/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export const requestFileDownload = async (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { storeMasterKeys } from "#lib/indexedDB/index.js";
|
||||
import { storeMasterKeys } from "#lib/client/storage/indexedDB/index.js";
|
||||
import { requestSessionUpgrade } from "#lib/services/auth.js";
|
||||
import {
|
||||
encodeToBase64,
|
||||
exportRSAKeyToBase64,
|
||||
@@ -7,8 +8,7 @@ import {
|
||||
unwrapMasterKey,
|
||||
signMasterKeyWrapped,
|
||||
verifyMasterKeyWrapped,
|
||||
} from "#lib/modules/crypto/index.js";
|
||||
import { requestSessionUpgrade } from "#lib/services/auth.js";
|
||||
} from "#lib/shared/crypto/index.js";
|
||||
import { masterKeyStore, type ClientKeys } from "#lib/stores/index.js";
|
||||
import { trpc, isTRPCClientError } from "#trpc/client.js";
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { AES_GCM_IV_SIZE } from "#lib/constants/index.js";
|
||||
import { AES_GCM_IV_SIZE } from "#lib/shared/format/index.js";
|
||||
import {
|
||||
encodeString,
|
||||
decodeString,
|
||||
encodeToBase64,
|
||||
decodeFromBase64,
|
||||
concatenateBuffers,
|
||||
} from "./utils";
|
||||
} from "./encoding";
|
||||
|
||||
export const generateMasterKey = async () => {
|
||||
return {
|
||||
File renamed without changes.
@@ -1,4 +1,4 @@
|
||||
export * from "./aes";
|
||||
export * from "./encoding";
|
||||
export * from "./rsa";
|
||||
export * from "./sha";
|
||||
export * from "./utils";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { encodeString, encodeToBase64, decodeFromBase64 } from "./utils";
|
||||
import { encodeString, encodeToBase64, decodeFromBase64 } from "./encoding";
|
||||
|
||||
export const generateEncryptionKeyPair = async () => {
|
||||
const keyPair = await crypto.subtle.generateKey(
|
||||
@@ -0,0 +1,16 @@
|
||||
export const digestMessage = async (message: BufferSource) => {
|
||||
return await crypto.subtle.digest("SHA-256", message);
|
||||
};
|
||||
|
||||
export const generateHmacSecret = async () => {
|
||||
return {
|
||||
hmacSecret: await crypto.subtle.generateKey(
|
||||
{
|
||||
name: "HMAC",
|
||||
hash: "SHA-256",
|
||||
} satisfies HmacKeyGenParams,
|
||||
true,
|
||||
["sign", "verify"],
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -1,7 +1,34 @@
|
||||
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants";
|
||||
// Files are encrypted in chunks of `CHUNK_SIZE` bytes, each of which is stored as
|
||||
// `IV || ciphertext || tag`. Legacy files are encrypted as a single chunk.
|
||||
|
||||
export * from "../../lib/modules/crypto";
|
||||
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; // 4 MiB
|
||||
export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD;
|
||||
|
||||
export const getChunkCount = (size: number) => {
|
||||
return Math.ceil(size / CHUNK_SIZE);
|
||||
};
|
||||
|
||||
export const getEncryptedSize = (size: number) => {
|
||||
return size + getChunkCount(size) * ENCRYPTION_OVERHEAD;
|
||||
};
|
||||
|
||||
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)
|
||||
);
|
||||
};
|
||||
|
||||
// Maps a range of the plaintext to the range of the encrypted chunks that contain it
|
||||
export const getEncryptedRange = (
|
||||
start: number,
|
||||
end: number,
|
||||
@@ -26,15 +53,3 @@ export const getEncryptedRange = (
|
||||
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)
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./chunk";
|
||||
@@ -0,0 +1,35 @@
|
||||
export type RangeRequest = { start: number; end?: number } | { suffixLength: number };
|
||||
|
||||
export interface ByteRange {
|
||||
start: number;
|
||||
end: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Only the first range is used if multiple ranges are requested
|
||||
export const parseRangeHeader = (value: string | null): RangeRequest | undefined => {
|
||||
const match = value?.match(/^bytes=\s*(\d*)-(\d*)/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const [, start, end] = match;
|
||||
if (start) {
|
||||
return { start: parseInt(start, 10), end: end ? parseInt(end, 10) : undefined };
|
||||
} else if (end) {
|
||||
return { suffixLength: parseInt(end, 10) };
|
||||
}
|
||||
};
|
||||
|
||||
// Returns `null` if the range is not satisfiable
|
||||
export const resolveRange = (range: RangeRequest | undefined, total: number): ByteRange | null => {
|
||||
if (!range) {
|
||||
return { start: 0, end: total - 1, total };
|
||||
}
|
||||
|
||||
const start = "suffixLength" in range ? Math.max(0, total - range.suffixLength) : range.start;
|
||||
const end = "suffixLength" in range ? total - 1 : Math.min(range.end ?? total - 1, total - 1);
|
||||
return start <= end ? { start, end, total } : null;
|
||||
};
|
||||
|
||||
export const getContentRangeHeader = (range: ByteRange) => {
|
||||
return { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
|
||||
};
|
||||
@@ -1,3 +1,5 @@
|
||||
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decryptedFile/";
|
||||
|
||||
export interface FileMetadata {
|
||||
isLegacy: boolean;
|
||||
dataKey: CryptoKey;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { concatenateBuffers, exportRSAKey, digestMessage } from "#lib/modules/crypto/index.js";
|
||||
import { concatenateBuffers, exportRSAKey, digestMessage } from "#lib/shared/crypto/index.js";
|
||||
|
||||
export { requestMasterKeyDownload } from "#lib/services/key.js";
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
import { FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { Categories, IconEntryButton, TopBar } from "#lib/components/molecules/index.js";
|
||||
import { getFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { captureVideoThumbnail } from "#lib/modules/thumbnail.js";
|
||||
import { getFileDownloadState } from "#lib/modules/file/index.js";
|
||||
import { captureVideoThumbnail } from "#lib/client/media/thumbnail.js";
|
||||
import { getFileDownloadState } from "#lib/client/transfer/index.js";
|
||||
import { masterKeyStore } from "#lib/stores/index.js";
|
||||
import { HybridPromise } from "#lib/utils/index.js";
|
||||
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { isFileDownloading, type FileDownloadState } from "#lib/modules/file/index.js";
|
||||
import { isFileDownloading, type FileDownloadState } from "#lib/client/transfer/index.js";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.js";
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { storeFileThumbnailCache } from "#lib/modules/file/index.js";
|
||||
import { prepareFileDecryption, getDecryptedFileUrl } from "#lib/serviceWorker/index.js";
|
||||
import { storeFileThumbnailCache } from "#lib/client/cache/index.js";
|
||||
import { prepareFileDecryption, getDecryptedFileUrl } from "#lib/client/serviceWorker.js";
|
||||
import { requestFileThumbnailUpload } from "#lib/services/file.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
getDownloadingFiles,
|
||||
clearDownloadedFiles,
|
||||
type FileDownloadState,
|
||||
} from "#lib/modules/file/index.js";
|
||||
} from "#lib/client/transfer/index.js";
|
||||
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { masterKeyStore } from "#lib/stores/index.js";
|
||||
import File from "./File.svelte";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { FileDownloadState } from "#lib/modules/file/index.js";
|
||||
import type { FileDownloadState } from "#lib/client/transfer/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { TopBar } from "#lib/components/molecules/index.js";
|
||||
import { getUploadingFiles, clearUploadedFiles } from "#lib/modules/file/index.js";
|
||||
import { getUploadingFiles, clearUploadedFiles } from "#lib/client/transfer/index.js";
|
||||
import File from "./File.svelte";
|
||||
|
||||
const uploadingFiles = getUploadingFiles();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { FileUploadState } from "#lib/modules/file/index.js";
|
||||
import type { FileUploadState } from "#lib/client/transfer/index.js";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.js";
|
||||
|
||||
import IconPending from "~icons/material-symbols/pending";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { deserializeClientKeys } from "#lib/modules/key.js";
|
||||
import {
|
||||
generateEncryptionKeyPair,
|
||||
generateSigningKeyPair,
|
||||
@@ -10,8 +11,7 @@ import {
|
||||
makeAESKeyNonextractable,
|
||||
wrapHmacSecret,
|
||||
generateHmacSecret,
|
||||
} from "#lib/modules/crypto/index.js";
|
||||
import { deserializeClientKeys } from "#lib/modules/key.js";
|
||||
} from "#lib/shared/crypto/index.js";
|
||||
import { clientKeyStore } from "#lib/stores/index.js";
|
||||
|
||||
export { requestLogout } from "#lib/services/auth.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/modules/file/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
|
||||
|
||||
+2
-2
@@ -2,11 +2,11 @@
|
||||
import { onMount } from "svelte";
|
||||
import { FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { TopBar } from "#lib/components/molecules/index.js";
|
||||
import type { FileCacheIndex } from "#lib/indexedDB/index.js";
|
||||
import type { FileCacheIndex } from "#lib/client/storage/indexedDB/index.js";
|
||||
import {
|
||||
getFileCacheIndex,
|
||||
deleteFileCache as doDeleteFileCache,
|
||||
} from "#lib/modules/file/index.js";
|
||||
} from "#lib/client/cache/index.js";
|
||||
import { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { masterKeyStore } from "#lib/stores/index.js";
|
||||
import { formatFileSize } from "#lib/utils/index.js";
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { FileCacheIndex } from "#lib/indexedDB/index.js";
|
||||
import type { FileCacheIndex } from "#lib/client/storage/indexedDB/index.js";
|
||||
import type { MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { formatDate, formatFileSize } from "#lib/utils/index.js";
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { BottomDiv, Button, FullscreenDiv } from "#lib/components/atoms/index.js";
|
||||
import { IconEntryButton, TopBar } from "#lib/components/molecules/index.js";
|
||||
import { deleteAllFileThumbnailCaches } from "#lib/modules/file/index.js";
|
||||
import { deleteAllFileThumbnailCaches } from "#lib/client/cache/index.js";
|
||||
import type { MaybeFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { masterKeyStore } from "#lib/stores/index.js";
|
||||
import { sortEntries } from "#lib/utils/index.js";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { limitFunction } from "p-limit";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { storeFileThumbnailCache } from "#lib/modules/file/index.js";
|
||||
import { storeFileThumbnailCache } from "#lib/client/cache/index.js";
|
||||
import { generateThumbnail } from "#lib/client/media/thumbnail.js";
|
||||
import { getFileInfo, type FileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { generateThumbnail } from "#lib/modules/thumbnail.js";
|
||||
import { requestFileDownload, requestFileThumbnailUpload } from "#lib/services/file.js";
|
||||
import { HybridPromise, Scheduler } from "#lib/utils/index.js";
|
||||
import type { RouterOutputs } from "#trpc/router.server.js";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { CheckBox, RowVirtualizer } from "#lib/components/atoms/index.js";
|
||||
import { SubCategories, TopBar } from "#lib/components/molecules/index.js";
|
||||
import { CategoryCreateModal } from "#lib/components/organisms/index.js";
|
||||
import { updateCategoryInfo } from "#lib/indexedDB/index.js";
|
||||
import { updateCategoryInfo } from "#lib/client/storage/indexedDB/index.js";
|
||||
import { getCategoryInfo, type MaybeCategoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { masterKeyStore } from "#lib/stores/index.js";
|
||||
import { HybridPromise, sortEntries } from "#lib/utils/index.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/modules/file/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { CategoryFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
import type { SelectedFile } from "./service.svelte";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import { encryptString } from "#lib/modules/crypto/index.js";
|
||||
import type { SelectedCategory } from "#lib/components/molecules/index.js";
|
||||
import { encryptString } from "#lib/shared/crypto/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "#lib/services/category.js";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton, RowVirtualizer } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getUploadingFiles, type LiveFileUploadState } from "#lib/modules/file/index.js";
|
||||
import { getUploadingFiles, type LiveFileUploadState } from "#lib/client/transfer/index.js";
|
||||
import type { DirectoryInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { sortEntries } from "#lib/utils/index.js";
|
||||
import File from "./File.svelte";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/modules/file/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
import type { SelectedEntry } from "../service.svelte";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { LiveFileUploadState } from "#lib/modules/file/index.js";
|
||||
import type { LiveFileUploadState } from "#lib/client/transfer/index.js";
|
||||
import { formatNetworkSpeed } from "#lib/utils/index.js";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getDownloadingFiles } from "#lib/modules/file/index.js";
|
||||
import { getDownloadingFiles } from "#lib/client/transfer/index.js";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getUploadingFiles } from "#lib/modules/file/index.js";
|
||||
import { getUploadingFiles } from "#lib/client/transfer/index.js";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { getContext, setContext } from "svelte";
|
||||
import { storeHmacSecrets } from "#lib/indexedDB/index.js";
|
||||
import {
|
||||
generateDataKey,
|
||||
wrapDataKey,
|
||||
unwrapHmacSecret,
|
||||
encryptString,
|
||||
} from "#lib/modules/crypto/index.js";
|
||||
import {
|
||||
storeFileCache,
|
||||
deleteFileCache,
|
||||
storeFileThumbnailCache,
|
||||
deleteFileThumbnailCache,
|
||||
uploadFile,
|
||||
} from "#lib/modules/file/index.js";
|
||||
} from "#lib/client/cache/index.js";
|
||||
import { storeHmacSecrets } from "#lib/client/storage/indexedDB/index.js";
|
||||
import { uploadFile } from "#lib/client/transfer/index.js";
|
||||
import type { DataKey } from "#lib/modules/filesystem/index.js";
|
||||
import {
|
||||
generateDataKey,
|
||||
wrapDataKey,
|
||||
unwrapHmacSecret,
|
||||
encryptString,
|
||||
} from "#lib/shared/crypto/index.js";
|
||||
import { hmacSecretStore, type MasterKey, type HmacSecret } from "#lib/stores/index.js";
|
||||
import { trpc } from "#trpc/client.js";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "#lib/components/atoms/index.js";
|
||||
import { DirectoryEntryLabel } from "#lib/components/molecules/index.js";
|
||||
import { getFileThumbnail } from "#lib/modules/file/index.js";
|
||||
import { getFileThumbnail } from "#lib/client/cache/index.js";
|
||||
import type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
|
||||
import { formatDateTime } from "#lib/utils/index.js";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getAllFileInfos } from "#lib/indexedDB/index.js";
|
||||
import { getAllFileInfos } from "#lib/client/storage/indexedDB/index.js";
|
||||
|
||||
export const requestFreshMediaFilesRetrieval = async (limit = 8) => {
|
||||
const files = await getAllFileInfos();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { getDownloadingFiles, getUploadingFiles } from "#lib/modules/file/index.js";
|
||||
import { getDownloadingFiles, getUploadingFiles } from "#lib/client/transfer/index.js";
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { parseRangeHeader, getContentRangeHeader } from "#lib/modules/http.js";
|
||||
import { authorize } from "#lib/server/auth.js";
|
||||
import { handleAppErrors } from "#lib/server/http.js";
|
||||
import { FileService } from "#lib/server/services/index.js";
|
||||
import { parseRangeHeader, getContentRangeHeader } from "#lib/shared/http.js";
|
||||
import type { RequestEvent, RequestHandler } from "./$types";
|
||||
|
||||
const downloadHandler = async ({ locals, params, request }: RequestEvent) => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../lib/constants";
|
||||
@@ -1,14 +1,25 @@
|
||||
import { DECRYPTED_FILE_URL_PREFIX, CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE } from "../constants";
|
||||
import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto";
|
||||
import { parseRangeHeader, getContentRangeHeader } from "../modules/http";
|
||||
import { getFile } from "#lib/client/storage/opfs.js";
|
||||
import { decryptChunk } from "#lib/shared/crypto/index.js";
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
ENCRYPTED_CHUNK_SIZE,
|
||||
getDecryptedSize,
|
||||
getEncryptedRange,
|
||||
} from "#lib/shared/format/index.js";
|
||||
import {
|
||||
getContentRangeHeader,
|
||||
parseRangeHeader,
|
||||
resolveRange,
|
||||
type ByteRange,
|
||||
type RangeRequest,
|
||||
} from "#lib/shared/http.js";
|
||||
import { DECRYPTED_FILE_URL_PREFIX, type FileMetadata } from "#lib/shared/serviceWorker.js";
|
||||
import { getFileMetadata } from "../modules/metadata";
|
||||
import { getFile } from "../modules/opfs";
|
||||
import type { FileMetadata } from "../types";
|
||||
|
||||
const createResponse = (
|
||||
stream: ReadableStream<Uint8Array>,
|
||||
isRangeRequest: boolean,
|
||||
range: { start: number; end: number; total: number },
|
||||
range: ByteRange,
|
||||
contentType?: string,
|
||||
downloadFilename?: string,
|
||||
) => {
|
||||
@@ -33,22 +44,28 @@ const createResponse = (
|
||||
});
|
||||
};
|
||||
|
||||
const createRangeNotSatisfiableResponse = (total: number) => {
|
||||
return new Response("Invalid range", {
|
||||
status: 416,
|
||||
headers: { "Content-Range": `bytes */${total}` },
|
||||
});
|
||||
};
|
||||
|
||||
const streamFromOpfs = async (
|
||||
file: File,
|
||||
metadata?: FileMetadata,
|
||||
range?: { start?: number; end?: number },
|
||||
rangeRequest?: RangeRequest,
|
||||
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 });
|
||||
const range = resolveRange(rangeRequest, file.size);
|
||||
if (!range) {
|
||||
return createRangeNotSatisfiableResponse(file.size);
|
||||
}
|
||||
|
||||
return createResponse(
|
||||
file.slice(start, end + 1).stream(),
|
||||
!!range,
|
||||
{ start, end, total: file.size },
|
||||
file.slice(range.start, range.end + 1).stream(),
|
||||
!!rangeRequest,
|
||||
range,
|
||||
metadata?.contentType,
|
||||
downloadFilename,
|
||||
);
|
||||
@@ -57,16 +74,16 @@ const streamFromOpfs = async (
|
||||
const streamFromServer = async (
|
||||
id: number,
|
||||
metadata: FileMetadata,
|
||||
range?: { start?: number; end?: number },
|
||||
rangeRequest?: RangeRequest,
|
||||
downloadFilename?: string,
|
||||
) => {
|
||||
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
|
||||
const start = range?.start ?? 0;
|
||||
const end = range?.end ?? totalSize - 1;
|
||||
if (start > end || start < 0 || end >= totalSize) {
|
||||
return new Response("Invalid range", { status: 416 });
|
||||
const range = resolveRange(rangeRequest, totalSize);
|
||||
if (!range) {
|
||||
return createRangeNotSatisfiableResponse(totalSize);
|
||||
}
|
||||
|
||||
const { start, end } = range;
|
||||
const encryptedRange = getEncryptedRange(start, end, metadata.encContentSize, metadata.isLegacy);
|
||||
const apiResponse = await fetch(`/api/file/${id}/download`, {
|
||||
headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` },
|
||||
@@ -85,9 +102,10 @@ const streamFromServer = async (
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
!!range,
|
||||
{ start, end, total: totalSize },
|
||||
!!rangeRequest,
|
||||
range,
|
||||
metadata.contentType,
|
||||
downloadFilename,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -124,8 +142,8 @@ const streamFromServer = async (
|
||||
|
||||
return createResponse(
|
||||
apiResponse.body.pipeThrough(decryptingStream),
|
||||
!!range,
|
||||
{ start, end, total: totalSize },
|
||||
!!rangeRequest,
|
||||
range,
|
||||
metadata.contentType,
|
||||
downloadFilename,
|
||||
);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { self } from "$app/service-worker";
|
||||
import { DECRYPTED_FILE_URL_PREFIX } from "./constants";
|
||||
import {
|
||||
DECRYPTED_FILE_URL_PREFIX,
|
||||
type ServiceWorkerMessage,
|
||||
type ServiceWorkerResponse,
|
||||
} from "#lib/shared/serviceWorker.js";
|
||||
import { decryptFile } from "./handlers";
|
||||
import { fileMetadataStore } from "./stores";
|
||||
import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
const message: ServiceWorkerMessage = event.data;
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../lib/modules/http";
|
||||
@@ -1,6 +1,6 @@
|
||||
import { self } from "$app/service-worker";
|
||||
import type { FileMetadata, ServiceWorkerResponse } from "#lib/shared/serviceWorker.js";
|
||||
import { fileMetadataStore } from "../stores";
|
||||
import type { FileMetadata, ServiceWorkerResponse } from "../types";
|
||||
|
||||
const METADATA_REQUEST_TIMEOUT_MS = 3000;
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../../lib/modules/opfs";
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FileMetadata } from "./types";
|
||||
import type { FileMetadata } from "#lib/shared/serviceWorker.js";
|
||||
|
||||
const MAX_FILE_METADATA_ENTRIES = 32;
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from "../lib/serviceWorker/types";
|
||||
@@ -39,6 +39,25 @@ describe("createEncContentStream", () => {
|
||||
expect(await read(encContentStream)).toBe("2345");
|
||||
});
|
||||
|
||||
it("serves suffix ranges", async () => {
|
||||
const path = await createFile("0123456789");
|
||||
|
||||
const { encContentStream, range } = await createEncContentStream(path, undefined, {
|
||||
suffixLength: 3,
|
||||
});
|
||||
|
||||
expect(range).toEqual({ start: 7, end: 9, total: 10 });
|
||||
expect(await read(encContentStream)).toBe("789");
|
||||
});
|
||||
|
||||
it("rejects unsatisfiable ranges", async () => {
|
||||
const path = await createFile("0123456789");
|
||||
|
||||
await expect(createEncContentStream(path, undefined, { start: 10 })).rejects.toMatchObject({
|
||||
code: "RANGE_NOT_SATISFIABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("prepends the legacy IV to the content", async () => {
|
||||
const path = await createFile("6789");
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
encryptChunk,
|
||||
encryptString,
|
||||
generateDataKey,
|
||||
} from "#lib/modules/crypto/index.js";
|
||||
} from "#lib/shared/crypto/index.js";
|
||||
|
||||
describe("base64", () => {
|
||||
it("round-trips large buffers", () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE, ENCRYPTION_OVERHEAD } from "#lib/constants/index.js";
|
||||
import { getContentRangeHeader, parseRangeHeader } from "#lib/modules/http.js";
|
||||
import { getDecryptedSize, getEncryptedRange } from "../../src/service-worker/modules/crypto";
|
||||
import {
|
||||
CHUNK_SIZE,
|
||||
ENCRYPTED_CHUNK_SIZE,
|
||||
ENCRYPTION_OVERHEAD,
|
||||
getDecryptedSize,
|
||||
getEncryptedRange,
|
||||
getEncryptedSize,
|
||||
} from "#lib/shared/format/index.js";
|
||||
import { getContentRangeHeader, parseRangeHeader, resolveRange } from "#lib/shared/http.js";
|
||||
|
||||
describe("parseRangeHeader", () => {
|
||||
it("parses byte ranges", () => {
|
||||
@@ -9,6 +15,33 @@ describe("parseRangeHeader", () => {
|
||||
expect(parseRangeHeader("bytes=0-")).toEqual({ start: 0, end: undefined });
|
||||
expect(parseRangeHeader("bytes=10-20")).toEqual({ start: 10, end: 20 });
|
||||
expect(parseRangeHeader("bytes=10-20, 30-40")).toEqual({ start: 10, end: 20 });
|
||||
expect(parseRangeHeader("bytes=-500")).toEqual({ suffixLength: 500 });
|
||||
});
|
||||
|
||||
it("ignores invalid ranges", () => {
|
||||
expect(parseRangeHeader("bytes=-")).toBeUndefined();
|
||||
expect(parseRangeHeader("items=0-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveRange", () => {
|
||||
it("resolves the whole content without a range", () => {
|
||||
expect(resolveRange(undefined, 100)).toEqual({ start: 0, end: 99, total: 100 });
|
||||
});
|
||||
|
||||
it("clamps the end to the content size", () => {
|
||||
expect(resolveRange({ start: 10, end: 1000 }, 100)).toEqual({ start: 10, end: 99, total: 100 });
|
||||
expect(resolveRange({ start: 10 }, 100)).toEqual({ start: 10, end: 99, total: 100 });
|
||||
});
|
||||
|
||||
it("resolves suffix ranges", () => {
|
||||
expect(resolveRange({ suffixLength: 10 }, 100)).toEqual({ start: 90, end: 99, total: 100 });
|
||||
expect(resolveRange({ suffixLength: 1000 }, 100)).toEqual({ start: 0, end: 99, total: 100 });
|
||||
});
|
||||
|
||||
it("rejects unsatisfiable ranges", () => {
|
||||
expect(resolveRange({ start: 100 }, 100)).toBeNull();
|
||||
expect(resolveRange({ start: 20, end: 10 }, 100)).toBeNull();
|
||||
});
|
||||
|
||||
it("formats Content-Range", () => {
|
||||
@@ -19,13 +52,13 @@ describe("parseRangeHeader", () => {
|
||||
});
|
||||
|
||||
describe("encrypted chunk layout", () => {
|
||||
const encryptedSizeOf = (size: number) =>
|
||||
size + Math.ceil(size / CHUNK_SIZE) * ENCRYPTION_OVERHEAD;
|
||||
|
||||
it.each([1, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, 3 * CHUNK_SIZE + 12345])(
|
||||
"computes the decrypted size of %i bytes",
|
||||
(size) => {
|
||||
expect(getDecryptedSize(encryptedSizeOf(size), false)).toBe(size);
|
||||
expect(getEncryptedSize(size)).toBe(
|
||||
size + Math.ceil(size / CHUNK_SIZE) * ENCRYPTION_OVERHEAD,
|
||||
);
|
||||
expect(getDecryptedSize(getEncryptedSize(size), false)).toBe(size);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -35,7 +68,7 @@ describe("encrypted chunk layout", () => {
|
||||
|
||||
it("maps a plaintext range to whole encrypted chunks", () => {
|
||||
const totalSize = 3 * CHUNK_SIZE + 100;
|
||||
const totalEncryptedSize = encryptedSizeOf(totalSize);
|
||||
const totalEncryptedSize = getEncryptedSize(totalSize);
|
||||
|
||||
expect(getEncryptedRange(0, 10, totalEncryptedSize, false)).toEqual({
|
||||
firstChunkIndex: 0,
|
||||
|
||||
Reference in new issue
Block a user