파일 시스템 관련 상태와 변경 로직을 도메인 모듈로 통합하여 캐시가 자동으로 갱신되도록 개선하고 오류를 토스트로 표시하도록 변경

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
staticandClaude Opus 5.5 committed 2026-10-12 00:22:25 +09:00
1 parent 2ce73aa331
commit 4e82c5e556
67 files changed
+1091 -1082

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import { trpc } from "#trpc/client.js";
export const login = async (email: string, password: string) => {
await trpc.auth.login.mutate({ email, password });
};
export const logout = async () => {
await trpc.auth.logout.mutate();
};
export const changePassword = async (oldPassword: string, newPassword: string) => {
await trpc.auth.changePassword.mutate({ oldPassword, newPassword });
};
+54
View File
@@ -0,0 +1,54 @@
import {
getDirectoryInfo,
getFileInfo,
setDirectoryFavorite,
setFileFavorite,
type LocalDirectoryInfo,
type SummarizedFileInfo,
} from "#lib/features/filesystem/index.js";
import { sortEntries } from "#lib/utils/index.js";
import { trpc } from "#trpc/client.js";
export type FavoriteEntry =
| { type: "directory"; name: string; details: LocalDirectoryInfo }
| { type: "file"; name: string; details: SummarizedFileInfo };
export const getFavoriteEntries = async () => {
const favorites = await trpc.favorites.get.query();
const [directories, files] = await Promise.all([
Promise.all(
favorites.directories.map((directory) =>
getDirectoryInfo(directory.id, { serverResponse: { ...directory, isFavorite: true } }),
),
),
Promise.all(
favorites.files.map((file) =>
getFileInfo(file.id, { serverResponse: { ...file, isFavorite: true } }),
),
),
]);
return [
...sortEntries(
directories.map((directory): FavoriteEntry => ({
type: "directory",
name: directory.name!,
details: directory as LocalDirectoryInfo,
})),
),
...sortEntries(
files.map((file): FavoriteEntry => ({
type: "file",
name: file.name!,
details: file as SummarizedFileInfo,
})),
),
];
};
export const removeFavorite = async ({ type, details }: FavoriteEntry) => {
if (type === "directory") {
await setDirectoryFavorite(details, false);
} else {
await setFileFavorite(details, false);
}
};
+139
View File
@@ -0,0 +1,139 @@
/* eslint-disable svelte/prefer-svelte-reactivity -- Lookup tables of reactive entries */
import { untrack } from "svelte";
interface FilesystemCacheOptions<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined) => Promise<V>;
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
bulkFetchFromServer?: (keys: Map<K, { cachedValue: V | undefined }>) => Promise<Map<K, V>>;
}
interface Entry<V> {
value?: V;
promise?: Promise<V>;
}
// Caches filesystem entries as reactive objects which are updated in place, so that every view
// of an entry reflects its latest state. Values are first loaded from IndexedDB (if any) and then
// revalidated against the server.
export class FilesystemCache<K, V extends object> {
private map = new Map<K, Entry<V>>();
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
private getOrCreateEntry(key: K) {
let entry = this.map.get(key);
if (!entry) {
const newEntry: Entry<V> = $state({});
entry = newEntry;
this.map.set(key, entry);
}
return entry;
}
private settle(entry: Entry<V>, value: V) {
if (entry.value) {
Object.assign(entry.value, value);
} else {
entry.value = value;
}
return entry.value!;
}
// Returns the cached value synchronously if available, and revalidates it in the background
get(key: K, options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> }) {
return untrack(() => {
const entry = this.getOrCreateEntry(key);
if (entry.promise) return entry.value ?? entry.promise;
const { promise, resolve, reject } = Promise.withResolvers<V>();
promise.catch(() => {}); // Rejections are handled by callers waiting for the promise
entry.promise = promise;
(async () => {
let cachedValue = entry.value && ($state.snapshot(entry.value) as V);
if (!cachedValue) {
cachedValue = await this.options.fetchFromIndexedDB(key);
if (cachedValue) {
resolve(this.settle(entry, cachedValue));
}
}
const loadedValue = await (options?.fetchFromServer?.(cachedValue) ??
this.options.fetchFromServer(key, cachedValue));
resolve(this.settle(entry, loadedValue));
})()
.catch((e) => {
console.error(e);
reject(e);
})
.finally(() => {
entry.promise = undefined;
});
return entry.value ?? promise;
});
}
bulkGet(keys: Set<K>) {
return untrack(() => {
const pending = new Map<K, PromiseWithResolvers<V>>();
for (const key of keys) {
const entry = this.getOrCreateEntry(key);
if (entry.promise) continue;
const resolvers = Promise.withResolvers<V>();
resolvers.promise.catch(() => {});
entry.promise = resolvers.promise;
pending.set(key, resolvers);
}
const settleAll = (values: Map<K, V>) => {
values.forEach((value, key) => {
pending.get(key)?.resolve(this.settle(this.map.get(key)!, value));
});
};
(async () => {
const pendingKeys = [...pending.keys()];
settleAll(
await this.options.bulkFetchFromIndexedDB!(
new Set(pendingKeys.filter((key) => !this.map.get(key)!.value)),
),
);
settleAll(
await this.options.bulkFetchFromServer!(
new Map(pendingKeys.map((key) => [key, { cachedValue: this.map.get(key)!.value }])),
),
);
})()
.catch((e) => {
console.error(e);
pending.forEach(({ reject }) => reject(e));
})
.finally(() => {
pending.forEach((_, key) => {
this.map.get(key)!.promise = undefined;
});
});
const makeResult = () => new Map([...keys].map((key) => [key, this.map.get(key)!.value!]));
const waiting = [...keys]
.map((key) => this.map.get(key)!)
.filter((entry) => !entry.value)
.map((entry) => entry.promise!);
return waiting.length > 0 ? Promise.all(waiting).then(makeResult) : makeResult();
});
}
// Revalidates the entry if it has been loaded
invalidate(key: K) {
if (this.map.has(key)) {
void Promise.resolve(this.get(key)).catch(() => {});
}
}
evict(key: K) {
this.map.delete(key);
}
}
+185
View File
@@ -0,0 +1,185 @@
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
import { isTRPCClientError, trpc } from "#trpc/client.js";
import { FilesystemCache } from "./cache.svelte";
import { invalidateFile } from "./file";
import {
decryptCategoryMetadata,
decryptFileMetadata,
encryptName,
generateEntryKeys,
} from "./metadata";
import { useResource } from "./resource.svelte";
import type { CategoryInfo, DataKey, LocalCategoryInfo, MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
async fetchFromIndexedDB(id) {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category?.files
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
parentId: fileInfo.parentId,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isFavorite: fileInfo.isFavorite,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return {
id,
exists: true,
subCategories,
};
} else if (category) {
return {
id,
exists: true,
parentId: category.parentId,
name: category.name,
subCategories,
files: files?.filter((file) => !!file) ?? [],
isFileRecursive: category.isFileRecursive ?? false,
};
}
},
async fetchFromServer(id, cachedInfo) {
try {
const category = await trpc.category.get.query({ id, recurse: true });
const [subCategories, files, metadata] = await Promise.all([
Promise.all(
category.subCategories.map(async (category) => ({
id: category.id,
parentId: id,
...(await decryptCategoryMetadata(category)),
})),
),
category.files &&
Promise.all(
category.files.map(async (file) => ({
id: file.id,
parentId: file.parent,
contentType: file.contentType,
isFavorite: file.isFavorite,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file)),
})),
),
category.metadata && decryptCategoryMetadata(category.metadata),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: category.metadata!.parent,
subCategories,
files: files!,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
...metadata!,
}
: { id, subCategories },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false };
}
throw e;
}
},
});
const storeToIndexedDB = (info: CategoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeCategoryInfo(info);
// TODO: Bulk Upsert
new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
}
// TODO: Bulk Upsert
info.subCategories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
void IndexedDB.deleteDanglingCategoryInfos(
info.id,
new Set(info.subCategories.map(({ id }) => id)),
);
return { ...info, exists: true as const };
};
export const getCategoryInfo = (id: CategoryId) => {
return cache.get(id);
};
export const useCategory = (getId: () => CategoryId) => {
return useResource(getId, (id) => getCategoryInfo(id));
};
export const invalidateCategory = (id: CategoryId) => {
cache.invalidate(id);
};
export const createCategory = async (parentId: CategoryId, name: string) => {
await trpc.category.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
invalidateCategory(parentId);
};
export const renameCategory = async (
category: { id: number; parentId: CategoryId; dataKey?: DataKey },
newName: string,
) => {
if (!category.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.category.rename.mutate({
id: category.id,
dekVersion: category.dataKey.version,
...(await encryptName(newName, category.dataKey.key)),
});
invalidateCategory(category.parentId);
invalidateCategory(category.id);
};
export const deleteCategory = async (category: { id: number; parentId: CategoryId }) => {
await trpc.category.delete.mutate({ id: category.id });
cache.evict(category.id);
invalidateCategory(category.parentId);
};
export const addFileToCategory = async (fileId: number, categoryId: number) => {
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
invalidateFile(fileId);
invalidateCategory(categoryId);
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId });
invalidateFile(fileId);
invalidateCategory(categoryId);
};
// Whether to show the files of subcategories is a local preference
export const setCategoryFileRecursive = async (category: LocalCategoryInfo, value: boolean) => {
category.isFileRecursive = value;
await IndexedDB.updateCategoryInfo(category.id, { isFileRecursive: value });
};
+62
View File
@@ -0,0 +1,62 @@
import {
deleteFileCache,
deleteFileThumbnailCache,
getFileCache,
storeFileCache,
storeFileThumbnailCache,
} from "#lib/client/cache/index.js";
import { getDecryptedFileUrl, prepareFileDecryption } from "#lib/client/serviceWorker.js";
import { getAllFileInfos } from "#lib/client/storage/indexedDB/index.js";
import { uploadBlob } from "#lib/client/transfer/chunkedUpload.js";
import { downloadFile } from "#lib/client/transfer/index.js";
import { trpc } from "#trpc/client.js";
import type { DataKey } from "./types";
// Returns the decrypted content of the file, from the cache if available
export const getFileContent = async (fileId: number, dataKey: CryptoKey, isLegacy: boolean) => {
const cache = await getFileCache(fileId);
if (cache) return cache;
const fileBuffer = await downloadFile(fileId, dataKey, isLegacy);
void storeFileCache(fileId, fileBuffer);
return fileBuffer;
};
// Returns a URL from which the service worker streams the decrypted file
export const getFileStreamUrl = async (fileId: number, dataKey: CryptoKey, contentType: string) => {
const res = await fetch(`/api/file/${fileId}/download`, { method: "HEAD" });
if (!res.ok) {
throw new Error("Failed to get the file size");
}
const encContentSize = parseInt(res.headers.get("Content-Length") ?? "0", 10);
if (encContentSize <= 0) {
throw new Error("Invalid file size");
}
await prepareFileDecryption(fileId, { isLegacy: false, dataKey, encContentSize, contentType });
return getDecryptedFileUrl(fileId);
};
export const uploadFileThumbnail = async (fileId: number, thumbnail: Blob, dataKey: DataKey) => {
const { uploadId } = await trpc.upload.startFileThumbnailUpload.mutate({
file: fileId,
dekVersion: dataKey.version,
});
await uploadBlob(uploadId, thumbnail, dataKey.key);
await trpc.upload.completeFileThumbnailUpload.mutate({ uploadId });
await storeFileThumbnailCache(fileId, await thumbnail.arrayBuffer());
};
// Deletes local caches of files deleted on other devices
export const cleanupDeletedFileCaches = async () => {
const liveFiles = new Set(await trpc.file.list.query());
const maybeCachedFiles = await getAllFileInfos();
await Promise.all(
maybeCachedFiles
.filter(({ id }) => !liveFiles.has(id))
.flatMap(({ id }) => [deleteFileCache(id), deleteFileThumbnailCache(id)]),
);
};
+195
View File
@@ -0,0 +1,195 @@
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.js";
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
import { isTRPCClientError, trpc } from "#trpc/client.js";
import { FilesystemCache } from "./cache.svelte";
import {
decryptDirectoryMetadata,
decryptFileMetadata,
encryptName,
generateEntryKeys,
} from "./metadata";
import { useResource } from "./resource.svelte";
import type { DataKey, DirectoryInfo, MaybeDirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
async fetchFromIndexedDB(id) {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
IndexedDB.getDirectoryInfos(id),
IndexedDB.getFileInfos(id),
]);
if (id === "root") {
return {
id,
exists: true,
subDirectories,
files,
};
} else if (directory) {
return {
id,
exists: true,
parentId: directory.parentId,
name: directory.name,
subDirectories,
files,
isFavorite: directory.isFavorite,
};
}
},
async fetchFromServer(id) {
try {
const directory = await trpc.directory.get.query({ id });
const [subDirectories, files, metadata] = await Promise.all([
Promise.all(
directory.subDirectories.map(async (directory) => ({
id: directory.id,
parentId: id,
isFavorite: directory.isFavorite,
...(await decryptDirectoryMetadata(directory)),
})),
),
Promise.all(
directory.files.map(async (file) => ({
id: file.id,
parentId: id,
contentType: file.contentType,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file)),
})),
),
directory.metadata && decryptDirectoryMetadata(directory.metadata),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: directory.metadata!.parent,
subDirectories,
files,
isFavorite: directory.metadata!.isFavorite,
...metadata!,
}
: { id, subDirectories, files },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
},
});
const storeToIndexedDB = (info: DirectoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeDirectoryInfo(info);
}
// TODO: Bulk Upsert
info.subDirectories.forEach((subDirectory) => {
void IndexedDB.storeDirectoryInfo(subDirectory);
});
// TODO: Bulk Upsert
info.files.forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
void IndexedDB.deleteDanglingDirectoryInfos(
info.id,
new Set(info.subDirectories.map(({ id }) => id)),
);
void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
return { ...info, exists: true as const };
};
export const getDirectoryInfo = (
id: DirectoryId,
options?: {
serverResponse?: {
parent: DirectoryId;
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
isFavorite: boolean;
};
},
) => {
return cache.get(id, {
fetchFromServer:
options?.serverResponse &&
(async (cachedValue) => {
const metadata = await decryptDirectoryMetadata(options!.serverResponse!);
return storeToIndexedDB({
subDirectories: [],
files: [],
...cachedValue,
id: id as number,
parentId: options!.serverResponse!.parent,
isFavorite: options!.serverResponse!.isFavorite,
...metadata,
});
}),
});
};
export const useDirectory = (getId: () => DirectoryId) => {
return useResource(getId, (id) => getDirectoryInfo(id));
};
export const invalidateDirectory = (id: DirectoryId) => {
cache.invalidate(id);
};
export const createDirectory = async (parentId: DirectoryId, name: string) => {
await trpc.directory.create.mutate({ parent: parentId, ...(await generateEntryKeys(name)) });
invalidateDirectory(parentId);
};
export const renameDirectory = async (
directory: { id: number; parentId: DirectoryId; dataKey?: DataKey },
newName: string,
) => {
if (!directory.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.directory.rename.mutate({
id: directory.id,
dekVersion: directory.dataKey.version,
...(await encryptName(newName, directory.dataKey.key)),
});
invalidateDirectory(directory.parentId);
invalidateDirectory(directory.id);
};
export const deleteDirectory = async (directory: { id: number; parentId: DirectoryId }) => {
const { deletedFiles } = await trpc.directory.delete.mutate({ id: directory.id });
cache.evict(directory.id);
invalidateDirectory(directory.parentId);
await Promise.all(
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
);
};
export const setDirectoryFavorite = async (
directory: { id: number; parentId: DirectoryId },
isFavorite: boolean,
) => {
if (isFavorite) {
await trpc.favorites.addDirectory.mutate({ id: directory.id });
} else {
await trpc.favorites.removeDirectory.mutate({ id: directory.id });
}
invalidateDirectory(directory.parentId);
invalidateDirectory(directory.id);
};
+263
View File
@@ -0,0 +1,263 @@
import { deleteFileCache, deleteFileThumbnailCache } from "#lib/client/cache/index.js";
import * as IndexedDB from "#lib/client/storage/indexedDB/index.js";
import { isTRPCClientError, trpc } from "#trpc/client.js";
import { FilesystemCache } from "./cache.svelte";
import { invalidateDirectory } from "./directory";
import { decryptCategoryMetadata, decryptFileMetadata, encryptName } from "./metadata";
import { useResource } from "./resource.svelte";
import type { DataKey, FileInfo, MaybeFileInfo } from "./types";
const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const file = await IndexedDB.getFileInfo(id);
const categories = file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined;
if (file) {
return {
id,
exists: true,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories?.filter((category) => !!category) ?? [],
isFavorite: file.isFavorite,
};
}
},
async fetchFromServer(id) {
try {
const file = await trpc.file.get.query({ id });
const [categories, metadata] = await Promise.all([
Promise.all(
file.categories.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category)),
})),
),
decryptFileMetadata(file),
]);
return storeToIndexedDB({
id,
isLegacy: file.isLegacy,
parentId: file.parent,
dataKey: metadata.dataKey,
contentType: file.contentType,
name: metadata.name,
createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt,
categories,
isFavorite: file.isFavorite,
});
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
return { id, exists: false as const };
}
throw e;
}
},
async bulkFetchFromIndexedDB(ids) {
const files = await IndexedDB.bulkGetFileInfos([...ids]);
const categories = await Promise.all(
files.map(async (file) =>
file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined,
),
);
return new Map(
files
.filter((file) => !!file)
.map((file, index) => [
file.id,
{
...file,
exists: true,
categories: categories[index]?.filter((category) => !!category) ?? [],
},
]),
);
},
async bulkFetchFromServer(ids) {
const idsArray = [...ids.keys()];
const filesRaw = await trpc.file.bulkGet.query({ ids: idsArray });
const files = await Promise.all(
filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
const [categories, metadata] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category)),
})),
),
decryptFileMetadata(metadataRaw),
]);
return {
id,
exists: true as const,
isLegacy: metadataRaw.isLegacy,
parentId: metadataRaw.parent,
contentType: metadataRaw.contentType,
categories,
isFavorite: metadataRaw.isFavorite,
...metadata,
};
}),
);
const existingIds = new Set(filesRaw.map(({ id }) => id));
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
void IndexedDB.bulkDeleteFileInfos(deletedIds);
return new Map<number, MaybeFileInfo>([
...bulkStoreToIndexedDB(files),
...deletedIds.map((id) => [id, { id, exists: false }] as const),
]);
},
});
const storeToIndexedDB = (info: FileInfo) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
info.categories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return { ...info, exists: true as const };
};
const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
// TODO: Bulk Upsert
infos.forEach((info) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
});
// TODO: Bulk Upsert
new Map(
infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
).forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
};
export const getFileInfo = (
id: number,
options?: {
serverResponse?: {
parent: DirectoryId;
mekVersion: number;
dek: string;
dekVersion: Date;
contentType: string;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
isFavorite: boolean;
};
},
) => {
return cache.get(id, {
fetchFromServer:
options?.serverResponse &&
(async (cachedValue) => {
const metadata = await decryptFileMetadata(options!.serverResponse!);
return storeToIndexedDB({
categories: [],
...cachedValue,
id,
parentId: options!.serverResponse!.parent,
contentType: options!.serverResponse!.contentType,
isFavorite: options!.serverResponse!.isFavorite,
...metadata,
});
}),
});
};
export const bulkGetFileInfo = (ids: number[]) => {
return cache.bulkGet(new Set(ids));
};
export const useFile = (getId: () => number) => {
return useResource(getId, (id) => getFileInfo(id));
};
export const invalidateFile = (id: number) => {
cache.invalidate(id);
};
export const renameFile = async (
file: { id: number; parentId: DirectoryId; dataKey?: DataKey },
newName: string,
) => {
if (!file.dataKey) {
throw new Error("Data key not loaded");
}
await trpc.file.rename.mutate({
id: file.id,
dekVersion: file.dataKey.version,
...(await encryptName(newName, file.dataKey.key)),
});
invalidateDirectory(file.parentId);
invalidateFile(file.id);
};
export const deleteFile = async (file: { id: number; parentId: DirectoryId }) => {
await trpc.file.delete.mutate({ id: file.id });
cache.evict(file.id);
invalidateDirectory(file.parentId);
await Promise.all([deleteFileCache(file.id), deleteFileThumbnailCache(file.id)]);
};
export const setFileFavorite = async (
file: { id: number; parentId: DirectoryId },
isFavorite: boolean,
) => {
if (isFavorite) {
await trpc.favorites.addFile.mutate({ id: file.id });
} else {
await trpc.favorites.removeFile.mutate({ id: file.id });
}
invalidateDirectory(file.parentId);
invalidateFile(file.id);
};
+6
View File
@@ -0,0 +1,6 @@
export * from "./category";
export * from "./content";
export * from "./directory";
export * from "./file";
export * from "./types";
export * from "./upload";
+83
View File
@@ -0,0 +1,83 @@
import { keyring } from "#lib/client/keys/index.js";
import {
decryptString,
encryptString,
generateDataKey,
unwrapDataKey,
wrapDataKey,
} from "#lib/shared/crypto/index.js";
const unwrapEntryDataKey = async (metadata: { mekVersion: number; dek: string }) => {
const { dataKey } = await unwrapDataKey(
metadata.dek,
keyring.getMasterKey(metadata.mekVersion).key,
);
return dataKey;
};
export const decryptDirectoryMetadata = async (metadata: {
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
}) => {
const dataKey = await unwrapEntryDataKey(metadata);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
};
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const decryptFileMetadata = async (metadata: {
mekVersion: number;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
}) => {
const dataKey = await unwrapEntryDataKey(metadata);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(metadata.name, metadata.nameIv, dataKey),
metadata.createdAt
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
: undefined,
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
]);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
createdAt,
lastModifiedAt,
};
};
export const decryptCategoryMetadata = decryptDirectoryMetadata;
export const encryptName = async (name: string, dataKey: CryptoKey) => {
const { ciphertext, iv } = await encryptString(name, dataKey);
return { name: ciphertext, nameIv: iv };
};
// Generates the keys of a new directory or category encrypted with the active master key
export const generateEntryKeys = async (name: string) => {
const masterKey = keyring.activeMasterKey;
const { dataKey, dataKeyVersion } = await generateDataKey();
return {
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion,
...(await encryptName(name, dataKey)),
};
};
@@ -0,0 +1,33 @@
import { untrack } from "svelte";
// Exposes the value of a cache entry identified by a reactive key. Cached values are available
// synchronously, so that views don't flicker while revalidating.
export const useResource = <K, V>(getKey: () => K, load: (key: K) => V | Promise<V>) => {
let current: V | undefined = $state();
$effect.pre(() => {
const key = getKey();
let isStale = false;
const result = untrack(() => load(key));
if (result instanceof Promise) {
result
.then((value) => {
if (!isStale) current = value;
})
.catch(() => {}); // Logged by the cache
} else {
current = result;
}
return () => {
isStale = true;
};
});
return {
get current() {
return current;
},
};
};
+79
View File
@@ -0,0 +1,79 @@
export type DataKey = { key: CryptoKey; version: Date };
export interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite: boolean;
}
export interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite?: undefined;
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
export interface FileInfo {
id: number;
isLegacy?: boolean;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: FileCategoryInfo[];
isFavorite: boolean;
}
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
export interface LocalCategoryInfo {
id: number;
parentId: CategoryId;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
export interface RootCategoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;
+22
View File
@@ -0,0 +1,22 @@
import { storeFileCache, storeFileThumbnailCache } from "#lib/client/cache/index.js";
import { uploadFile as doUploadFile } from "#lib/client/transfer/index.js";
import { invalidateDirectory } from "./directory";
// Uploads the file and caches it locally. Returns `false` if the upload is canceled.
export const uploadFile = async (
file: File,
parentId: DirectoryId,
onDuplicate: () => Promise<boolean>,
) => {
const res = await doUploadFile(file, parentId, onDuplicate);
if (!res) return false;
invalidateDirectory(parentId);
if (res.fileBuffer) {
void storeFileCache(res.fileId, res.fileBuffer);
}
if (res.thumbnailBuffer) {
void storeFileThumbnailCache(res.fileId, res.thumbnailBuffer);
}
return true;
};
+41
View File
@@ -0,0 +1,41 @@
import {
getDirectoryInfo,
getFileInfo,
type FileInfo,
type LocalCategoryInfo,
type LocalDirectoryInfo,
} from "#lib/features/filesystem/index.js";
import { trpc } from "#trpc/client.js";
export interface SearchFilter {
ancestorId: DirectoryId;
inFavorites: boolean;
categories: { info: LocalCategoryInfo; type: "include" | "exclude" }[];
}
export interface SearchResult {
directories: LocalDirectoryInfo[];
files: FileInfo[];
}
const getCategoryIds = (filter: SearchFilter, type: "include" | "exclude") => {
return filter.categories.filter((category) => category.type === type).map(({ info }) => info.id);
};
export const search = async (filter: SearchFilter) => {
const { directories: directoriesRaw, files: filesRaw } = await trpc.search.search.query({
ancestor: filter.ancestorId,
inFavorites: filter.inFavorites,
includeCategories: getCategoryIds(filter, "include"),
excludeCategories: getCategoryIds(filter, "exclude"),
});
const [directories, files] = await Promise.all([
Promise.all(
directoriesRaw.map((directory) =>
getDirectoryInfo(directory.id, { serverResponse: directory }),
),
),
Promise.all(filesRaw.map((file) => getFileInfo(file.id, { serverResponse: file }))),
]);
return { directories, files } as SearchResult;
};