서버, 클라이언트, 서비스 워커 공용 코드를 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 type { ClientInit } from "@sveltejs/kit/hooks";
import { prepareFileCache } from "#lib/client/cache/index.js";
import { listenForMetadataRequests } from "#lib/client/serviceWorker.js";
import { import {
cleanupDanglingInfos, cleanupDanglingInfos,
getClientKey, getClientKey,
getMasterKeys, getMasterKeys,
getHmacSecrets, getHmacSecrets,
} from "#lib/indexedDB/index.js"; } from "#lib/client/storage/indexedDB/index.js";
import { prepareFileCache } from "#lib/modules/file/index.js";
import { listenForMetadataRequests } from "#lib/serviceWorker/index.js";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "#lib/stores/index.js"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "#lib/stores/index.js";
const requestPersistentStorage = async () => { const requestPersistentStorage = async () => {
@@ -3,8 +3,8 @@ import {
storeFileCacheIndex, storeFileCacheIndex,
deleteFileCacheIndex, deleteFileCacheIndex,
type FileCacheIndex, type FileCacheIndex,
} from "#lib/indexedDB/index.js"; } from "#lib/client/storage/indexedDB/index.js";
import { readFile, writeFile, deleteFile } from "#lib/modules/opfs.js"; import { readFile, writeFile, deleteFile } from "#lib/client/storage/opfs.js";
const fileCacheIndex = new Map<number, FileCacheIndex>(); 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 { LRUCache } from "lru-cache";
import { writable, type Writable } from "svelte/store"; 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 type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
import { readFile, writeFile, deleteFile, deleteDirectory } from "#lib/modules/opfs.js"; import { decryptChunk } from "#lib/shared/crypto/index.js";
import { getThumbnailUrl } from "#lib/modules/thumbnail.js";
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 }); const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
const loadingThumbnails = new Map<number, Writable<string | undefined>>(); 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 type { ComputeMessage, ResultMessage } from "#workers/hmac.js";
import HmacWorker from "#workers/hmac.js?worker";
export const digestMessage = async (message: BufferSource) => { // Computes HMAC of large blobs incrementally in a worker, which WebCrypto doesn't support
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"],
),
};
};
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => { export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => {
const stream = message.stream(); const stream = message.stream();
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret)); 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) => { const scaleSize = (width: number, height: number, targetSize: number) => {
if (width <= targetSize || height <= targetSize) { if (width <= targetSize || height <= targetSize) {
@@ -1,5 +1,9 @@
import { DECRYPTED_FILE_URL_PREFIX } from "#lib/constants/index.js"; import { DECRYPTED_FILE_URL_PREFIX } from "#lib/shared/serviceWorker.js";
import type { FileMetadata, ServiceWorkerMessage, ServiceWorkerResponse } from "./types"; import type {
FileMetadata,
ServiceWorkerMessage,
ServiceWorkerResponse,
} from "#lib/shared/serviceWorker.js";
const PREPARE_TIMEOUT_MS = 5000; 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 { sha256 } from "@noble/hashes/sha2.js";
import axios from "axios"; import axios from "axios";
import pLimit from "p-limit"; import pLimit from "p-limit";
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "#lib/constants/index.js"; import { encodeToBase64, encryptChunk } from "#lib/shared/crypto/index.js";
import { encodeToBase64, encryptChunk } from "#lib/modules/crypto/index.js"; import { CHUNK_SIZE, getChunkCount, getEncryptedSize } from "#lib/shared/format/index.js";
import { BoundedQueue } from "#lib/utils/index.js"; import { BoundedQueue } from "#lib/utils/concurrency/index.js";
interface UploadStats { interface UploadStats {
progress: number; progress: number;
@@ -98,8 +98,8 @@ export const uploadBlob = async (
const networkConcurrency = options?.concurrency ?? 4; const networkConcurrency = options?.concurrency ?? 4;
const maxQueueSize = 8; const maxQueueSize = 8;
const totalChunks = Math.ceil(blob.size / CHUNK_SIZE); const totalChunks = getChunkCount(blob.size);
const totalBytes = blob.size + totalChunks * ENCRYPTION_OVERHEAD; const totalBytes = getEncryptedSize(blob.size);
const uploadedByChunk = new Array<number>(totalChunks).fill(0); const uploadedByChunk = new Array<number>(totalChunks).fill(0);
const speedMeter = createSpeedMeter(3000, 200); const speedMeter = createSpeedMeter(3000, 200);
@@ -1,7 +1,7 @@
import axios from "axios"; import axios from "axios";
import { limitFunction } from "p-limit"; import { limitFunction } from "p-limit";
import { ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js"; import { decryptChunk, concatenateBuffers } from "#lib/shared/crypto/index.js";
import { decryptChunk, concatenateBuffers } from "#lib/modules/crypto/index.js"; import { ENCRYPTED_CHUNK_SIZE } from "#lib/shared/format/index.js";
export interface FileDownloadState { export interface FileDownloadState {
id: number; id: number;
@@ -1,4 +1,2 @@
export * from "./cache";
export * from "./download.svelte"; export * from "./download.svelte";
export * from "./thumbnail";
export * from "./upload.svelte"; export * from "./upload.svelte";
@@ -1,15 +1,15 @@
import ExifReader from "exifreader";
import { limitFunction } from "p-limit"; 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 { import {
encodeToBase64, encodeToBase64,
generateDataKey, generateDataKey,
wrapDataKey, wrapDataKey,
encryptString, encryptString,
} from "#lib/modules/crypto/index.js"; } from "#lib/shared/crypto/index.js";
import { signMessageHmac } from "#lib/modules/crypto/index.js"; import { getChunkCount } from "#lib/shared/format/index.js";
import { generateThumbnail } from "#lib/modules/thumbnail.js";
import { uploadBlob } from "#lib/modules/upload.js";
import type { MasterKey, HmacSecret } from "#lib/stores/index.js"; import type { MasterKey, HmacSecret } from "#lib/stores/index.js";
import { Scheduler } from "#lib/utils/index.js"; import { Scheduler } from "#lib/utils/index.js";
import { trpc } from "#trpc/client.js"; import { trpc } from "#trpc/client.js";
@@ -84,32 +84,6 @@ const getFileType = (file: File) => {
throw new Error("Unknown file type"); 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 { interface FileMetadata {
parentId: "root" | number; parentId: "root" | number;
name: string; name: string;
@@ -140,7 +114,7 @@ const requestFileMetadataEncryption = limitFunction(
]); ]);
const { uploadId } = await trpc.upload.startFileUpload.mutate({ const { uploadId } = await trpc.upload.startFileUpload.mutate({
chunks: Math.ceil(file.size / CHUNK_SIZE), chunks: getChunkCount(file.size),
parent: fileMetadata.parentId, parent: fileMetadata.parentId,
mekVersion: masterKey.version, mekVersion: masterKey.version,
dek: dataKeyWrapped, dek: dataKeyWrapped,
@@ -1,5 +1,5 @@
<script lang="ts"> <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 type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
import IconFavorite from "~icons/material-symbols/favorite"; 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 { trpc, isTRPCClientError } from "#trpc/client.js";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { CategoryInfo, MaybeCategoryInfo } from "./types"; 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 { trpc, isTRPCClientError } from "#trpc/client.js";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte"; import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { DirectoryInfo, MaybeDirectoryInfo } from "./types"; 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 { trpc, isTRPCClientError } from "#trpc/client.js";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte"; import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { FileInfo, MaybeFileInfo } from "./types"; import type { FileInfo, MaybeFileInfo } from "./types";
@@ -1,5 +1,5 @@
import { untrack } from "svelte"; 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> { interface FilesystemCacheOptions<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>; 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 { 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"; import type { ClientKeys } from "#lib/stores/index.js";
const SerializedClientKeysSchema = z.intersection( 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 type { Ciphertext } from "#lib/server/db/schema/index.js";
import { AppError } from "#lib/server/errors.js"; import { AppError } from "#lib/server/errors.js";
import { createEncContentStream } from "#lib/server/storage/index.js"; import { createEncContentStream } from "#lib/server/storage/index.js";
import type { RangeRequest } from "#lib/shared/http.js";
import { schedulePendingFileDeletions } from "./fileDeletion"; import { schedulePendingFileDeletions } from "./fileDeletion";
type Range = { start?: number; end?: number };
export const getFile = async (userId: number, fileId: number) => { export const getFile = async (userId: number, fileId: number) => {
const file = await FileRepo.getFile(userId, fileId); const file = await FileRepo.getFile(userId, fileId);
if (!file) { 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); 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); const file = await FileRepo.getFile(userId, fileId);
if (!file) { if (!file) {
throw new AppError("NOT_FOUND", "File not found"); 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); 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); const thumbnail = await getFileThumbnail(userId, fileId);
return await createEncContentStream(thumbnail.path, decodeIv(thumbnail.encContentIv), range); return await createEncContentStream(thumbnail.path, decodeIv(thumbnail.encContentIv), range);
}; };
+1 -1
View File
@@ -1,7 +1,7 @@
import { open } from "fs/promises"; import { open } from "fs/promises";
import type { Readable } from "stream"; import type { Readable } from "stream";
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "#lib/constants/index.js";
import { AppError } from "#lib/server/errors.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"); 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 { stat } from "fs/promises";
import { Readable } from "stream"; import { Readable } from "stream";
import { AppError } from "#lib/server/errors.js"; import { AppError } from "#lib/server/errors.js";
import { resolveRange, type RangeRequest } from "#lib/shared/http.js";
export const createEncContentStream = async ( export const createEncContentStream = async (
path: string, path: string,
iv?: Buffer, iv?: Buffer,
range?: { start?: number; end?: number }, rangeRequest?: RangeRequest,
) => { ) => {
const { size: fileSize } = await stat(path); const { size: fileSize } = await stat(path);
const ivSize = iv?.byteLength ?? 0; const ivSize = iv?.byteLength ?? 0;
const totalSize = fileSize + ivSize;
const start = range?.start ?? 0; const range = resolveRange(rangeRequest, fileSize + ivSize);
const end = range?.end ?? totalSize - 1; if (!range) {
if (start > end || start < 0 || end >= totalSize) {
throw new AppError("RANGE_NOT_SATISFIABLE", "Invalid range"); throw new AppError("RANGE_NOT_SATISFIABLE", "Invalid range");
} }
const { start, end } = range;
return { return {
encContentStream: Readable.toWeb( encContentStream: Readable.toWeb(
Readable.from( Readable.from(
@@ -34,7 +34,7 @@ export const createEncContentStream = async (
})(), })(),
), ),
), ),
range: { start, end, total: totalSize }, range,
isRangeRequest: !!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"; import { trpc, isTRPCClientError } from "#trpc/client.js";
export const requestSessionUpgrade = async ( 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 type { MasterKey } from "#lib/stores/index.js";
import { trpc } from "#trpc/client.js"; import { trpc } from "#trpc/client.js";
+4 -4
View File
@@ -1,12 +1,12 @@
import { getAllFileInfos } from "#lib/indexedDB/filesystem.js";
import { import {
getFileCache, getFileCache,
storeFileCache, storeFileCache,
deleteFileCache, deleteFileCache,
downloadFile,
deleteFileThumbnailCache, deleteFileThumbnailCache,
} from "#lib/modules/file/index.js"; } from "#lib/client/cache/index.js";
import { uploadBlob } from "#lib/modules/upload.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"; import { trpc } from "#trpc/client.js";
export const requestFileDownload = async ( 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 { import {
encodeToBase64, encodeToBase64,
exportRSAKeyToBase64, exportRSAKeyToBase64,
@@ -7,8 +8,7 @@ import {
unwrapMasterKey, unwrapMasterKey,
signMasterKeyWrapped, signMasterKeyWrapped,
verifyMasterKeyWrapped, verifyMasterKeyWrapped,
} from "#lib/modules/crypto/index.js"; } from "#lib/shared/crypto/index.js";
import { requestSessionUpgrade } from "#lib/services/auth.js";
import { masterKeyStore, type ClientKeys } from "#lib/stores/index.js"; import { masterKeyStore, type ClientKeys } from "#lib/stores/index.js";
import { trpc, isTRPCClientError } from "#trpc/client.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 { import {
encodeString, encodeString,
decodeString, decodeString,
encodeToBase64, encodeToBase64,
decodeFromBase64, decodeFromBase64,
concatenateBuffers, concatenateBuffers,
} from "./utils"; } from "./encoding";
export const generateMasterKey = async () => { export const generateMasterKey = async () => {
return { return {
File renamed without changes.
@@ -1,4 +1,4 @@
export * from "./aes"; export * from "./aes";
export * from "./encoding";
export * from "./rsa"; export * from "./rsa";
export * from "./sha"; 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 () => { export const generateEncryptionKeyPair = async () => {
const keyPair = await crypto.subtle.generateKey( 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 = ( export const getEncryptedRange = (
start: number, start: number,
end: number, end: number,
@@ -26,15 +53,3 @@ export const getEncryptedRange = (
end: Math.min((lastChunkIndex + 1) * ENCRYPTED_CHUNK_SIZE - 1, totalEncryptedSize - 1), 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 { export interface FileMetadata {
isLegacy: boolean; isLegacy: boolean;
dataKey: CryptoKey; 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"; export { requestMasterKeyDownload } from "#lib/services/key.js";
@@ -6,8 +6,8 @@
import { FullscreenDiv } from "#lib/components/atoms/index.js"; import { FullscreenDiv } from "#lib/components/atoms/index.js";
import { Categories, IconEntryButton, TopBar } from "#lib/components/molecules/index.js"; import { Categories, IconEntryButton, TopBar } from "#lib/components/molecules/index.js";
import { getFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js"; import { getFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
import { captureVideoThumbnail } from "#lib/modules/thumbnail.js"; import { captureVideoThumbnail } from "#lib/client/media/thumbnail.js";
import { getFileDownloadState } from "#lib/modules/file/index.js"; import { getFileDownloadState } from "#lib/client/transfer/index.js";
import { masterKeyStore } from "#lib/stores/index.js"; import { masterKeyStore } from "#lib/stores/index.js";
import { HybridPromise } from "#lib/utils/index.js"; import { HybridPromise } from "#lib/utils/index.js";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
@@ -1,5 +1,5 @@
<script lang="ts"> <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"; import { formatNetworkSpeed } from "#lib/utils/index.js";
interface Props { interface Props {
+2 -2
View File
@@ -1,5 +1,5 @@
import { storeFileThumbnailCache } from "#lib/modules/file/index.js"; import { storeFileThumbnailCache } from "#lib/client/cache/index.js";
import { prepareFileDecryption, getDecryptedFileUrl } from "#lib/serviceWorker/index.js"; import { prepareFileDecryption, getDecryptedFileUrl } from "#lib/client/serviceWorker.js";
import { requestFileThumbnailUpload } from "#lib/services/file.js"; import { requestFileThumbnailUpload } from "#lib/services/file.js";
import { trpc } from "#trpc/client.js"; import { trpc } from "#trpc/client.js";
@@ -6,7 +6,7 @@
getDownloadingFiles, getDownloadingFiles,
clearDownloadedFiles, clearDownloadedFiles,
type FileDownloadState, 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 { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
import { masterKeyStore } from "#lib/stores/index.js"; import { masterKeyStore } from "#lib/stores/index.js";
import File from "./File.svelte"; import File from "./File.svelte";
@@ -1,5 +1,5 @@
<script lang="ts"> <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 type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
import { formatNetworkSpeed } from "#lib/utils/index.js"; import { formatNetworkSpeed } from "#lib/utils/index.js";
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { FullscreenDiv } from "#lib/components/atoms/index.js"; import { FullscreenDiv } from "#lib/components/atoms/index.js";
import { TopBar } from "#lib/components/molecules/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"; import File from "./File.svelte";
const uploadingFiles = getUploadingFiles(); const uploadingFiles = getUploadingFiles();
@@ -1,5 +1,5 @@
<script lang="ts"> <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 { formatNetworkSpeed } from "#lib/utils/index.js";
import IconPending from "~icons/material-symbols/pending"; import IconPending from "~icons/material-symbols/pending";
@@ -1,3 +1,4 @@
import { deserializeClientKeys } from "#lib/modules/key.js";
import { import {
generateEncryptionKeyPair, generateEncryptionKeyPair,
generateSigningKeyPair, generateSigningKeyPair,
@@ -10,8 +11,7 @@ import {
makeAESKeyNonextractable, makeAESKeyNonextractable,
wrapHmacSecret, wrapHmacSecret,
generateHmacSecret, generateHmacSecret,
} from "#lib/modules/crypto/index.js"; } from "#lib/shared/crypto/index.js";
import { deserializeClientKeys } from "#lib/modules/key.js";
import { clientKeyStore } from "#lib/stores/index.js"; import { clientKeyStore } from "#lib/stores/index.js";
export { requestLogout } from "#lib/services/auth.js"; export { requestLogout } from "#lib/services/auth.js";
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ActionEntryButton } from "#lib/components/atoms/index.js"; import { ActionEntryButton } from "#lib/components/atoms/index.js";
import { DirectoryEntryLabel } from "#lib/components/molecules/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 type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
import { formatDateTime } from "#lib/utils/index.js"; import { formatDateTime } from "#lib/utils/index.js";
+2 -2
View File
@@ -2,11 +2,11 @@
import { onMount } from "svelte"; import { onMount } from "svelte";
import { FullscreenDiv } from "#lib/components/atoms/index.js"; import { FullscreenDiv } from "#lib/components/atoms/index.js";
import { TopBar } from "#lib/components/molecules/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 { import {
getFileCacheIndex, getFileCacheIndex,
deleteFileCache as doDeleteFileCache, 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 { bulkGetFileInfo, type MaybeFileInfo } from "#lib/modules/filesystem/index.js";
import { masterKeyStore } from "#lib/stores/index.js"; import { masterKeyStore } from "#lib/stores/index.js";
import { formatFileSize } from "#lib/utils/index.js"; import { formatFileSize } from "#lib/utils/index.js";
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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 type { MaybeFileInfo } from "#lib/modules/filesystem/index.js";
import { formatDate, formatFileSize } from "#lib/utils/index.js"; import { formatDate, formatFileSize } from "#lib/utils/index.js";
@@ -3,7 +3,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { BottomDiv, Button, FullscreenDiv } from "#lib/components/atoms/index.js"; import { BottomDiv, Button, FullscreenDiv } from "#lib/components/atoms/index.js";
import { IconEntryButton, TopBar } from "#lib/components/molecules/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 type { MaybeFileInfo } from "#lib/modules/filesystem/index.js";
import { masterKeyStore } from "#lib/stores/index.js"; import { masterKeyStore } from "#lib/stores/index.js";
import { sortEntries } from "#lib/utils/index.js"; import { sortEntries } from "#lib/utils/index.js";
@@ -1,8 +1,8 @@
import { limitFunction } from "p-limit"; import { limitFunction } from "p-limit";
import { SvelteMap } from "svelte/reactivity"; 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 { 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 { requestFileDownload, requestFileThumbnailUpload } from "#lib/services/file.js";
import { HybridPromise, Scheduler } from "#lib/utils/index.js"; import { HybridPromise, Scheduler } from "#lib/utils/index.js";
import type { RouterOutputs } from "#trpc/router.server.js"; import type { RouterOutputs } from "#trpc/router.server.js";
@@ -3,7 +3,7 @@
import { CheckBox, RowVirtualizer } from "#lib/components/atoms/index.js"; import { CheckBox, RowVirtualizer } from "#lib/components/atoms/index.js";
import { SubCategories, TopBar } from "#lib/components/molecules/index.js"; import { SubCategories, TopBar } from "#lib/components/molecules/index.js";
import { CategoryCreateModal } from "#lib/components/organisms/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 { getCategoryInfo, type MaybeCategoryInfo } from "#lib/modules/filesystem/index.js";
import { masterKeyStore } from "#lib/stores/index.js"; import { masterKeyStore } from "#lib/stores/index.js";
import { HybridPromise, sortEntries } from "#lib/utils/index.js"; import { HybridPromise, sortEntries } from "#lib/utils/index.js";
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ActionEntryButton } from "#lib/components/atoms/index.js"; import { ActionEntryButton } from "#lib/components/atoms/index.js";
import { DirectoryEntryLabel } from "#lib/components/molecules/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 type { CategoryFileInfo } from "#lib/modules/filesystem/index.js";
import { formatDateTime } from "#lib/utils/index.js"; import { formatDateTime } from "#lib/utils/index.js";
import type { SelectedFile } from "./service.svelte"; import type { SelectedFile } from "./service.svelte";
@@ -1,6 +1,6 @@
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { encryptString } from "#lib/modules/crypto/index.js";
import type { SelectedCategory } from "#lib/components/molecules/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"; import { trpc } from "#trpc/client.js";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "#lib/services/category.js"; export { requestCategoryCreation, requestFileRemovalFromCategory } from "#lib/services/category.js";
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ActionEntryButton, RowVirtualizer } from "#lib/components/atoms/index.js"; import { ActionEntryButton, RowVirtualizer } from "#lib/components/atoms/index.js";
import { DirectoryEntryLabel } from "#lib/components/molecules/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 type { DirectoryInfo } from "#lib/modules/filesystem/index.js";
import { sortEntries } from "#lib/utils/index.js"; import { sortEntries } from "#lib/utils/index.js";
import File from "./File.svelte"; import File from "./File.svelte";
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ActionEntryButton } from "#lib/components/atoms/index.js"; import { ActionEntryButton } from "#lib/components/atoms/index.js";
import { DirectoryEntryLabel } from "#lib/components/molecules/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 type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
import { formatDateTime } from "#lib/utils/index.js"; import { formatDateTime } from "#lib/utils/index.js";
import type { SelectedEntry } from "../service.svelte"; import type { SelectedEntry } from "../service.svelte";
@@ -1,5 +1,5 @@
<script lang="ts"> <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 { formatNetworkSpeed } from "#lib/utils/index.js";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getDownloadingFiles } from "#lib/modules/file/index.js"; import { getDownloadingFiles } from "#lib/client/transfer/index.js";
interface Props { interface Props {
onclick: () => void; onclick: () => void;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getUploadingFiles } from "#lib/modules/file/index.js"; import { getUploadingFiles } from "#lib/client/transfer/index.js";
interface Props { interface Props {
onclick: () => void; onclick: () => void;
@@ -1,19 +1,19 @@
import { getContext, setContext } from "svelte"; import { getContext, setContext } from "svelte";
import { storeHmacSecrets } from "#lib/indexedDB/index.js";
import {
generateDataKey,
wrapDataKey,
unwrapHmacSecret,
encryptString,
} from "#lib/modules/crypto/index.js";
import { import {
storeFileCache, storeFileCache,
deleteFileCache, deleteFileCache,
storeFileThumbnailCache, storeFileThumbnailCache,
deleteFileThumbnailCache, deleteFileThumbnailCache,
uploadFile, } from "#lib/client/cache/index.js";
} from "#lib/modules/file/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 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 { hmacSecretStore, type MasterKey, type HmacSecret } from "#lib/stores/index.js";
import { trpc } from "#trpc/client.js"; import { trpc } from "#trpc/client.js";
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ActionEntryButton } from "#lib/components/atoms/index.js"; import { ActionEntryButton } from "#lib/components/atoms/index.js";
import { DirectoryEntryLabel } from "#lib/components/molecules/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 type { SummarizedFileInfo } from "#lib/modules/filesystem/index.js";
import { formatDateTime } from "#lib/utils/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) => { export const requestFreshMediaFilesRetrieval = async (limit = 8) => {
const files = await getAllFileInfos(); const files = await getAllFileInfos();
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getDownloadingFiles, getUploadingFiles } from "#lib/modules/file/index.js"; import { getDownloadingFiles, getUploadingFiles } from "#lib/client/transfer/index.js";
import "../app.css"; import "../app.css";
let { children } = $props(); let { children } = $props();
@@ -1,9 +1,9 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { z } from "zod"; import { z } from "zod";
import { parseRangeHeader, getContentRangeHeader } from "#lib/modules/http.js";
import { authorize } from "#lib/server/auth.js"; import { authorize } from "#lib/server/auth.js";
import { handleAppErrors } from "#lib/server/http.js"; import { handleAppErrors } from "#lib/server/http.js";
import { FileService } from "#lib/server/services/index.js"; import { FileService } from "#lib/server/services/index.js";
import { parseRangeHeader, getContentRangeHeader } from "#lib/shared/http.js";
import type { RequestEvent, RequestHandler } from "./$types"; import type { RequestEvent, RequestHandler } from "./$types";
const downloadHandler = async ({ locals, params, request }: RequestEvent) => { 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 { getFile } from "#lib/client/storage/opfs.js";
import { decryptChunk, getEncryptedRange, getDecryptedSize } from "../modules/crypto"; import { decryptChunk } from "#lib/shared/crypto/index.js";
import { parseRangeHeader, getContentRangeHeader } from "../modules/http"; 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 { getFileMetadata } from "../modules/metadata";
import { getFile } from "../modules/opfs";
import type { FileMetadata } from "../types";
const createResponse = ( const createResponse = (
stream: ReadableStream<Uint8Array>, stream: ReadableStream<Uint8Array>,
isRangeRequest: boolean, isRangeRequest: boolean,
range: { start: number; end: number; total: number }, range: ByteRange,
contentType?: string, contentType?: string,
downloadFilename?: 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 ( const streamFromOpfs = async (
file: File, file: File,
metadata?: FileMetadata, metadata?: FileMetadata,
range?: { start?: number; end?: number }, rangeRequest?: RangeRequest,
downloadFilename?: string, downloadFilename?: string,
) => { ) => {
const start = range?.start ?? 0; const range = resolveRange(rangeRequest, file.size);
const end = range?.end ?? file.size - 1; if (!range) {
if (start > end || start < 0 || end >= file.size) { return createRangeNotSatisfiableResponse(file.size);
return new Response("Invalid range", { status: 416 });
} }
return createResponse( return createResponse(
file.slice(start, end + 1).stream(), file.slice(range.start, range.end + 1).stream(),
!!range, !!rangeRequest,
{ start, end, total: file.size }, range,
metadata?.contentType, metadata?.contentType,
downloadFilename, downloadFilename,
); );
@@ -57,16 +74,16 @@ const streamFromOpfs = async (
const streamFromServer = async ( const streamFromServer = async (
id: number, id: number,
metadata: FileMetadata, metadata: FileMetadata,
range?: { start?: number; end?: number }, rangeRequest?: RangeRequest,
downloadFilename?: string, downloadFilename?: string,
) => { ) => {
const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy); const totalSize = getDecryptedSize(metadata.encContentSize, metadata.isLegacy);
const start = range?.start ?? 0; const range = resolveRange(rangeRequest, totalSize);
const end = range?.end ?? totalSize - 1; if (!range) {
if (start > end || start < 0 || end >= totalSize) { return createRangeNotSatisfiableResponse(totalSize);
return new Response("Invalid range", { status: 416 });
} }
const { start, end } = range;
const encryptedRange = getEncryptedRange(start, end, metadata.encContentSize, metadata.isLegacy); const encryptedRange = getEncryptedRange(start, end, metadata.encContentSize, metadata.isLegacy);
const apiResponse = await fetch(`/api/file/${id}/download`, { const apiResponse = await fetch(`/api/file/${id}/download`, {
headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` }, headers: { Range: `bytes=${encryptedRange.start}-${encryptedRange.end}` },
@@ -85,9 +102,10 @@ const streamFromServer = async (
controller.close(); controller.close();
}, },
}), }),
!!range, !!rangeRequest,
{ start, end, total: totalSize }, range,
metadata.contentType, metadata.contentType,
downloadFilename,
); );
} }
@@ -124,8 +142,8 @@ const streamFromServer = async (
return createResponse( return createResponse(
apiResponse.body.pipeThrough(decryptingStream), apiResponse.body.pipeThrough(decryptingStream),
!!range, !!rangeRequest,
{ start, end, total: totalSize }, range,
metadata.contentType, metadata.contentType,
downloadFilename, downloadFilename,
); );
+5 -2
View File
@@ -1,8 +1,11 @@
import { self } from "$app/service-worker"; 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 { decryptFile } from "./handlers";
import { fileMetadataStore } from "./stores"; import { fileMetadataStore } from "./stores";
import type { ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
self.addEventListener("message", (event) => { self.addEventListener("message", (event) => {
const message: ServiceWorkerMessage = event.data; 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 { self } from "$app/service-worker";
import type { FileMetadata, ServiceWorkerResponse } from "#lib/shared/serviceWorker.js";
import { fileMetadataStore } from "../stores"; import { fileMetadataStore } from "../stores";
import type { FileMetadata, ServiceWorkerResponse } from "../types";
const METADATA_REQUEST_TIMEOUT_MS = 3000; 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; 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"); 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 () => { it("prepends the legacy IV to the content", async () => {
const path = await createFile("6789"); const path = await createFile("6789");
+1 -1
View File
@@ -7,7 +7,7 @@ import {
encryptChunk, encryptChunk,
encryptString, encryptString,
generateDataKey, generateDataKey,
} from "#lib/modules/crypto/index.js"; } from "#lib/shared/crypto/index.js";
describe("base64", () => { describe("base64", () => {
it("round-trips large buffers", () => { it("round-trips large buffers", () => {
+41 -8
View File
@@ -1,7 +1,13 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE, ENCRYPTION_OVERHEAD } from "#lib/constants/index.js"; import {
import { getContentRangeHeader, parseRangeHeader } from "#lib/modules/http.js"; CHUNK_SIZE,
import { getDecryptedSize, getEncryptedRange } from "../../src/service-worker/modules/crypto"; 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", () => { describe("parseRangeHeader", () => {
it("parses byte ranges", () => { it("parses byte ranges", () => {
@@ -9,6 +15,33 @@ describe("parseRangeHeader", () => {
expect(parseRangeHeader("bytes=0-")).toEqual({ start: 0, end: undefined }); expect(parseRangeHeader("bytes=0-")).toEqual({ start: 0, end: undefined });
expect(parseRangeHeader("bytes=10-20")).toEqual({ start: 10, end: 20 }); 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=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", () => { it("formats Content-Range", () => {
@@ -19,13 +52,13 @@ describe("parseRangeHeader", () => {
}); });
describe("encrypted chunk layout", () => { 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])( it.each([1, CHUNK_SIZE - 1, CHUNK_SIZE, CHUNK_SIZE + 1, 3 * CHUNK_SIZE + 12345])(
"computes the decrypted size of %i bytes", "computes the decrypted size of %i bytes",
(size) => { (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", () => { it("maps a plaintext range to whole encrypted chunks", () => {
const totalSize = 3 * CHUNK_SIZE + 100; const totalSize = 3 * CHUNK_SIZE + 100;
const totalEncryptedSize = encryptedSizeOf(totalSize); const totalEncryptedSize = getEncryptedSize(totalSize);
expect(getEncryptedRange(0, 10, totalEncryptedSize, false)).toEqual({ expect(getEncryptedRange(0, 10, totalEncryptedSize, false)).toEqual({
firstChunkIndex: 0, firstChunkIndex: 0,