서버, 클라이언트, 서비스 워커 공용 코드를 lib/shared로 분리하고 브라우저 전용 모듈을 lib/client로 재구성

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 00:04:56 +09:00
1 parent 6a6aa1765b
commit aee87fd9b6
82 files changed
+326 -221

No files matched your search

+3 -3
View File
@@ -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>();
+2
View File
@@ -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));
+27
View File
@@ -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.
@@ -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);
@@ -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";
-2
View File
@@ -1,2 +0,0 @@
export * from "./serviceWorker";
export * from "./upload";
-1
View File
@@ -1 +0,0 @@
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decryptedFile/";
-6
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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>;
-14
View File
@@ -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 -1
View File
@@ -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(
+7 -4
View File
@@ -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 -1
View File
@@ -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");
+7 -7
View File
@@ -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,
};
};
-2
View File
@@ -1,2 +0,0 @@
export * from "./client";
export * from "./types";
+1 -1
View File
@@ -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 -1
View File
@@ -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";
+4 -4
View File
@@ -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 (
+3 -3
View File
@@ -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(
+16
View File
@@ -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)
);
};
+1
View File
@@ -0,0 +1 @@
export * from "./chunk";
+35
View File
@@ -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 {
+2 -2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from "../lib/constants";
+41 -23
View File
@@ -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,
);
+5 -2
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from "../../lib/modules/http";
+1 -1
View File
@@ -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
View File
@@ -1 +0,0 @@
export * from "../../lib/modules/opfs";
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FileMetadata } from "./types";
import type { FileMetadata } from "#lib/shared/serviceWorker.js";
const MAX_FILE_METADATA_ENTRIES = 32;
-1
View File
@@ -1 +0,0 @@
export * from "../lib/serviceWorker/types";
+19
View File
@@ -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");
+1 -1
View File
@@ -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", () => {
+41 -8
View File
@@ -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,