4 Commits

45 changed files with 842 additions and 975 deletions

View File

@@ -1,42 +1,34 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileInfo } from "$lib/modules/filesystem";
import { browser } from "$app/environment";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
interface Props {
info: Writable<FileInfo | null>;
onclick?: (file: FileInfo) => void;
info: SummarizedFileInfo;
onclick?: (file: SummarizedFileInfo) => void;
}
let { info, onclick }: Props = $props();
let thumbnail: string | undefined = $state();
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
let showThumbnail = $derived(
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
);
let thumbnailPromise = $derived(
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
);
</script>
{#if $info}
<button
onclick={() => onclick?.($info)}
<button
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
>
>
{#await thumbnailPromise}
<div class="h-full w-full bg-gray-100"></div>
{:then thumbnail}
{#if thumbnail}
<img src={thumbnail} alt={$info.name} class="h-full w-full object-cover" />
<img src={thumbnail} alt={info.name} class="h-full w-full object-cover" />
{:else}
<div class="h-full w-full bg-gray-100"></div>
{/if}
</button>
{/if}
{/await}
</button>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import type { SubCategoryInfo } from "$lib/modules/filesystem2.svelte";
import type { SubCategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/utils";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";

View File

@@ -3,7 +3,7 @@
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem2.svelte";
import type { SubCategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {

View File

@@ -1,5 +1,7 @@
import type { DataKey } from "$lib/modules/filesystem";
export interface SelectedCategory {
id: number;
dataKey?: { key: CryptoKey; version: Date };
dataKey?: DataKey;
name: string;
}

View File

@@ -2,7 +2,7 @@
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem2.svelte";
import type { CategoryInfo } from "$lib/modules/filesystem";
import IconAddCircle from "~icons/material-symbols/add-circle";

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { CheckBox, RowVirtualizer } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem2.svelte";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
import File from "./File.svelte";
import type { SelectedFile } from "./service";

View File

@@ -2,7 +2,7 @@
import { browser } from "$app/environment";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { CategoryFileInfo } from "$lib/modules/filesystem2.svelte";
import type { CategoryFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
import type { SelectedFile } from "./service";

View File

@@ -1,97 +1,47 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton, RowVirtualizer } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
interface Props {
files: Writable<FileInfo | null>[];
onFileClick?: (file: FileInfo) => void;
files: SummarizedFileInfo[];
onFileClick?: (file: SummarizedFileInfo) => void;
}
let { files, onFileClick }: Props = $props();
type FileEntry =
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row =
| { type: "header"; label: string }
| { type: "items"; items: FileEntry[]; isLast: boolean };
| { type: "items"; files: SummarizedFileInfo[]; isLast: boolean };
let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]);
$effect(() => {
filesWithDate = files.map((file) => {
const info = get(file);
if (info) {
return {
date: info.createdAt ?? info.lastModifiedAt,
contentType: info.contentType,
info: file,
};
} else {
return { info: file };
}
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (
!file.date ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
continue;
}
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
label: formatDate(entries[0]!.date!),
});
for (let i = 0; i < entries.length; i += 4) {
newRows.push({
type: "items",
items: entries.slice(i, i + 4),
isLast: i + 4 >= entries.length,
});
}
}
rows = newRows;
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
const newContentType = value?.contentType;
if (file.date?.getTime() === newDate?.getTime() && file.contentType === newContentType) {
return;
}
file.date = newDate;
file.contentType = newContentType;
buildRows();
}),
let rows = $derived.by(() => {
const groups = Map.groupBy(
files.filter(
(file) => file.contentType.startsWith("image/") || file.contentType.startsWith("video/"),
),
(file) => formatDateSortable(file.createdAt ?? file.lastModifiedAt),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
return Array.from(groups.entries())
.sort(([dateA], [dateB]) => dateB.localeCompare(dateA))
.flatMap(([_, entries]) => {
const sortedEntries = [...entries];
sortEntries(sortedEntries, SortBy.DATE_DESC);
return [
{
type: "header",
label: formatDate(sortedEntries[0]!.createdAt ?? sortedEntries[0]!.lastModifiedAt),
},
...Array.from({ length: Math.ceil(sortedEntries.length / 4) }, (_, i) => {
const start = i * 4;
const end = start + 4;
return {
type: "items" as const,
files: sortedEntries.slice(start, end),
isLast: end >= sortedEntries.length,
};
}),
] satisfies Row[];
});
});
</script>
@@ -101,8 +51,8 @@
itemHeight={(index) =>
rows[index]!.type === "header"
? 28
: Math.ceil(rows[index]!.items.length / 4) * 181 +
(Math.ceil(rows[index]!.items.length / 4) - 1) * 4 +
: Math.ceil(rows[index]!.files.length / 4) * 181 +
(Math.ceil(rows[index]!.files.length / 4) - 1) * 4 +
16}
class="flex flex-grow flex-col"
>
@@ -112,8 +62,8 @@
<p class="pb-2 text-sm font-medium">{row.label}</p>
{:else}
<div class={["grid grid-cols-4 gap-x-1", row.isLast ? "pb-4" : "pb-1"]}>
{#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{#each row.files as file}
<FileThumbnailButton info={file} onclick={onFileClick} />
{/each}
</div>
{/if}
@@ -123,8 +73,6 @@
<p class="text-gray-500">
{#if files.length === 0}
업로드된 파일이 없어요.
{:else if filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else}
사진 또는 동영상이 없어요.
{/if}

View File

@@ -0,0 +1,95 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { decryptData } from "$lib/modules/crypto";
export interface FileDownloadState {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
type LiveFileDownloadState = FileDownloadState & {
status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
};
let downloadingFiles: FileDownloadState[] = $state([]);
export const isFileDownloading = (
status: FileDownloadState["status"],
): status is LiveFileDownloadState["status"] =>
["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
export const getFileDownloadState = (fileId: number) => {
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
};
export const getDownloadingFiles = () => {
return downloadingFiles.filter((file) => isFileDownloading(file.status));
};
export const clearDownloadedFiles = () => {
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
};
const requestFileDownload = limitFunction(
async (state: FileDownloadState, id: number) => {
state.status = "downloading";
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
state.progress = progress;
state.rate = rate;
state.estimated = estimated;
},
});
const fileEncrypted: ArrayBuffer = res.data;
state.status = "decryption-pending";
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
state: FileDownloadState,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
state.status = "decrypting";
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
state.status = "decrypted";
state.result = fileBuffer;
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
downloadingFiles.push({
id,
status: "download-pending",
});
const state = downloadingFiles.at(-1)!;
try {
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
} catch (e) {
state.status = "error";
throw e;
}
};

View File

@@ -1,84 +0,0 @@
import axios from "axios";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import { decryptData } from "$lib/modules/crypto";
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
const requestFileDownload = limitFunction(
async (status: Writable<FileDownloadStatus>, id: number) => {
status.update((value) => {
value.status = "downloading";
return value;
});
const res = await axios.get(`/api/file/${id}/download`, {
responseType: "arraybuffer",
onDownloadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const fileEncrypted: ArrayBuffer = res.data;
status.update((value) => {
value.status = "decryption-pending";
return value;
});
return fileEncrypted;
},
{ concurrency: 1 },
);
const decryptFile = limitFunction(
async (
status: Writable<FileDownloadStatus>,
fileEncrypted: ArrayBuffer,
fileEncryptedIv: string,
dataKey: CryptoKey,
) => {
status.update((value) => {
value.status = "decrypting";
return value;
});
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
status.update((value) => {
value.status = "decrypted";
value.result = fileBuffer;
return value;
});
return fileBuffer;
},
{ concurrency: 4 },
);
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
const status = writable<FileDownloadStatus>({
id,
status: "download-pending",
});
fileDownloadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
return await decryptFile(
status,
await requestFileDownload(status, id),
fileEncryptedIv,
dataKey,
);
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -1,3 +1,3 @@
export * from "./cache";
export * from "./download";
export * from "./download.svelte";
export * from "./upload.svelte";

View File

@@ -1,106 +0,0 @@
import { get, writable, type Writable } from "svelte/store";
import {
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client";
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
if (get(info)) return;
const file = await getFileInfoFromIndexedDB(id);
if (!file) return;
info.set(file);
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
const fetchFileInfoFromServer = async (
id: number,
info: Writable<FileInfo | null>,
masterKey: CryptoKey,
) => {
let metadata;
try {
metadata = await trpc().file.get.query({ id });
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteFileInfo(id);
return;
}
throw new Error("Failed to fetch file information");
}
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
const createdAt =
metadata.createdAt && metadata.createdAtIv
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
: undefined;
const lastModifiedAt = await decryptDate(
metadata.lastModifiedAt,
metadata.lastModifiedAtIv,
dataKey,
);
info.set({
id,
parentId: metadata.parent,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, info, masterKey); // Intended
return info;
};

View File

@@ -0,0 +1,104 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { CategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, CategoryInfo, Partial<CategoryInfo>>();
const fetchFromIndexedDB = async (id: CategoryId) => {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return { id, subCategories };
} else if (category) {
return {
id,
name: category.name,
subCategories,
files: files!.filter((file) => !!file),
isFileRecursive: category.isFileRecursive,
};
}
};
const fetchFromServer = async (id: CategoryId, masterKey: CryptoKey) => {
try {
const {
metadata,
subCategories: subCategoriesRaw,
files: filesRaw,
} = await trpc().category.get.query({ id });
const [subCategories, files] = await Promise.all([
Promise.all(
subCategoriesRaw.map(async (category) => ({
id: category.id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
filesRaw
? Promise.all(
filesRaw.map(async (file) => ({
id: file.id,
contentType: file.contentType,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file, masterKey)),
})),
)
: undefined,
]);
if (id === "root") {
return { id, subCategories };
} else {
return {
id,
subCategories,
files,
...(await decryptCategoryMetadata(metadata!, masterKey)),
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
cache.delete(id);
await IndexedDB.deleteCategoryInfo(id as number);
return;
}
throw e;
}
};
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
return await cache.get(id, async (isInitial, resolve) => {
if (isInitial) {
const info = await fetchFromIndexedDB(id);
if (info) {
resolve(info);
}
}
const info = await fetchFromServer(id, masterKey);
if (info) {
resolve(info);
}
});
};

View File

@@ -0,0 +1,74 @@
import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { DirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, DirectoryInfo>();
const fetchFromIndexedDB = async (id: DirectoryId) => {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
IndexedDB.getDirectoryInfos(id),
IndexedDB.getFileInfos(id),
]);
if (id === "root") {
return { id, subDirectories, files };
} else if (directory) {
return { id, parentId: directory.parentId, name: directory.name, subDirectories, files };
}
};
const fetchFromServer = async (id: DirectoryId, masterKey: CryptoKey) => {
try {
const {
metadata,
subDirectories: subDirectoriesRaw,
files: filesRaw,
} = await trpc().directory.get.query({ id });
const [subDirectories, files] = await Promise.all([
Promise.all(
subDirectoriesRaw.map(async (directory) => ({
id: directory.id,
...(await decryptDirectoryMetadata(directory, masterKey)),
})),
),
Promise.all(
filesRaw.map(async (file) => ({
id: file.id,
contentType: file.contentType,
...(await decryptFileMetadata(file, masterKey)),
})),
),
]);
if (id === "root") {
return { id, subDirectories, files };
} else {
return {
id,
parentId: metadata!.parent,
subDirectories,
files,
...(await decryptDirectoryMetadata(metadata!, masterKey)),
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
cache.delete(id);
await IndexedDB.deleteDirectoryInfo(id as number);
return;
}
throw e;
}
};
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) =>
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
};

View File

@@ -0,0 +1,70 @@
import * as IndexedDB from "$lib/indexedDB";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { FileInfo } from "./types";
const cache = new FilesystemCache<number, FileInfo>();
const fetchFromIndexedDB = async (id: number) => {
const file = await IndexedDB.getFileInfo(id);
const categories = file
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category ? { id: category.id, name: category.name } : undefined;
}),
)
: undefined;
if (file) {
return {
id,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories!.filter((category) => !!category),
};
}
};
const fetchFromServer = async (id: number, masterKey: CryptoKey) => {
try {
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id });
const [categories] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
]);
return {
id,
parentId: metadata.parent,
contentType: metadata.contentType,
contentIv: metadata.contentIv,
categories,
...(await decryptFileMetadata(metadata, masterKey)),
};
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
cache.delete(id);
await IndexedDB.deleteFileInfo(id);
return;
}
throw e;
}
};
export const getFileInfo = async (id: number, masterKey: CryptoKey) => {
return await cache.get(id, (isInitial, resolve) =>
monotonicResolve(
[isInitial && fetchFromIndexedDB(id), fetchFromServer(id, masterKey)],
resolve,
),
);
};

View File

@@ -0,0 +1,4 @@
export * from "./category";
export * from "./directory";
export * from "./file";
export * from "./types";

View File

@@ -0,0 +1,86 @@
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
export class FilesystemCache<K, V extends RV, RV = V> {
private map = new Map<K, V | Promise<V>>();
get(key: K, loader: (isInitial: boolean, resolve: (value: RV | undefined) => void) => void) {
const info = this.map.get(key);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<V>();
if (!info) {
this.map.set(key, promise);
}
loader(!info, (loadedInfo) => {
if (!loadedInfo) return;
let info = this.map.get(key)!;
if (info instanceof Promise) {
const state = $state(loadedInfo);
this.map.set(key, state as V);
resolve(state as V);
} else {
Object.assign(info, loadedInfo);
resolve(info);
}
});
return info ?? promise;
}
delete(key: K) {
this.map.delete(key);
}
}
export const decryptDirectoryMetadata = async (
metadata: { dek: string; dekVersion: Date; name: string; nameIv: string },
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
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: {
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
},
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
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;

View File

@@ -0,0 +1,61 @@
export type DataKey = { key: CryptoKey; version: Date };
interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
export interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: { id: number; name: string }[];
}
export type SummarizedFileInfo = Omit<FileInfo, "parentId" | "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo {
id: number;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
interface RootCategoryInfo {
id: "root";
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;

View File

@@ -1,341 +0,0 @@
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
deleteFileInfo,
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { monotonicResolve } from "$lib/utils";
import { trpc, isTRPCClientError } from "$trpc/client";
type DataKey = { key: CryptoKey; version: Date };
interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
interface FileInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
contentIv: string | undefined;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: { id: number; name: string }[];
}
export type SummarizedFileInfo = Omit<FileInfo, "parentId" | "contentIv" | "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
interface LocalCategoryInfo {
id: number;
dataKey?: DataKey | undefined;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
interface RootCategoryInfo {
id: "root";
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
const directoryInfoCache = new Map<DirectoryId, DirectoryInfo | Promise<DirectoryInfo>>();
const categoryInfoCache = new Map<CategoryId, CategoryInfo | Promise<CategoryInfo>>();
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
const info = directoryInfoCache.get(id);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<DirectoryInfo>();
if (!info) {
directoryInfoCache.set(id, promise);
}
monotonicResolve(
[!info && fetchDirectoryInfoFromIndexedDB(id), fetchDirectoryInfoFromServer(id, masterKey)],
(directoryInfo) => {
let info = directoryInfoCache.get(id);
if (info instanceof Promise) {
const state = $state(directoryInfo);
directoryInfoCache.set(id, state);
resolve(state);
} else {
Object.assign(info!, directoryInfo);
resolve(info!);
}
},
);
return info ?? promise;
};
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
): Promise<DirectoryInfo | undefined> => {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
if (id === "root") {
return { id, subDirectories, files };
} else if (directory) {
return { id, parentId: directory.parentId, name: directory.name, subDirectories, files };
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
masterKey: CryptoKey,
): Promise<DirectoryInfo | undefined> => {
try {
const {
metadata,
subDirectories: subDirectoriesRaw,
files: filesRaw,
} = await trpc().directory.get.query({ id });
const [subDirectories, files] = await Promise.all([
Promise.all(
subDirectoriesRaw.map(async (directory) => {
const { dataKey } = await unwrapDataKey(directory.dek, masterKey);
const name = await decryptString(directory.name, directory.nameIv, dataKey);
return {
id: directory.id,
dataKey: { key: dataKey, version: directory.dekVersion },
name,
};
}),
),
Promise.all(
filesRaw.map(async (file) => {
const { dataKey } = await unwrapDataKey(file.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(file.name, file.nameIv, dataKey),
file.createdAt ? decryptDate(file.createdAt, file.createdAtIv!, dataKey) : undefined,
decryptDate(file.lastModifiedAt, file.lastModifiedAtIv, dataKey),
]);
return {
id: file.id,
dataKey: { key: dataKey, version: file.dekVersion },
contentType: file.contentType,
name,
createdAt,
lastModifiedAt,
};
}),
),
]);
if (id === "root") {
return { id, subDirectories, files };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
return {
id,
parentId: metadata!.parent,
dataKey: { key: dataKey, version: metadata!.dekVersion },
name,
subDirectories,
files,
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
directoryInfoCache.delete(id);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
const info = categoryInfoCache.get(id);
if (info instanceof Promise) {
return info;
}
const { promise, resolve } = Promise.withResolvers<CategoryInfo>();
if (!info) {
categoryInfoCache.set(id, promise);
const categoryInfo = await fetchCategoryInfoFromIndexedDB(id);
if (categoryInfo) {
const state = $state(categoryInfo);
categoryInfoCache.set(id, state);
resolve(state);
}
}
fetchCategoryInfoFromServer(id, masterKey).then((categoryInfo) => {
if (!categoryInfo) return;
let info = categoryInfoCache.get(id);
if (info instanceof Promise) {
const state = $state(categoryInfo);
categoryInfoCache.set(id, state);
resolve(state);
} else {
Object.assign(info!, categoryInfo);
resolve(info!);
}
});
return info ?? promise;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
): Promise<CategoryInfo | undefined> => {
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const files = category
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await getFileInfoFromIndexedDB(file.id);
return fileInfo
? {
id: file.id,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return { id, subCategories };
} else if (category) {
return {
id,
name: category.name,
subCategories,
files: files!.filter((file) => !!file),
isFileRecursive: category.isFileRecursive,
};
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
masterKey: CryptoKey,
): Promise<CategoryInfo | undefined> => {
try {
const {
metadata,
subCategories: subCategoriesRaw,
files: filesRaw,
} = await trpc().category.get.query({ id, recurse: true });
const [subCategories, files] = await Promise.all([
Promise.all(
subCategoriesRaw.map(async (category) => {
const { dataKey } = await unwrapDataKey(category.dek, masterKey);
const name = await decryptString(category.name, category.nameIv, dataKey);
return {
id: category.id,
dataKey: { key: dataKey, version: category.dekVersion },
name,
};
}),
),
id !== "root"
? Promise.all(
filesRaw!.map(async (file) => {
const { dataKey } = await unwrapDataKey(file.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(file.name, file.nameIv, dataKey),
file.createdAt
? decryptDate(file.createdAt, file.createdAtIv!, dataKey)
: undefined,
decryptDate(file.lastModifiedAt, file.lastModifiedAtIv, dataKey),
]);
return {
id: file.id,
dataKey: { key: dataKey, version: file.dekVersion },
contentType: file.contentType,
name,
createdAt,
lastModifiedAt,
isRecursive: file.isRecursive,
};
}),
)
: undefined,
]);
if (id === "root") {
return { id, subCategories };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
return {
id,
dataKey: { key: dataKey, version: metadata!.dekVersion },
name,
subCategories,
files: files!,
isFileRecursive: false,
};
}
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
categoryInfoCache.delete(id);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
};

View File

@@ -1,4 +1,4 @@
import { sql, type NotNull } from "kysely";
import { sql } from "kysely";
import pg from "pg";
import { IntegrityError } from "./error";
import db from "./kysely";
@@ -486,10 +486,17 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => {
export const getAllFileCategories = async (fileId: number) => {
const categories = await db
.selectFrom("file_category")
.select("category_id")
.innerJoin("category", "file_category.category_id", "category.id")
.selectAll("category")
.where("file_id", "=", fileId)
.execute();
return categories.map(({ category_id }) => ({ id: category_id }));
return categories.map((category) => ({
id: category.id,
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}));
};
export const removeFileFromCategory = async (fileId: number, categoryId: number) => {

View File

@@ -1,25 +0,0 @@
import { writable, type Writable } from "svelte/store";
export interface FileDownloadStatus {
id: number;
status:
| "download-pending"
| "downloading"
| "decryption-pending"
| "decrypting"
| "decrypted"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
result?: ArrayBuffer;
}
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
export const isFileDownloading = (
status: FileDownloadStatus["status"],
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
};

View File

@@ -1,2 +1 @@
export * from "./file";
export * from "./key";

View File

@@ -1,13 +1,13 @@
export const monotonicResolve = <T>(
promises: (Promise<T | undefined> | false)[],
promises: (Promise<T> | false)[],
callback: (value: T) => void,
) => {
let latestResolvedIndex = -1;
promises.forEach((promise, index) => {
if (!promise) return;
promises
.filter((promise) => !!promise)
.forEach((promise, index) => {
promise.then((value) => {
if (value !== undefined && index > latestResolvedIndex) {
if (index > latestResolvedIndex) {
latestResolvedIndex = index;
callback(value);
}

View File

@@ -1,14 +1,14 @@
<script lang="ts">
import FileSaver from "file-saver";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import { getFileDownloadState } from "$lib/modules/file";
import { masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
import DownloadStatus from "./DownloadStatus.svelte";
import {
@@ -26,19 +26,13 @@
let { data } = $props();
let info: Writable<FileInfo | null> | undefined = $state();
// let categories: Writable<CategoryInfo | null>[] = $state([]);
let infoPromise: Promise<FileInfo | null> | undefined = $state();
let info: FileInfo | null = $state(null);
let downloadState = $derived(getFileDownloadState(data.id));
let isMenuOpen = $state(false);
let isAddToCategoryBottomSheetOpen = $state(false);
let downloadStatus = $derived(
$fileDownloadStatusStore.find((statusStore) => {
const { id, status } = get(statusStore);
return id === data.id && isFileDownloading(status);
}),
);
let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state();
let fileBlob: Blob | undefined = $state();
@@ -71,28 +65,27 @@
const addToCategory = async (categoryId: number) => {
await requestFileAdditionToCategory(data.id, categoryId);
isAddToCategoryBottomSheetOpen = false;
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
const removeFromCategory = async (categoryId: number) => {
await requestFileRemovalFromCategory(data.id, categoryId);
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
};
$effect(() => {
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!).then((fileInfo) => {
info = fileInfo;
return fileInfo;
});
info = null;
isDownloadRequested = false;
viewerType = undefined;
});
// $effect(() => {
// categories =
// $info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
// });
$effect(() => {
if ($info && $info.dataKey && $info.contentIv) {
const contentType = $info.contentType;
if (info?.dataKey) {
const contentType = info.contentType;
if (contentType.startsWith("image")) {
viewerType = "image";
} else if (contentType.startsWith("video")) {
@@ -100,24 +93,24 @@
}
untrack(() => {
if (!downloadStatus && !isDownloadRequested) {
if (!downloadState && !isDownloadRequested) {
isDownloadRequested = true;
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
requestFileDownload(data.id, info!.contentIv!, info!.dataKey!.key).then(
async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, $info.name);
FileSaver.saveAs(blob, info!.name);
}
});
},
);
}
});
}
});
$effect(() => {
if ($info && $downloadStatus?.status === "decrypted") {
untrack(
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
if (info && downloadState?.status === "decrypted") {
untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType));
}
});
@@ -128,7 +121,8 @@
<title>파일</title>
</svelte:head>
<TopBar title={$info?.name}>
{#if info}
<TopBar title={info.name}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div onclick={(e) => e.stopPropagation()}>
@@ -141,17 +135,19 @@
<TopBarMenu
bind:isOpen={isMenuOpen}
directoryId={["category", "gallery"].includes(page.url.searchParams.get("from") ?? "")
? $info?.parentId
? info.parentId
: undefined}
{fileBlob}
filename={$info?.name}
filename={info.name}
/>
</div>
</TopBar>
<FullscreenDiv>
</TopBar>
<FullscreenDiv>
<div class="space-y-4 pb-4">
<DownloadStatus status={downloadStatus} />
{#if $info && viewerType}
{#if downloadState}
<DownloadStatus state={downloadState} />
{/if}
{#if viewerType}
<div class="flex w-full justify-center">
{#snippet viewerLoading(message: string)}
<p class="text-gray-500">{message}</p>
@@ -159,7 +155,7 @@
{#if viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} />
<img src={fileBlobUrl} alt={info.name} onerror={convertHeicToJpeg} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
@@ -170,7 +166,7 @@
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton
icon={IconCamera}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)}
onclick={() => updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)}
class="w-full"
>
이 장면을 썸네일로 설정하기
@@ -185,12 +181,12 @@
<div class="space-y-2">
<p class="text-lg font-bold">카테고리</p>
<div class="space-y-1">
<!-- <Categories
{categories}
<Categories
categories={info.categories}
categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)}
/> -->
/>
<IconEntryButton
icon={IconAddCircle}
onclick={() => (isAddToCategoryBottomSheetOpen = true)}
@@ -203,9 +199,10 @@
</div>
</div>
</div>
</FullscreenDiv>
</FullscreenDiv>
<AddToCategoryBottomSheet
<AddToCategoryBottomSheet
bind:isOpen={isAddToCategoryBottomSheetOpen}
onAddToCategoryClick={addToCategory}
/>
/>
{/if}

View File

@@ -2,7 +2,7 @@
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms";
import { SubCategories } from "$lib/components/molecules";
import { CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem2.svelte";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { requestCategoryCreation } from "./service";

View File

@@ -1,32 +1,31 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { isFileDownloading, type FileDownloadState } from "$lib/modules/file";
import { formatNetworkSpeed } from "$lib/utils";
interface Props {
status?: Writable<FileDownloadStatus>;
state: FileDownloadState;
}
let { status }: Props = $props();
let { state }: Props = $props();
</script>
{#if $status && isFileDownloading($status.status)}
{#if isFileDownloading(state.status)}
<div class="w-full rounded-xl bg-gray-100 p-3">
<p class="font-medium">
{#if $status.status === "download-pending"}
{#if state.status === "download-pending"}
다운로드를 기다리는 중
{:else if $status.status === "downloading"}
{:else if state.status === "downloading"}
다운로드하는 중
{:else if $status.status === "decryption-pending"}
{:else if state.status === "decryption-pending"}
복호화를 기다리는 중
{:else if $status.status === "decrypting"}
{:else if state.status === "decrypting"}
복호화하는 중
{/if}
</p>
<p class="text-xs">
{#if $status.status === "downloading"}
{#if state.status === "downloading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
{Math.floor((state.progress ?? 0) * 100)}% · {formatNetworkSpeed((state.rate ?? 0) * 8)}
{/if}
</p>
</div>

View File

@@ -1,19 +1,17 @@
<script lang="ts">
import { get } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
import { getDownloadingFiles, clearDownloadedFiles } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
let downloadingFiles = $derived(
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
const downloadingFiles = getDownloadingFiles();
const filesPromise = $derived(
Promise.all(downloadingFiles.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!))),
);
$effect(() => () => {
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
});
$effect(() => clearDownloadedFiles);
</script>
<svelte:head>
@@ -22,9 +20,11 @@
<TopBar />
<FullscreenDiv>
{#await filesPromise then files}
<div class="space-y-2 pb-4">
{#each downloadingFiles as status}
<File {status} />
{#each files as file, index}
<File state={downloadingFiles[index]!} info={file} />
{/each}
</div>
{/await}
</FullscreenDiv>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { get, type Writable } from "svelte/store";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
import type { FileDownloadState } from "$lib/modules/file";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/utils";
import IconCloud from "~icons/material-symbols/cloud";
@@ -12,56 +11,49 @@
import IconError from "~icons/material-symbols/error";
interface Props {
status: Writable<FileDownloadStatus>;
info: FileInfo;
state: FileDownloadState;
}
let { status }: Props = $props();
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
let { info, state }: Props = $props();
</script>
{#if $fileInfo}
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex h-14 items-center gap-x-4 p-2">
<div class="flex-shrink-0 text-lg text-gray-600">
{#if $status.status === "download-pending"}
{#if state.status === "download-pending"}
<IconCloud />
{:else if $status.status === "downloading"}
{:else if state.status === "downloading"}
<IconCloudDownload />
{:else if $status.status === "decryption-pending"}
{:else if state.status === "decryption-pending"}
<IconLock />
{:else if $status.status === "decrypting"}
{:else if state.status === "decrypting"}
<IconLockClock />
{:else if $status.status === "decrypted"}
{:else if state.status === "decrypted"}
<IconCheckCircle class="text-green-500" />
{:else if $status.status === "error"}
{:else if state.status === "error"}
<IconError class="text-red-500" />
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
<p title={info.name} class="truncate font-medium">
{info.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "download-pending"}
{#if state.status === "download-pending"}
다운로드를 기다리는 중
{:else if $status.status === "downloading"}
{:else if state.status === "downloading"}
전송됨
{Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{:else if $status.status === "decryption-pending"}
{Math.floor((state.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((state.rate ?? 0) * 8)}
{:else if state.status === "decryption-pending"}
복호화를 기다리는 중
{:else if $status.status === "decrypting"}
{:else if state.status === "decrypting"}
복호화하는 중
{:else if $status.status === "decrypted"}
{:else if state.status === "decrypted"}
다운로드 완료
{:else if $status.status === "error"}
{:else if state.status === "error"}
다운로드 실패
{/if}
</p>
</div>
</div>
{/if}
</div>

View File

@@ -4,6 +4,8 @@
import { getUploadingFiles, clearUploadedFiles } from "$lib/modules/file";
import File from "./File.svelte";
const uploadingFiles = getUploadingFiles();
$effect(() => clearUploadedFiles);
</script>
@@ -14,7 +16,7 @@
<TopBar />
<FullscreenDiv>
<div class="space-y-2 pb-4">
{#each getUploadingFiles() as file}
{#each uploadingFiles as file}
<File state={file} />
{/each}
</div>

View File

@@ -1,5 +1,5 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
@@ -9,10 +9,12 @@
let { data } = $props();
let files: Writable<FileInfo | null>[] = $state([]);
let files: (FileInfo | null)[] = $state([]);
$effect(() => {
files = data.files.map((file) => getFileInfo(file, $masterKeyStore?.get(1)?.key!));
onMount(async () => {
files = await Promise.all(
data.files.map((file) => getFileInfo(file, $masterKeyStore?.get(1)?.key!)),
);
});
</script>
@@ -22,5 +24,8 @@
<TopBar title="사진 및 동영상" />
<FullscreenDiv>
<Gallery {files} onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)} />
<Gallery
files={files.filter((file) => !!file)}
onFileClick={({ id }) => goto(`/file/${id}?from=gallery`)}
/>
</FullscreenDiv>

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { FullscreenDiv } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import type { FileCacheIndex } from "$lib/indexedDB";
@@ -12,7 +11,7 @@
interface FileCache {
index: FileCacheIndex;
fileInfo: Writable<FileInfo | null>;
fileInfo: FileInfo | null;
}
let fileCache: FileCache[] | undefined = $state();
@@ -23,13 +22,14 @@
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
};
onMount(() => {
fileCache = getFileCacheIndex()
.map((index) => ({
onMount(async () => {
fileCache = await Promise.all(
getFileCacheIndex().map(async (index) => ({
index,
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
}))
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
fileInfo: await getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
})),
);
fileCache.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
});
$effect(() => {

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfo } from "$lib/modules/filesystem";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/utils";
import IconDraft from "~icons/material-symbols/draft";
@@ -10,7 +9,7 @@
interface Props {
index: FileCacheIndex;
info: Writable<FileInfo | null>;
info: SummarizedFileInfo | null;
onDeleteClick: (fileId: number) => void;
}
@@ -18,7 +17,7 @@
</script>
<div class="flex h-14 items-center gap-x-4 p-2">
{#if $info}
{#if info}
<div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl">
<IconDraft class="text-blue-400" />
</div>
@@ -28,8 +27,8 @@
</div>
{/if}
<div class="flex-grow overflow-hidden">
{#if $info}
<p title={$info.name} class="truncate font-medium">{$info.name}</p>
{#if info}
<p title={info.name} class="truncate font-medium">{info.name}</p>
{:else}
<p class="font-medium">삭제된 파일</p>
{/if}

View File

@@ -20,19 +20,20 @@
const generateAllThumbnails = () => {
persistentStates.files.forEach(({ info }) => {
const fileInfo = get(info);
if (fileInfo) {
requestThumbnailGeneration(fileInfo);
if (info) {
requestThumbnailGeneration(info);
}
});
};
onMount(() => {
persistentStates.files = data.files.map((fileId) => ({
onMount(async () => {
persistentStates.files = await Promise.all(
data.files.map(async (fileId) => ({
id: fileId,
info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!),
info: await getFileInfo(fileId, $masterKeyStore?.get(1)?.key!),
status: getGenerationStatus(fileId),
}));
})),
);
});
</script>

View File

@@ -20,27 +20,25 @@
import IconCamera from "~icons/material-symbols/camera";
interface Props {
info: Writable<FileInfo | null>;
onclick: (selectedFile: FileInfo) => void;
onGenerateThumbnailClick: (selectedFile: FileInfo) => void;
info: FileInfo;
onclick: (file: FileInfo) => void;
onGenerateThumbnailClick: (file: FileInfo) => void;
generationStatus?: Writable<GenerationStatus>;
}
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
</script>
{#if $info}
<ActionEntryButton
<ActionEntryButton
class="h-14"
onclick={() => onclick($info)}
onclick={() => onclick(info)}
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined}
onActionButtonClick={() => onGenerateThumbnailClick($info)}
onActionButtonClick={() => onGenerateThumbnailClick(info)}
actionButtonClass="text-gray-800"
>
>
{@const subtext =
$generationStatus && $generationStatus !== "uploaded"
? subtexts[$generationStatus]
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={$info.name} {subtext} />
</ActionEntryButton>
{/if}
: formatDateTime(info.createdAt ?? info.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={info.name} {subtext} />
</ActionEntryButton>

View File

@@ -17,7 +17,7 @@ export type GenerationStatus =
interface File {
id: number;
info: Writable<FileInfo | null>;
info: FileInfo;
status?: Writable<GenerationStatus>;
}
@@ -129,7 +129,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
let fileSize = 0;
try {
const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!);
const file = await requestFileDownload(
fileInfo.id,
fileInfo.contentIv!,
fileInfo.dataKey?.key!,
);
fileSize = file.byteLength;
memoryUsage += fileSize;
@@ -141,11 +145,11 @@ export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
status,
file,
fileInfo.contentType,
fileInfo.dataKey!,
fileInfo.dataKey?.key!,
);
if (
!thumbnail ||
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail))
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
) {
status.set("error");
}

View File

@@ -2,7 +2,7 @@
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem2.svelte";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";

View File

@@ -4,7 +4,7 @@
import { page } from "$app/state";
import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem2.svelte";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries";

View File

@@ -2,7 +2,7 @@
import { ActionEntryButton, RowVirtualizer } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getUploadingFiles, type LiveFileUploadState } from "$lib/modules/file";
import type { DirectoryInfo } from "$lib/modules/filesystem2.svelte";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
import File from "./File.svelte";
import SubDirectory from "./SubDirectory.svelte";

View File

@@ -2,7 +2,7 @@
import { browser } from "$app/environment";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { SummarizedFileInfo } from "$lib/modules/filesystem2.svelte";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
import { formatDateTime } from "$lib/utils";
import type { SelectedEntry } from "../service.svelte";

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { SubDirectoryInfo } from "$lib/modules/filesystem2.svelte";
import type { SubDirectoryInfo } from "$lib/modules/filesystem";
import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";

View File

@@ -1,7 +1,5 @@
<script lang="ts">
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { fileDownloadStatusStore, isFileDownloading, type FileDownloadStatus } from "$lib/stores";
import { getDownloadingFiles } from "$lib/modules/file";
interface Props {
onclick: () => void;
@@ -9,23 +7,7 @@
let { onclick }: Props = $props();
let downloadingFiles: Writable<FileDownloadStatus>[] = $state([]);
$effect(() => {
downloadingFiles = $fileDownloadStatusStore.filter((status) =>
isFileDownloading(get(status).status),
);
return untrack(() => {
const unsubscribes = downloadingFiles.map((downloadingFile) =>
downloadingFile.subscribe(({ status }) => {
if (!isFileDownloading(status)) {
downloadingFiles = downloadingFiles.filter((file) => file !== downloadingFile);
}
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
let downloadingFiles = $derived(getDownloadingFiles());
</script>
{#if downloadingFiles.length > 0}

View File

@@ -8,13 +8,14 @@ import {
deleteFileThumbnailCache,
uploadFile,
} from "$lib/modules/file";
import type { DataKey } from "$lib/modules/filesystem";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
import { trpc } from "$trpc/client";
export interface SelectedEntry {
type: "directory" | "file";
id: number;
dataKey: { key: CryptoKey; version: Date } | undefined;
dataKey: DataKey | undefined;
name: string;
}

View File

@@ -1,17 +1,18 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { EntryButton, FileThumbnailButton } from "$lib/components/atoms";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { requestFreshMediaFilesRetrieval } from "./service";
let mediaFiles: Writable<FileInfo | null>[] = $state([]);
let mediaFiles: (FileInfo | null)[] = $state([]);
$effect(() => {
requestFreshMediaFilesRetrieval().then((files) => {
mediaFiles = files.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!));
});
onMount(async () => {
const files = await requestFreshMediaFilesRetrieval();
mediaFiles = await Promise.all(
files.map(({ id }) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)),
);
});
</script>
@@ -28,7 +29,9 @@
{#if mediaFiles.length > 0}
<div class="grid grid-cols-4 gap-2 p-2">
{#each mediaFiles as file}
{#if file}
<FileThumbnailButton info={file} onclick={({ id }) => goto(`/file/${id}`)} />
{/if}
{/each}
</div>
{/if}

View File

@@ -1,23 +1,14 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { goto as svelteGoto } from "$app/navigation";
import { getUploadingFiles } from "$lib/modules/file";
import {
fileDownloadStatusStore,
isFileDownloading,
clientKeyStore,
masterKeyStore,
} from "$lib/stores";
import { getDownloadingFiles, getUploadingFiles } from "$lib/modules/file";
import { clientKeyStore, masterKeyStore } from "$lib/stores";
import "../app.css";
let { children } = $props();
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if (
getUploadingFiles().length > 0 ||
$fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status))
) {
if (getDownloadingFiles().length > 0 || getUploadingFiles().length > 0) {
e.preventDefault();
}
};

View File

@@ -31,7 +31,14 @@ const fileRouter = router({
createdAtIv: file.encCreatedAt?.iv,
lastModifiedAt: file.encLastModifiedAt.ciphertext,
lastModifiedAtIv: file.encLastModifiedAt.iv,
categories: categories.map(({ id }) => id),
categories: categories.map((category) => ({
id: category.id,
mekVersion: category.mekVersion,
dek: category.encDek,
dekVersion: category.dekVersion,
name: category.encName.ciphertext,
nameIv: category.encName.iv,
})),
};
}),