10 Commits

120 changed files with 3175 additions and 4344 deletions

View File

@@ -2,11 +2,7 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache bash curl && \
curl -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
chmod +x /usr/local/bin/wait-for-it
RUN npm install -g pnpm@9
RUN npm install -g pnpm@10
COPY pnpm-lock.yaml .
# Build Stage
@@ -29,4 +25,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity
CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"]
CMD ["node", "./build/index.js"]

View File

@@ -3,7 +3,8 @@ services:
build: .
restart: unless-stopped
depends_on:
- database
database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data/library:/app/data/library
@@ -35,3 +36,8 @@ services:
environment:
- POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5

View File

@@ -1,13 +1,11 @@
import { includeIgnoreFile } from "@eslint/compat";
import js from "@eslint/js";
import svelteQuery from "@tanstack/eslint-plugin-query";
import prettier from "eslint-config-prettier";
import js from "@eslint/js";
import { includeIgnoreFile } from "@eslint/compat";
import svelte from "eslint-plugin-svelte";
import tailwind from "eslint-plugin-tailwindcss";
import globals from "globals";
import { fileURLToPath } from "node:url";
import ts from "typescript-eslint";
import { fileURLToPath } from "url";
const gitignorePath = fileURLToPath(new URL("./.gitignore", import.meta.url));
export default ts.config(
@@ -28,11 +26,11 @@ export default ts.config(
},
{
files: ["**/*.svelte"],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
},
...svelteQuery.configs["flat/recommended"],
);

View File

@@ -16,55 +16,56 @@
"db:migrate": "kysely migrate"
},
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@iconify-json/material-symbols": "^1.2.29",
"@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.22.5",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tanstack/eslint-plugin-query": "^5.81.2",
"@tanstack/svelte-query": "^5.83.0",
"@eslint/compat": "^1.4.1",
"@iconify-json/material-symbols": "^1.2.50",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.49.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/node-schedule": "^2.1.8",
"@types/pg": "^8.15.4",
"autoprefixer": "^10.4.21",
"axios": "^1.10.0",
"dexie": "^4.0.11",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.10.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"exifreader": "^4.31.1",
"@types/pg": "^8.16.0",
"autoprefixer": "^10.4.23",
"axios": "^1.13.2",
"dexie": "^4.2.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.1",
"eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.33.1",
"file-saver": "^2.0.5",
"globals": "^16.3.0",
"globals": "^16.5.0",
"heic2any": "^0.0.4",
"kysely-ctl": "^0.13.1",
"lru-cache": "^11.1.0",
"mime": "^4.0.7",
"p-limit": "^6.2.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.35.6",
"svelte-check": "^4.2.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"unplugin-icons": "^22.1.0",
"vite": "^5.4.19"
"kysely-ctl": "^0.19.0",
"lru-cache": "^11.2.4",
"mime": "^4.1.0",
"p-limit": "^7.2.0",
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.1",
"svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"unplugin-icons": "^22.5.0",
"vite": "^7.3.0"
},
"dependencies": {
"@fastify/busboy": "^3.1.1",
"argon2": "^0.43.0",
"kysely": "^0.28.2",
"@fastify/busboy": "^3.2.0",
"@trpc/server": "^11.8.1",
"argon2": "^0.44.0",
"kysely": "^0.28.9",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"pg": "^8.16.3",
"uuid": "^11.1.0",
"zod": "^3.25.76"
"superjson": "^2.2.6",
"uuid": "^13.0.0",
"zod": "^4.2.1"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^9.0.0"
"pnpm": "^10.0.0"
}
}

2157
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,14 @@
<script lang="ts">
import type { Component } from "svelte";
import { untrack, type Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { derived } from "svelte/store";
import type { CategoryId } from "$lib/indexedDB";
import { getCategoryInfo, type SubCategoryInfo } from "$lib/modules/filesystem2";
import { get, type Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";
interface Props {
categoryIds: CategoryId[];
categories: Writable<CategoryInfo | null>[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
@@ -18,33 +16,42 @@
}
let {
categoryIds,
categories,
categoryMenuIcon,
onCategoryClick,
onCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let categories = $derived(
derived(
categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) => {
const categories = infos
.filter(($info) => $info.status === "success")
.map(($info) => ({
name: $info.data.name,
info: $info.data as SubCategoryInfo,
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]);
$effect(() => {
categoriesWithName = categories.map((category) => ({
name: get(category)?.name,
info: category,
}));
sortEntries(categories, sortBy);
return categories;
},
),
const sort = () => {
sortEntries(categoriesWithName, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = categoriesWithName.map((category) =>
category.info.subscribe((value) => {
if (category.name === value?.name) return;
category.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if $categories.length > 0}
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each $categories as { info }}
{#each categoriesWithName as { info }}
<Category
{info}
menuIcon={categoryMenuIcon}

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem2";
import type { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {
info: SubCategoryInfo;
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
@@ -16,25 +17,27 @@
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onMenuClick!({ id, dataKey, dataKeyVersion, name });
};
</script>
<ActionEntryButton
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openCategory}
actionButtonIcon={menuIcon}
onActionButtonClick={openMenu}
>
<CategoryLabel name={info.name} />
</ActionEntryButton>
>
<CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -1,8 +1,10 @@
<script lang="ts">
import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem2";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -25,6 +27,14 @@
subCategoryCreatePosition = "bottom",
subCategoryMenuIcon,
}: Props = $props();
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
$effect(() => {
subCategories = info.subCategoryIds.map((id) =>
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
);
});
</script>
<div class={["space-y-1", className]}>
@@ -45,7 +55,7 @@
{/if}
{#key info}
<Categories
categoryIds={info.subCategoryIds}
categories={subCategories}
categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import { derived } from "svelte/store";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { CheckBox } from "$lib/components/atoms";
import { SubCategories, type SelectedCategory } from "$lib/components/molecules";
import { getFileInfo, type CategoryInfo } from "$lib/modules/filesystem2";
import { getFileInfo, type FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
@@ -18,7 +19,7 @@
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive?: boolean;
isFileRecursive: boolean;
}
let {
@@ -32,35 +33,39 @@
isFileRecursive = $bindable(),
}: Props = $props();
let fileInfos = $derived(
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
);
$effect(() => {
files =
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, isRecursive }) => ({
info: getFileInfo(id, $masterKeyStore?.get(1)?.key!),
isRecursive,
})) ?? [],
);
let files = $derived(
derived(
fileInfos.map(({ info }) => info),
(infos) => {
const files = infos
.map(($info, i) => {
if ($info.status === "success") {
.map(({ id, isRecursive }) => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
name: $info.data.name,
isRecursive: fileInfos[i]!.isRecursive,
info: $info.data,
name: get(info)?.name,
info,
isRecursive,
};
}
return undefined;
})
.filter((info) => info !== undefined);
}) ?? [];
const sort = () => {
sortEntries(files, sortBy);
return files;
},
),
};
return untrack(() => {
sort();
const unsubscribes = files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<div class="space-y-4">
@@ -80,15 +85,13 @@
<div class="space-y-4 bg-white p-4">
<div class="flex items-center justify-between">
<p class="text-lg font-bold text-gray-800">파일</p>
{#if isFileRecursive !== undefined}
<CheckBox bind:checked={isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
{/if}
</div>
<div class="space-y-1">
{#key info}
{#each $files as { info, isRecursive }}
{#each files as { info, isRecursive }}
<File
{info}
onclick={onFileClick}

View File

@@ -1,13 +1,14 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: FileInfo;
info: Writable<FileInfo | null>;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
}
@@ -17,22 +18,22 @@
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ id, dataKey, dataKeyVersion, name });
};
const removeFile = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if (info.dataKey) {
requestFileThumbnailDownload(info.id, info.dataKey)
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
@@ -46,11 +47,13 @@
});
</script>
<ActionEntryButton
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={info.name} />
</ActionEntryButton>
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
</ActionEntryButton>
{/if}

View File

@@ -1,24 +1,11 @@
interface FetchOptions {
fetch?: typeof fetch;
signal?: AbortSignal;
}
export const callGetApi = async (
input: RequestInfo,
{ fetch = globalThis.fetch, signal }: FetchOptions = {},
) => {
return await fetch(input, { method: "GET", signal });
export const callGetApi = async (input: RequestInfo, fetchInternal = fetch) => {
return await fetchInternal(input);
};
export const callPostApi = async <T>(
input: RequestInfo,
payload?: T,
{ fetch = globalThis.fetch, signal }: FetchOptions = {},
) => {
return await fetch(input, {
export const callPostApi = async <T>(input: RequestInfo, payload?: T, fetchInternal = fetch) => {
return await fetchInternal(input, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: payload ? JSON.stringify(payload) : undefined,
signal,
});
};

View File

@@ -62,10 +62,6 @@ export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.put(directoryInfo);
};
export const updateDirectoryInfo = async (id: number, changes: { name?: string }) => {
await filesystem.directory.update(id, changes);
};
export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id);
};
@@ -86,10 +82,6 @@ export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.put(fileInfo);
};
export const updateFileInfo = async (id: number, changes: { name?: string }) => {
await filesystem.file.update(id, changes);
};
export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id);
};
@@ -106,10 +98,7 @@ export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo);
};
export const updateCategoryInfo = async (
id: number,
changes: { name?: string; isFileRecursive?: boolean },
) => {
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
await filesystem.category.update(id, changes);
};

View File

@@ -5,7 +5,6 @@ import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
makeAESKeyNonextractable,
wrapDataKey,
encryptData,
encryptString,
@@ -14,8 +13,6 @@ import {
} from "$lib/modules/crypto";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
DuplicateFileScanRequest,
DuplicateFileScanResponse,
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
@@ -26,18 +23,18 @@ import {
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import { useTRPC } from "$trpc/client";
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const trpc = useTRPC();
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const res = await axios.post("/api/file/scanDuplicates", {
const files = await trpc.file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
} satisfies DuplicateFileScanRequest);
const { files }: DuplicateFileScanResponse = res.data;
});
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, fileSigned };
} else {
@@ -119,14 +116,12 @@ const encryptFile = limitFunction(
});
return {
dataKey: await makeAESKeyNonextractable(dataKey),
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAt,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
@@ -179,7 +174,9 @@ export const uploadFile = async (
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
) => {
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
@@ -209,14 +206,12 @@ export const uploadFile = async (
}
const {
dataKey,
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAt,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
@@ -259,16 +254,7 @@ export const uploadFile = async (
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return {
fileId,
fileDataKey: dataKey,
fileDataKeyVersion: dataKeyVersion,
fileType,
fileEncryptedIv: fileEncrypted.iv,
fileCreatedAt: createdAt,
fileBuffer,
thumbnailBuffer: thumbnail?.plaintext,
};
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
status.update((value) => {
value.status = "error";

View File

@@ -0,0 +1,362 @@
import { TRPCClientError } from "@trpc/client";
import { get, writable, type Writable } from "svelte/store";
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,
type DirectoryId,
type CategoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
import { useTRPC } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
const categoryInfoStore = new Map<CategoryId, Writable<CategoryInfo | null>>();
const fetchDirectoryInfoFromIndexedDB = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
) => {
if (get(info)) return;
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
if (!directory) return;
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
const trpc = useTRPC();
let data;
try {
data = await trpc.directory.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteDirectoryInfo(id as number);
return;
}
throw new Error("Failed to fetch directory information");
}
const { metadata, subDirectories: subDirectoryIds, files: fileIds } = data;
if (id === "root") {
info.set({ id, subDirectoryIds, fileIds });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
});
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
}
};
const fetchDirectoryInfo = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchDirectoryInfoFromIndexedDB(id, info);
await fetchDirectoryInfoFromServer(id, info, masterKey);
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = directoryInfoStore.get(id);
if (!info) {
info = writable(null);
directoryInfoStore.set(id, info);
}
fetchDirectoryInfo(id, info, masterKey); // Intended
return info;
};
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,
) => {
const trpc = useTRPC();
let metadata;
try {
metadata = await trpc.file.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && 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,
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;
};
const fetchCategoryInfoFromIndexedDB = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
) => {
if (get(info)) return;
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const subCategoryIds = subCategories.map(({ id }) => id);
if (id === "root") {
info.set({ id, subCategoryIds });
} else {
if (!category) return;
info.set({
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
}
};
const fetchCategoryInfoFromServer = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
const trpc = useTRPC();
let data;
try {
data = await trpc.category.get.query({ id });
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "NOT_FOUND") {
info.set(null);
await deleteCategoryInfo(id as number);
return;
}
throw new Error("Failed to fetch category information");
}
const { metadata, subCategories } = data;
if (id === "root") {
info.set({ id, subCategoryIds: subCategories });
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
let files;
try {
files = await trpc.category.files.query({ id, recurse: true });
} catch {
throw new Error("Failed to fetch category files");
}
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
const fetchCategoryInfo = async (
id: CategoryId,
info: Writable<CategoryInfo | null>,
masterKey: CryptoKey,
) => {
await fetchCategoryInfoFromIndexedDB(id, info);
await fetchCategoryInfoFromServer(id, info, masterKey);
};
export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = categoryInfoStore.get(categoryId);
if (!info) {
info = writable(null);
categoryInfoStore.set(categoryId, info);
}
fetchCategoryInfo(categoryId, info, masterKey); // Intended
return info;
};
export const updateCategoryInfo = async (
categoryId: number,
changes: { isFileRecursive?: boolean },
) => {
await updateCategoryInfoInIndexedDB(categoryId, changes);
categoryInfoStore.get(categoryId)?.update((value) => {
if (!value) return value;
if (changes.isFileRecursive !== undefined) {
value.isFileRecursive = changes.isFileRecursive;
}
return value;
});
};

View File

@@ -1,294 +0,0 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
import { callGetApi, callPostApi } from "$lib/hooks";
import {
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo,
deleteCategoryInfo,
type CategoryId,
} from "$lib/indexedDB";
import {
generateDataKey,
wrapDataKey,
unwrapDataKey,
encryptString,
decryptString,
} from "$lib/modules/crypto";
import type {
CategoryInfoResponse,
CategoryFileListResponse,
CategoryRenameRequest,
CategoryCreateRequest,
CategoryCreateResponse,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
export type CategoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
export type SubCategoryInfo = CategoryInfo & { id: number };
let temporaryIdCounter = -1;
const getInitialCategoryInfo = async (id: CategoryId) => {
const [category, subCategories] = await Promise.all([
id !== "root" ? getCategoryInfoFromIndexedDB(id) : undefined,
getCategoryInfosFromIndexedDB(id),
]);
const subCategoryIds = subCategories.map(({ id }) => id);
if (id === "root") {
return { id, subCategoryIds };
} else if (category) {
return {
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
};
}
return undefined;
};
export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => {
return createQuery<CategoryInfo>({
queryKey: ["category", id],
queryFn: async ({ client, signal }) => {
if (!client.getQueryData<CategoryInfo>(["category", id])) {
const initialInfo = await getInitialCategoryInfo(id);
if (initialInfo) {
setTimeout(() => client.invalidateQueries({ queryKey: ["category", id] }), 0);
return initialInfo;
}
}
const res = await callGetApi(`/api/category/${id}`, { signal }); // TODO: 404
const { metadata, subCategories }: CategoryInfoResponse = await res.json();
if (id === "root") {
return { id, subCategoryIds: subCategories };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
const res = await callGetApi(`/api/category/${id}/file/list?recurse=true`); // TODO: Error Handling
const { files }: CategoryFileListResponse = await res.json();
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
const prevInfo = client.getQueryData<CategoryInfo>(["category", id]);
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: prevInfo?.isFileRecursive ?? false,
});
return {
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
isFileRecursive: prevInfo?.isFileRecursive ?? false,
};
}
},
staleTime: Infinity,
});
};
export type CategoryInfoStore = ReturnType<typeof getCategoryInfo>;
export const useCategoryCreation = (parentId: CategoryId, masterKey: MasterKey) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { name: string }, { tempId: number }>({
mutationFn: async ({ name }) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
const res = await callPostApi<CategoryCreateRequest>("/api/category/create", {
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to create category");
const { category: id }: CategoryCreateResponse = await res.json();
queryClient.setQueryData<CategoryInfo>(["category", id], {
id,
name,
dataKey,
dataKeyVersion,
subCategoryIds: [],
files: [],
isFileRecursive: false,
});
await storeCategoryInfo({ id, parentId, name, files: [], isFileRecursive: false });
},
onMutate: async ({ name }) => {
const tempId = temporaryIdCounter--;
queryClient.setQueryData<CategoryInfo>(["category", tempId], {
id: tempId,
name,
subCategoryIds: [],
files: [],
isFileRecursive: false,
});
await queryClient.cancelQueries({ queryKey: ["category", parentId] });
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: [...prevParentInfo.subCategoryIds, tempId],
};
});
return { tempId };
},
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: prevParentInfo.subCategoryIds.filter((id) => id !== context.tempId),
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["category", parentId] });
},
});
};
export const useCategoryRename = () => {
const queryClient = useQueryClient();
return createMutation<
void,
Error,
{
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
newName: string;
},
{ oldName: string | undefined }
>({
mutationFn: async ({ id, dataKey, dataKeyVersion, newName }) => {
const newNameEncrypted = await encryptString(newName, dataKey);
const res = await callPostApi<CategoryRenameRequest>(`/api/category/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to rename category");
await updateCategoryInfo(id, { name: newName });
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["category", id] });
const prevInfo = queryClient.getQueryData<SubCategoryInfo>(["category", id]);
if (prevInfo) {
queryClient.setQueryData<CategoryInfo>(["category", id], {
...prevInfo,
name: newName,
});
}
return { oldName: prevInfo?.name };
},
onError: (_error, { id }, context) => {
if (context?.oldName) {
queryClient.setQueryData<SubCategoryInfo>(["category", id], (prevInfo) => {
if (!prevInfo) return;
return { ...prevInfo, name: context.oldName! };
});
}
},
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["category", id] });
},
});
};
export const useCategoryDeletion = (parentId: CategoryId) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { id: number }, {}>({
mutationFn: async ({ id }) => {
const res = await callPostApi(`/api/category/${id}/delete`);
if (!res.ok) throw new Error("Failed to delete category");
await deleteCategoryInfo(id);
// TODO: Update FileInfo
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["category", parentId] });
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: prevParentInfo.subCategoryIds.filter((categoryId) => categoryId !== id),
};
});
return {};
},
onError: (_error, { id }, context) => {
if (context) {
queryClient.setQueryData<CategoryInfo>(["category", parentId], (prevParentInfo) => {
if (!prevParentInfo) return;
return {
...prevParentInfo,
subCategoryIds: [...prevParentInfo.subCategoryIds, id],
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["category", parentId] });
},
});
};
export const useCategoryFileRecursionToggle = () => {
const queryClient = useQueryClient();
return createMutation<void, Error, { id: number; isFileRecursive: boolean }, {}>({
mutationFn: async ({ id, isFileRecursive }) => {
await updateCategoryInfo(id, { isFileRecursive });
},
onMutate: async ({ id, isFileRecursive }) => {
const prevInfo = queryClient.getQueryData<SubCategoryInfo>(["category", id]);
if (prevInfo) {
queryClient.setQueryData<CategoryInfo>(["category", id], {
...prevInfo,
isFileRecursive,
});
}
},
});
};

View File

@@ -1,267 +0,0 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
import { callGetApi, callPostApi } from "$lib/hooks";
import {
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
storeDirectoryInfo,
updateDirectoryInfo,
deleteDirectoryInfo,
getFileInfos as getFileInfosFromIndexedDB,
deleteFileInfo,
type DirectoryId,
} from "$lib/indexedDB";
import {
generateDataKey,
wrapDataKey,
unwrapDataKey,
encryptString,
decryptString,
} from "$lib/modules/crypto";
import type {
DirectoryInfoResponse,
DirectoryDeleteResponse,
DirectoryRenameRequest,
DirectoryCreateRequest,
DirectoryCreateResponse,
} from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
export type DirectoryInfo =
| {
id: "root";
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export type SubDirectoryInfo = DirectoryInfo & { id: number };
let temporaryIdCounter = -1;
const getInitialDirectoryInfo = async (id: DirectoryId) => {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
getDirectoryInfosFromIndexedDB(id),
getFileInfosFromIndexedDB(id),
]);
const subDirectoryIds = subDirectories.map(({ id }) => id);
const fileIds = files.map(({ id }) => id);
if (id === "root") {
return { id, subDirectoryIds, fileIds };
} else if (directory) {
return { id, name: directory.name, subDirectoryIds, fileIds };
}
return undefined;
};
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
return createQuery<DirectoryInfo>({
queryKey: ["directory", id],
queryFn: async ({ client, signal }) => {
if (!client.getQueryData(["directory", id])) {
const initialInfo = await getInitialDirectoryInfo(id);
if (initialInfo) {
setTimeout(() => client.invalidateQueries({ queryKey: ["directory", id] }), 0);
return initialInfo;
}
}
const res = await callGetApi(`/api/directory/${id}`, { signal }); // TODO: 404
const {
metadata,
subDirectories: subDirectoryIds,
files: fileIds,
}: DirectoryInfoResponse = await res.json();
if (id === "root") {
return { id, subDirectoryIds, fileIds };
} else {
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
return {
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subDirectoryIds,
fileIds,
};
}
},
staleTime: Infinity,
});
};
export type DirectoryInfoStore = ReturnType<typeof getDirectoryInfo>;
export const useDirectoryCreation = (parentId: DirectoryId, masterKey: MasterKey) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { name: string }, { tempId: number }>({
mutationFn: async ({ name }) => {
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
const res = await callPostApi<DirectoryCreateRequest>(`/api/directory/create`, {
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to create directory");
const { directory: id }: DirectoryCreateResponse = await res.json();
queryClient.setQueryData<DirectoryInfo>(["directory", id], {
id,
name,
dataKey,
dataKeyVersion,
subDirectoryIds: [],
fileIds: [],
});
await storeDirectoryInfo({ id, parentId, name });
},
onMutate: async ({ name }) => {
const tempId = temporaryIdCounter--;
queryClient.setQueryData<DirectoryInfo>(["directory", tempId], {
id: tempId,
name,
subDirectoryIds: [],
fileIds: [],
});
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: [...prevParentInfo.subDirectoryIds, tempId],
};
});
return { tempId };
},
onError: (_error, _variables, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter((id) => id !== context.tempId),
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};
export const useDirectoryRename = () => {
const queryClient = useQueryClient();
return createMutation<
void,
Error,
{
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
newName: string;
},
{ oldName: string | undefined }
>({
mutationFn: async ({ id, dataKey, dataKeyVersion, newName }) => {
const newNameEncrypted = await encryptString(newName, dataKey);
const res = await callPostApi<DirectoryRenameRequest>(`/api/directory/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to rename directory");
await updateDirectoryInfo(id, { name: newName });
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["directory", id] });
const prevInfo = queryClient.getQueryData<SubDirectoryInfo>(["directory", id]);
if (prevInfo) {
queryClient.setQueryData<DirectoryInfo>(["directory", id], {
...prevInfo,
name: newName,
});
}
return { oldName: prevInfo?.name };
},
onError: (_error, { id }, context) => {
if (context?.oldName) {
queryClient.setQueryData<SubDirectoryInfo>(["directory", id], (prevInfo) => {
if (!prevInfo) return undefined;
return { ...prevInfo, name: context.oldName! };
});
}
},
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["directory", id] });
},
});
};
export const useDirectoryDeletion = (parentId: DirectoryId) => {
const queryClient = useQueryClient();
return createMutation<{ deletedFiles: number[] }, Error, { id: number }, {}>({
mutationFn: async ({ id }) => {
const res = await callPostApi(`/api/directory/${id}/delete`);
if (!res.ok) throw new Error("Failed to delete directory");
const { deletedDirectories, deletedFiles }: DirectoryDeleteResponse = await res.json();
await Promise.all([
...deletedDirectories.map(deleteDirectoryInfo),
...deletedFiles.map(deleteFileInfo),
]);
return { deletedFiles };
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: prevParentInfo.subDirectoryIds.filter(
(subDirectoryId) => subDirectoryId !== id,
),
};
});
return {};
},
onError: (_error, { id }, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
subDirectoryIds: [...prevParentInfo.subDirectoryIds, id],
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};

View File

@@ -1,230 +0,0 @@
import { useQueryClient, createQuery, createMutation } from "@tanstack/svelte-query";
import { callGetApi, callPostApi } from "$lib/hooks";
import {
getFileInfo as getFileInfoFromIndexedDB,
storeFileInfo,
updateFileInfo,
deleteFileInfo,
type DirectoryId,
} from "$lib/indexedDB";
import { unwrapDataKey, encryptString, decryptString } from "$lib/modules/crypto";
import { uploadFile } from "$lib/modules/file";
import type { FileInfoResponse, FileRenameRequest } from "$lib/server/schemas";
import type { MasterKey, HmacSecret } from "$lib/stores";
import type { DirectoryInfo } from "./directory";
export interface FileInfo {
id: number;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
contentType: string;
contentIv?: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categoryIds: number[];
}
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const getFileInfo = (id: number, masterKey: CryptoKey) => {
return createQuery<FileInfo>({
queryKey: ["file", id],
queryFn: async ({ client, signal }) => {
if (!client.getQueryData(["file", id])) {
const initialInfo = await getFileInfoFromIndexedDB(id);
if (initialInfo) {
setTimeout(() => client.invalidateQueries({ queryKey: ["file", id] }), 0);
return initialInfo;
}
}
const res = await callGetApi(`/api/file/${id}`, { signal }); // TODO: 404
const metadata: FileInfoResponse = await res.json();
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,
);
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
return {
id,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
};
},
staleTime: Infinity,
});
};
export type FileInfoStore = ReturnType<typeof getFileInfo>;
export const useFileUpload = (
parentId: DirectoryId,
masterKey: MasterKey,
hmacSecret: HmacSecret,
) => {
const queryClient = useQueryClient();
return createMutation<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer },
Error,
{ file: File; onDuplicate: () => Promise<boolean> },
{ tempId: number }
>({
mutationFn: async ({ file, onDuplicate }) => {
const res = await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate);
if (!res) throw new Error("Failed to upload file");
queryClient.setQueryData<FileInfo>(["file", res.fileId], {
id: res.fileId,
dataKey: res.fileDataKey,
dataKeyVersion: res.fileDataKeyVersion,
contentType: res.fileType,
contentIv: res.fileEncryptedIv,
name: file.name,
createdAt: res.fileCreatedAt,
lastModifiedAt: new Date(file.lastModified),
categoryIds: [],
});
await storeFileInfo({
id: res.fileId,
parentId,
name: file.name,
contentType: res.fileType,
createdAt: res.fileCreatedAt,
lastModifiedAt: new Date(file.lastModified),
categoryIds: [],
});
return {
fileId: res.fileId,
fileBuffer: res.fileBuffer,
thumbnailBuffer: res.thumbnailBuffer,
};
},
onSuccess: async ({ fileId }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
fileIds: [...prevParentInfo.fileIds, fileId],
};
});
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};
export const useFileRename = () => {
const queryClient = useQueryClient();
return createMutation<
void,
Error,
{
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
newName: string;
},
{ oldName: string | undefined }
>({
mutationFn: async ({ id, dataKey, dataKeyVersion, newName }) => {
const newNameEncrypted = await encryptString(newName, dataKey);
const res = await callPostApi<FileRenameRequest>(`/api/file/${id}/rename`, {
dekVersion: dataKeyVersion.toISOString(),
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
if (!res.ok) throw new Error("Failed to rename file");
await updateFileInfo(id, { name: newName });
},
onMutate: async ({ id, newName }) => {
await queryClient.cancelQueries({ queryKey: ["file", id] });
const prevInfo = queryClient.getQueryData<FileInfo>(["file", id]);
if (prevInfo) {
queryClient.setQueryData<FileInfo>(["file", id], {
...prevInfo,
name: newName,
});
}
return { oldName: prevInfo?.name };
},
onError: (_error, { id }, context) => {
if (context?.oldName) {
queryClient.setQueryData<FileInfo>(["file", id], (prevInfo) => {
if (!prevInfo) return undefined;
return { ...prevInfo, name: context.oldName! };
});
}
},
onSettled: (_data, _error, { id }) => {
queryClient.invalidateQueries({ queryKey: ["file", id] });
},
});
};
export const useFileDeletion = (parentId: DirectoryId) => {
const queryClient = useQueryClient();
return createMutation<void, Error, { id: number }, {}>({
mutationFn: async ({ id }) => {
const res = await callPostApi(`/api/file/${id}/delete`);
if (!res.ok) throw new Error("Failed to delete file");
await deleteFileInfo(id);
},
onMutate: async ({ id }) => {
await queryClient.cancelQueries({ queryKey: ["directory", parentId] });
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
fileIds: prevParentInfo.fileIds.filter((fileId) => fileId !== id),
};
});
return {};
},
onError: (_error, { id }, context) => {
if (context) {
queryClient.setQueryData<DirectoryInfo>(["directory", parentId], (prevParentInfo) => {
if (!prevParentInfo) return undefined;
return {
...prevParentInfo,
fileIds: [...prevParentInfo.fileIds, id],
};
});
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["directory", parentId] });
},
});
};

View File

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

View File

@@ -5,14 +5,14 @@ import type { ClientKeys } from "$lib/stores";
const serializedClientKeysSchema = z.intersection(
z.object({
generator: z.literal("ArkVault"),
exportedAt: z.string().datetime(),
exportedAt: z.iso.datetime(),
}),
z.object({
version: z.literal(1),
encryptKey: z.string().base64().nonempty(),
decryptKey: z.string().base64().nonempty(),
signKey: z.string().base64().nonempty(),
verifyKey: z.string().base64().nonempty(),
encryptKey: z.base64().nonempty(),
decryptKey: z.base64().nonempty(),
signKey: z.base64().nonempty(),
verifyKey: z.base64().nonempty(),
}),
);

View File

@@ -17,7 +17,7 @@ interface Category {
export type NewCategory = Omit<Category, "id">;
export const registerCategory = async (params: NewCategory) => {
return await db.transaction().execute(async (trx) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
@@ -51,7 +51,6 @@ export const registerCategory = async (params: NewCategory) => {
new_name: params.encName,
})
.execute();
return { id: categoryId };
});
};

View File

@@ -98,22 +98,6 @@ export const createUserClient = async (userId: number, clientId: number) => {
}
};
export const getAllUserClients = async (userId: number) => {
const userClients = await db
.selectFrom("user_client")
.selectAll()
.where("user_id", "=", userId)
.execute();
return userClients.map(
({ user_id, client_id, state }) =>
({
userId: user_id,
clientId: client_id,
state,
}) satisfies UserClient,
);
};
export const getUserClient = async (userId: number, clientId: number) => {
const userClient = await db
.selectFrom("user_client")

View File

@@ -39,7 +39,7 @@ interface File {
export type NewFile = Omit<File, "id">;
export const registerDirectory = async (params: NewDirectory) => {
return await db.transaction().execute(async (trx) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
@@ -73,7 +73,6 @@ export const registerDirectory = async (params: NewDirectory) => {
new_name: params.encName,
})
.execute();
return { id: directoryId };
});
};
@@ -181,10 +180,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
};
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{
subDirectories: { id: number }[];
files: { id: number; path: string; thumbnailPath: string | null }[];
}> => {
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await trx
.selectFrom("directory")
@@ -192,7 +188,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
.where("parent_id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
const subDirectoryEntries = await Promise.all(
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
@@ -204,12 +200,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
if (deleteRes.numDeletedRows === 0n) {
throw new IntegrityError("Directory not found");
}
return {
subDirectories: subDirectoryEntries
.flatMap(({ subDirectories }) => subDirectories)
.concat(subDirectories),
files: subDirectoryEntries.flatMap(({ files }) => files).concat(files),
};
return files.concat(...subDirectoryFilePaths);
};
return await unregisterDirectoryRecursively(directoryId);
});

View File

@@ -0,0 +1,10 @@
export * as CategoryRepo from "./category";
export * as ClientRepo from "./client";
export * as FileRepo from "./file";
export * as HskRepo from "./hsk";
export * as MediaRepo from "./media";
export * as MekRepo from "./mek";
export * as SessionRepo from "./session";
export * as UserRepo from "./user";
export * from "./error";

View File

@@ -60,19 +60,6 @@ export const registerInitialMek = async (
});
};
export const getInitialMek = async (userId: number) => {
const mek = await db
.selectFrom("master_encryption_key")
.selectAll()
.where("user_id", "=", userId)
.where("version", "=", 1)
.limit(1)
.executeTakeFirst();
return mek
? ({ userId: mek.user_id, version: mek.version, state: mek.state } satisfies Mek)
: null;
};
export const getAllValidClientMeks = async (userId: number, clientId: number) => {
const clientMeks = await db
.selectFrom("client_master_encryption_key")

View File

@@ -27,10 +27,6 @@ export const getUserByEmail = async (email: string) => {
return user ? (user satisfies User) : null;
};
export const setUserNickname = async (userId: number, nickname: string) => {
await db.updateTable("user").set({ nickname }).where("id", "=", userId).execute();
};
export const setUserPassword = async (userId: number, password: string) => {
await db.updateTable("user").set({ password }).where("id", "=", userId).execute();
};

View File

@@ -3,11 +3,6 @@ import env from "$lib/server/loadenv";
import { authenticate, AuthenticationError } from "$lib/server/modules/auth";
export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
const { pathname, search } = event.url;
if (pathname === "/api/auth/login") {
return await resolve(event);
}
try {
const sessionIdSigned = event.cookies.get("sessionId");
if (!sessionIdSigned) {
@@ -24,7 +19,8 @@ export const authenticateMiddleware: Handle = async ({ event, resolve }) => {
});
} catch (e) {
if (e instanceof AuthenticationError) {
if (pathname === "/auth/login") {
const { pathname, search } = event.url;
if (pathname === "/auth/login" || pathname.startsWith("/api/trpc")) {
return await resolve(event);
} else if (pathname.startsWith("/api")) {
error(e.status, e.message);

View File

@@ -11,10 +11,17 @@ interface Session {
clientId?: number;
}
interface ClientSession extends Session {
export interface ClientSession extends Session {
clientId: number;
}
export type SessionPermission =
| "any"
| "notClient"
| "anyClient"
| "pendingClient"
| "activeClient";
export class AuthenticationError extends Error {
constructor(
public status: 400 | 401,
@@ -25,6 +32,16 @@ export class AuthenticationError extends Error {
}
}
export class AuthorizationError extends Error {
constructor(
public status: 403 | 500,
message: string,
) {
super(message);
this.name = "AuthorizationError";
}
}
export const startSession = async (userId: number, ip: string, userAgent: string) => {
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await createSession(userId, sessionId, ip, userAgent);
@@ -52,34 +69,12 @@ export const authenticate = async (sessionIdSigned: string, ip: string, userAgen
}
};
export async function authorize(locals: App.Locals, requiredPermission: "any"): Promise<Session>;
export async function authorize(
export const authorizeInternal = async (
locals: App.Locals,
requiredPermission: "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "pendingClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient" | "anyClient" | "pendingClient" | "activeClient",
): Promise<Session> {
requiredPermission: SessionPermission,
): Promise<Session> => {
if (!locals.session) {
error(500, "Unauthenticated");
throw new AuthorizationError(500, "Unauthenticated");
}
const { id: sessionId, userId, clientId } = locals.session;
@@ -89,39 +84,63 @@ export async function authorize(
break;
case "notClient":
if (clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
case "anyClient":
if (!clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
case "pendingClient": {
if (!clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "pending") {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
}
case "activeClient": {
if (!clientId) {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
throw new AuthorizationError(500, "Invalid session id");
} else if (userClient.state !== "active") {
error(403, "Forbidden");
throw new AuthorizationError(403, "Forbidden");
}
break;
}
}
return { sessionId, userId, clientId };
};
export async function authorize(
locals: App.Locals,
requiredPermission: "any" | "notClient",
): Promise<Session>;
export async function authorize(
locals: App.Locals,
requiredPermission: "anyClient" | "pendingClient" | "activeClient",
): Promise<ClientSession>;
export async function authorize(
locals: App.Locals,
requiredPermission: SessionPermission,
): Promise<Session> {
try {
return await authorizeInternal(locals, requiredPermission);
} catch (e) {
if (e instanceof AuthorizationError) {
error(e.status, e.message);
}
throw e;
}
}

View File

@@ -0,0 +1,7 @@
import { unlink } from "fs/promises";
export const safeUnlink = async (path: string | null | undefined) => {
if (path) {
await unlink(path).catch(console.error);
}
};

View File

@@ -1,25 +0,0 @@
import { error } from "@sveltejs/kit";
import { getUserClientWithDetails } from "$lib/server/db/client";
import { getInitialMek } from "$lib/server/db/mek";
import { verifySignature } from "$lib/server/modules/crypto";
export const isInitialMekNeeded = async (userId: number) => {
const initialMek = await getInitialMek(userId);
return !initialMek;
};
export const verifyClientEncMekSig = async (
userId: number,
clientId: number,
version: number,
encMek: string,
encMekSig: string,
) => {
const userClient = await getUserClientWithDetails(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
const data = JSON.stringify({ version, key: encMek });
return verifySignature(Buffer.from(data), encMekSig, userClient.sigPubKey);
};

View File

@@ -1,32 +0,0 @@
import { z } from "zod";
export const passwordChangeRequest = z.object({
oldPassword: z.string().trim().nonempty(),
newPassword: z.string().trim().nonempty(),
});
export type PasswordChangeRequest = z.input<typeof passwordChangeRequest>;
export const loginRequest = z.object({
email: z.string().email(),
password: z.string().trim().nonempty(),
});
export type LoginRequest = z.input<typeof loginRequest>;
export const sessionUpgradeRequest = z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
});
export type SessionUpgradeRequest = z.input<typeof sessionUpgradeRequest>;
export const sessionUpgradeResponse = z.object({
id: z.number().int().positive(),
challenge: z.string().base64().nonempty(),
});
export type SessionUpgradeResponse = z.output<typeof sessionUpgradeResponse>;
export const sessionUpgradeVerifyRequest = z.object({
id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(),
force: z.boolean().default(false),
});
export type SessionUpgradeVerifyRequest = z.input<typeof sessionUpgradeVerifyRequest>;

View File

@@ -1,60 +1,3 @@
import { z } from "zod";
export const categoryIdSchema = z.union([z.literal("root"), z.number().int().positive()]);
export const categoryInfoResponse = z.object({
metadata: z
.object({
parent: categoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
})
.optional(),
subCategories: z.number().int().positive().array(),
});
export type CategoryInfoResponse = z.output<typeof categoryInfoResponse>;
export const categoryFileAddRequest = z.object({
file: z.number().int().positive(),
});
export type CategoryFileAddRequest = z.input<typeof categoryFileAddRequest>;
export const categoryFileListResponse = z.object({
files: z.array(
z.object({
file: z.number().int().positive(),
isRecursive: z.boolean(),
}),
),
});
export type CategoryFileListResponse = z.output<typeof categoryFileListResponse>;
export const categoryFileRemoveRequest = z.object({
file: z.number().int().positive(),
});
export type CategoryFileRemoveRequest = z.input<typeof categoryFileRemoveRequest>;
export const categoryRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type CategoryRenameRequest = z.input<typeof categoryRenameRequest>;
export const categoryCreateRequest = z.object({
parent: categoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type CategoryCreateRequest = z.input<typeof categoryCreateRequest>;
export const categoryCreateResponse = z.object({
category: z.number().int().positive(),
});
export type CategoryCreateResponse = z.output<typeof categoryCreateResponse>;
export const categoryIdSchema = z.union([z.literal("root"), z.int().positive()]);

View File

@@ -1,36 +0,0 @@
import { z } from "zod";
export const clientListResponse = z.object({
clients: z.array(
z.object({
id: z.number().int().positive(),
state: z.enum(["pending", "active"]),
}),
),
});
export type ClientListResponse = z.output<typeof clientListResponse>;
export const clientRegisterRequest = z.object({
encPubKey: z.string().base64().nonempty(),
sigPubKey: z.string().base64().nonempty(),
});
export type ClientRegisterRequest = z.input<typeof clientRegisterRequest>;
export const clientRegisterResponse = z.object({
id: z.number().int().positive(),
challenge: z.string().base64().nonempty(),
});
export type ClientRegisterResponse = z.output<typeof clientRegisterResponse>;
export const clientRegisterVerifyRequest = z.object({
id: z.number().int().positive(),
answerSig: z.string().base64().nonempty(),
});
export type ClientRegisterVerifyRequest = z.input<typeof clientRegisterVerifyRequest>;
export const clientStatusResponse = z.object({
id: z.number().int().positive(),
state: z.enum(["pending", "active"]),
isInitialMekNeeded: z.boolean(),
});
export type ClientStatusResponse = z.output<typeof clientStatusResponse>;

View File

@@ -1,47 +1,3 @@
import { z } from "zod";
export const directoryIdSchema = z.union([z.literal("root"), z.number().int().positive()]);
export const directoryInfoResponse = z.object({
metadata: z
.object({
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
})
.optional(),
subDirectories: z.number().int().positive().array(),
files: z.number().int().positive().array(),
});
export type DirectoryInfoResponse = z.output<typeof directoryInfoResponse>;
export const directoryDeleteResponse = z.object({
deletedDirectories: z.number().int().positive().array(),
deletedFiles: z.number().int().positive().array(),
});
export type DirectoryDeleteResponse = z.output<typeof directoryDeleteResponse>;
export const directoryRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type DirectoryRenameRequest = z.input<typeof directoryRenameRequest>;
export const directoryCreateRequest = z.object({
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type DirectoryCreateRequest = z.input<typeof directoryCreateRequest>;
export const directoryCreateResponse = z.object({
directory: z.number().int().positive(),
});
export type DirectoryCreateResponse = z.output<typeof directoryCreateResponse>;
export const directoryIdSchema = z.union([z.literal("root"), z.int().positive()]);

View File

@@ -2,90 +2,35 @@ import mime from "mime";
import { z } from "zod";
import { directoryIdSchema } from "./directory";
export const fileInfoResponse = z.object({
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
contentType: z
.string()
.trim()
.nonempty()
.refine((value) => mime.getExtension(value) !== null), // MIME type
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
createdAt: z.string().base64().nonempty().optional(),
createdAtIv: z.string().base64().nonempty().optional(),
lastModifiedAt: z.string().base64().nonempty(),
lastModifiedAtIv: z.string().base64().nonempty(),
categories: z.number().int().positive().array(),
});
export type FileInfoResponse = z.output<typeof fileInfoResponse>;
export const fileRenameRequest = z.object({
dekVersion: z.string().datetime(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
});
export type FileRenameRequest = z.input<typeof fileRenameRequest>;
export const fileThumbnailInfoResponse = z.object({
updatedAt: z.string().datetime(),
contentIv: z.string().base64().nonempty(),
});
export type FileThumbnailInfoResponse = z.output<typeof fileThumbnailInfoResponse>;
export const fileThumbnailUploadRequest = z.object({
dekVersion: z.string().datetime(),
contentIv: z.string().base64().nonempty(),
dekVersion: z.iso.datetime(),
contentIv: z.base64().nonempty(),
});
export type FileThumbnailUploadRequest = z.input<typeof fileThumbnailUploadRequest>;
export const fileListResponse = z.object({
files: z.number().int().positive().array(),
});
export type FileListResponse = z.output<typeof fileListResponse>;
export const duplicateFileScanRequest = z.object({
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
});
export type DuplicateFileScanRequest = z.input<typeof duplicateFileScanRequest>;
export const duplicateFileScanResponse = z.object({
files: z.number().int().positive().array(),
});
export type DuplicateFileScanResponse = z.output<typeof duplicateFileScanResponse>;
export const missingThumbnailFileScanResponse = z.object({
files: z.number().int().positive().array(),
});
export type MissingThumbnailFileScanResponse = z.output<typeof missingThumbnailFileScanResponse>;
export const fileUploadRequest = z.object({
parent: directoryIdSchema,
mekVersion: z.number().int().positive(),
dek: z.string().base64().nonempty(),
dekVersion: z.string().datetime(),
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
mekVersion: z.int().positive(),
dek: z.base64().nonempty(),
dekVersion: z.iso.datetime(),
hskVersion: z.int().positive(),
contentHmac: z.base64().nonempty(),
contentType: z
.string()
.trim()
.nonempty()
.refine((value) => mime.getExtension(value) !== null), // MIME type
contentIv: z.string().base64().nonempty(),
name: z.string().base64().nonempty(),
nameIv: z.string().base64().nonempty(),
createdAt: z.string().base64().nonempty().optional(),
createdAtIv: z.string().base64().nonempty().optional(),
lastModifiedAt: z.string().base64().nonempty(),
lastModifiedAtIv: z.string().base64().nonempty(),
contentIv: z.base64().nonempty(),
name: z.base64().nonempty(),
nameIv: z.base64().nonempty(),
createdAt: z.base64().nonempty().optional(),
createdAtIv: z.base64().nonempty().optional(),
lastModifiedAt: z.base64().nonempty(),
lastModifiedAtIv: z.base64().nonempty(),
});
export type FileUploadRequest = z.input<typeof fileUploadRequest>;
export const fileUploadResponse = z.object({
file: z.number().int().positive(),
file: z.int().positive(),
});
export type FileUploadResponse = z.output<typeof fileUploadResponse>;

View File

@@ -1,19 +0,0 @@
import { z } from "zod";
export const hmacSecretListResponse = z.object({
hsks: z.array(
z.object({
version: z.number().int().positive(),
state: z.enum(["active"]),
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
}),
),
});
export type HmacSecretListResponse = z.output<typeof hmacSecretListResponse>;
export const initialHmacSecretRegisterRequest = z.object({
mekVersion: z.number().int().positive(),
hsk: z.string().base64().nonempty(),
});
export type InitialHmacSecretRegisterRequest = z.input<typeof initialHmacSecretRegisterRequest>;

View File

@@ -1,8 +1,3 @@
export * from "./auth";
export * from "./category";
export * from "./client";
export * from "./directory";
export * from "./file";
export * from "./hsk";
export * from "./mek";
export * from "./user";

View File

@@ -1,19 +0,0 @@
import { z } from "zod";
export const masterKeyListResponse = z.object({
meks: z.array(
z.object({
version: z.number().int().positive(),
state: z.enum(["active", "retired"]),
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
}),
),
});
export type MasterKeyListResponse = z.output<typeof masterKeyListResponse>;
export const initialMasterKeyRegisterRequest = z.object({
mek: z.string().base64().nonempty(),
mekSig: z.string().base64().nonempty(),
});
export type InitialMasterKeyRegisterRequest = z.input<typeof initialMasterKeyRegisterRequest>;

View File

@@ -1,12 +0,0 @@
import { z } from "zod";
export const userInfoResponse = z.object({
email: z.string().email(),
nickname: z.string().nonempty(),
});
export type UserInfoResponse = z.output<typeof userInfoResponse>;
export const nicknameChangeRequest = z.object({
newNickname: z.string().trim().min(2).max(8),
});
export type NicknameChangeRequest = z.input<typeof nicknameChangeRequest>;

View File

@@ -1,122 +0,0 @@
import { error } from "@sveltejs/kit";
import argon2 from "argon2";
import { getClient, getClientByPubKeys, getUserClient } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import {
upgradeSession,
deleteSession,
deleteAllOtherSessions,
registerSessionUpgradeChallenge,
consumeSessionUpgradeChallenge,
} from "$lib/server/db/session";
import { getUser, getUserByEmail, setUserPassword } from "$lib/server/db/user";
import env from "$lib/server/loadenv";
import { startSession } from "$lib/server/modules/auth";
import { verifySignature, generateChallenge } from "$lib/server/modules/crypto";
const hashPassword = async (password: string) => {
return await argon2.hash(password);
};
const verifyPassword = async (hash: string, password: string) => {
return await argon2.verify(hash, password);
};
export const changePassword = async (
userId: number,
sessionId: string,
oldPassword: string,
newPassword: string,
) => {
if (oldPassword === newPassword) {
error(400, "Same passwords");
} else if (newPassword.length < 8) {
error(400, "Too short password");
}
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
} else if (!(await verifyPassword(user.password, oldPassword))) {
error(403, "Invalid password");
}
await setUserPassword(userId, await hashPassword(newPassword));
await deleteAllOtherSessions(userId, sessionId);
};
export const login = async (email: string, password: string, ip: string, userAgent: string) => {
const user = await getUserByEmail(email);
if (!user || !(await verifyPassword(user.password, password))) {
error(401, "Invalid email or password");
}
return { sessionIdSigned: await startSession(user.id, ip, userAgent) };
};
export const logout = async (sessionId: string) => {
await deleteSession(sessionId);
};
export const createSessionUpgradeChallenge = async (
sessionId: string,
userId: number,
ip: string,
encPubKey: string,
sigPubKey: string,
) => {
const client = await getClientByPubKeys(encPubKey, sigPubKey);
const userClient = client ? await getUserClient(userId, client.id) : undefined;
if (!client) {
error(401, "Invalid public key(s)");
} else if (!userClient || userClient.state === "challenging") {
error(403, "Unregistered client");
}
const { answer, challenge } = await generateChallenge(32, encPubKey);
const { id } = await registerSessionUpgradeChallenge(
sessionId,
client.id,
answer.toString("base64"),
ip,
new Date(Date.now() + env.challenge.sessionUpgradeExp),
);
return { id, challenge: challenge.toString("base64") };
};
export const verifySessionUpgradeChallenge = async (
sessionId: string,
userId: number,
ip: string,
challengeId: number,
answerSig: string,
force: boolean,
) => {
const challenge = await consumeSessionUpgradeChallenge(challengeId, sessionId, ip);
if (!challenge) {
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey)
) {
error(403, "Invalid challenge answer signature");
}
try {
await upgradeSession(userId, sessionId, client.id, force);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Session not found") {
error(500, "Invalid challenge answer");
} else if (!force && e.message === "Session already exists") {
error(409, "Already logged in");
}
}
throw e;
}
};

View File

@@ -1,134 +0,0 @@
import { error } from "@sveltejs/kit";
import {
registerCategory,
getAllCategoriesByParent,
getCategory,
setCategoryEncName,
unregisterCategory,
type CategoryId,
type NewCategory,
} from "$lib/server/db/category";
import { IntegrityError } from "$lib/server/db/error";
import {
getAllFilesByCategory,
getFile,
addFileToCategory,
removeFileFromCategory,
} from "$lib/server/db/file";
import type { Ciphertext } from "$lib/server/db/schema";
export const getCategoryInformation = async (userId: number, categoryId: CategoryId) => {
const category = categoryId !== "root" ? await getCategory(userId, categoryId) : undefined;
if (category === null) {
error(404, "Invalid category id");
}
const categories = await getAllCategoriesByParent(userId, categoryId);
return {
metadata: category && {
parentId: category.parentId ?? ("root" as const),
mekVersion: category.mekVersion,
encDek: category.encDek,
dekVersion: category.dekVersion,
encName: category.encName,
},
categories: categories.map(({ id }) => id),
};
};
export const deleteCategory = async (userId: number, categoryId: number) => {
try {
await unregisterCategory(userId, categoryId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Category not found") {
error(404, "Invalid category id");
}
throw e;
}
};
export const addCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
const category = await getCategory(userId, categoryId);
const file = await getFile(userId, fileId);
if (!category) {
error(404, "Invalid category id");
} else if (!file) {
error(404, "Invalid file id");
}
try {
await addFileToCategory(fileId, categoryId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "File already added to category") {
error(400, "File already added");
}
throw e;
}
};
export const getCategoryFiles = async (userId: number, categoryId: number, recurse: boolean) => {
const category = await getCategory(userId, categoryId);
if (!category) {
error(404, "Invalid category id");
}
const files = await getAllFilesByCategory(userId, categoryId, recurse);
return { files };
};
export const removeCategoryFile = async (userId: number, categoryId: number, fileId: number) => {
const category = await getCategory(userId, categoryId);
const file = await getFile(userId, fileId);
if (!category) {
error(404, "Invalid category id");
} else if (!file) {
error(404, "Invalid file id");
}
try {
await removeFileFromCategory(fileId, categoryId);
} catch (e) {
if (e instanceof IntegrityError && e.message === "File not found in category") {
error(400, "File not added");
}
throw e;
}
};
export const renameCategory = async (
userId: number,
categoryId: number,
dekVersion: Date,
newEncName: Ciphertext,
) => {
try {
await setCategoryEncName(userId, categoryId, dekVersion, newEncName);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Category not found") {
error(404, "Invalid category id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const createCategory = async (params: NewCategory) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
try {
const { id } = await registerCategory(params);
return { id };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Inactive MEK version");
}
throw e;
}
};

View File

@@ -1,116 +0,0 @@
import { error } from "@sveltejs/kit";
import {
createClient,
getClient,
getClientByPubKeys,
createUserClient,
getAllUserClients,
getUserClient,
setUserClientStateToPending,
registerUserClientChallenge,
consumeUserClientChallenge,
} from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { verifyPubKey, verifySignature, generateChallenge } from "$lib/server/modules/crypto";
import { isInitialMekNeeded } from "$lib/server/modules/mek";
import env from "$lib/server/loadenv";
export const getUserClientList = async (userId: number) => {
const userClients = await getAllUserClients(userId);
return {
userClients: userClients.map(({ clientId, state }) => ({
id: clientId,
state: state as "pending" | "active",
})),
};
};
const expiresAt = () => new Date(Date.now() + env.challenge.userClientExp);
const createUserClientChallenge = async (
ip: string,
userId: number,
clientId: number,
encPubKey: string,
) => {
const { answer, challenge } = await generateChallenge(32, encPubKey);
const { id } = await registerUserClientChallenge(
userId,
clientId,
answer.toString("base64"),
ip,
expiresAt(),
);
return { id, challenge: challenge.toString("base64") };
};
export const registerUserClient = async (
userId: number,
ip: string,
encPubKey: string,
sigPubKey: string,
) => {
const client = await getClientByPubKeys(encPubKey, sigPubKey);
if (client) {
try {
await createUserClient(userId, client.id);
return await createUserClientChallenge(ip, userId, client.id, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "User client already exists") {
error(409, "Client already registered");
}
throw e;
}
} else {
if (encPubKey === sigPubKey) {
error(400, "Same public keys");
} else if (!verifyPubKey(encPubKey) || !verifyPubKey(sigPubKey)) {
error(400, "Invalid public key(s)");
}
try {
const { id: clientId } = await createClient(encPubKey, sigPubKey, userId);
return await createUserClientChallenge(ip, userId, clientId, encPubKey);
} catch (e) {
if (e instanceof IntegrityError && e.message === "Public key(s) already registered") {
error(409, "Public key(s) already used");
}
throw e;
}
}
};
export const verifyUserClient = async (
userId: number,
ip: string,
challengeId: number,
answerSig: string,
) => {
const challenge = await consumeUserClientChallenge(challengeId, userId, ip);
if (!challenge) {
error(403, "Invalid challenge answer");
}
const client = await getClient(challenge.clientId);
if (!client) {
error(500, "Invalid challenge answer");
} else if (
!verifySignature(Buffer.from(challenge.answer, "base64"), answerSig, client.sigPubKey)
) {
error(403, "Invalid challenge answer signature");
}
await setUserClientStateToPending(userId, client.id);
};
export const getUserClientStatus = async (userId: number, clientId: number) => {
const userClient = await getUserClient(userId, clientId);
if (!userClient) {
error(500, "Invalid session id");
}
return {
state: userClient.state as "pending" | "active",
isInitialMekNeeded: await isInitialMekNeeded(userId),
};
};

View File

@@ -1,98 +0,0 @@
import { error } from "@sveltejs/kit";
import { unlink } from "fs/promises";
import { IntegrityError } from "$lib/server/db/error";
import {
registerDirectory,
getAllDirectoriesByParent,
getDirectory,
setDirectoryEncName,
unregisterDirectory,
getAllFilesByParent,
type DirectoryId,
type NewDirectory,
} from "$lib/server/db/file";
import type { Ciphertext } from "$lib/server/db/schema";
export const getDirectoryInformation = async (userId: number, directoryId: DirectoryId) => {
const directory = directoryId !== "root" ? await getDirectory(userId, directoryId) : undefined;
if (directory === null) {
error(404, "Invalid directory id");
}
const directories = await getAllDirectoriesByParent(userId, directoryId);
const files = await getAllFilesByParent(userId, directoryId);
return {
metadata: directory && {
parentId: directory.parentId ?? ("root" as const),
mekVersion: directory.mekVersion,
encDek: directory.encDek,
dekVersion: directory.dekVersion,
encName: directory.encName,
},
directories: directories.map(({ id }) => id),
files: files.map(({ id }) => id),
};
};
const safeUnlink = async (path: string | null) => {
if (path) {
await unlink(path).catch(console.error);
}
};
export const deleteDirectory = async (userId: number, directoryId: number) => {
try {
const { subDirectories, files } = await unregisterDirectory(userId, directoryId);
return {
directories: [...subDirectories.map(({ id }) => id), directoryId],
files: files.map(({ id, path, thumbnailPath }) => {
safeUnlink(path); // Intended
safeUnlink(thumbnailPath); // Intended
return id;
}),
};
} catch (e) {
if (e instanceof IntegrityError && e.message === "Directory not found") {
error(404, "Invalid directory id");
}
throw e;
}
};
export const renameDirectory = async (
userId: number,
directoryId: number,
dekVersion: Date,
newEncName: Ciphertext,
) => {
try {
await setDirectoryEncName(userId, directoryId, dekVersion, newEncName);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "Directory not found") {
error(404, "Invalid directory id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const createDirectory = async (params: NewDirectory) => {
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
try {
const { id } = await registerDirectory(params);
return { id };
} catch (e) {
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
}
throw e;
}
};

View File

@@ -1,72 +1,17 @@
import { error } from "@sveltejs/kit";
import { createHash } from "crypto";
import { createReadStream, createWriteStream } from "fs";
import { mkdir, stat, unlink } from "fs/promises";
import { mkdir, stat } from "fs/promises";
import { dirname } from "path";
import { Readable } from "stream";
import { pipeline } from "stream/promises";
import { v4 as uuidv4 } from "uuid";
import { IntegrityError } from "$lib/server/db/error";
import {
registerFile,
getAllFileIds,
getAllFileIdsByContentHmac,
getFile,
setFileEncName,
unregisterFile,
getAllFileCategories,
type NewFile,
} from "$lib/server/db/file";
import {
updateFileThumbnail,
getFileThumbnail,
getMissingFileThumbnails,
} from "$lib/server/db/media";
import type { Ciphertext } from "$lib/server/db/schema";
import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
import env from "$lib/server/loadenv";
export const getFileInformation = async (userId: number, fileId: number) => {
const file = await getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
const categories = await getAllFileCategories(fileId);
return {
parentId: file.parentId ?? ("root" as const),
mekVersion: file.mekVersion,
encDek: file.encDek,
dekVersion: file.dekVersion,
contentType: file.contentType,
encContentIv: file.encContentIv,
encName: file.encName,
encCreatedAt: file.encCreatedAt,
encLastModifiedAt: file.encLastModifiedAt,
categories: categories.map(({ id }) => id),
};
};
const safeUnlink = async (path: string | null) => {
if (path) {
await unlink(path).catch(console.error);
}
};
export const deleteFile = async (userId: number, fileId: number) => {
try {
const { path, thumbnailPath } = await unregisterFile(userId, fileId);
safeUnlink(path); // Intended
safeUnlink(thumbnailPath); // Intended
} catch (e) {
if (e instanceof IntegrityError && e.message === "File not found") {
error(404, "Invalid file id");
}
throw e;
}
};
import { safeUnlink } from "$lib/server/modules/filesystem";
export const getFileStream = async (userId: number, fileId: number) => {
const file = await getFile(userId, fileId);
const file = await FileRepo.getFile(userId, fileId);
if (!file) {
error(404, "Invalid file id");
}
@@ -78,37 +23,8 @@ export const getFileStream = async (userId: number, fileId: number) => {
};
};
export const renameFile = async (
userId: number,
fileId: number,
dekVersion: Date,
newEncName: Ciphertext,
) => {
try {
await setFileEncName(userId, fileId, dekVersion, newEncName);
} catch (e) {
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
error(404, "Invalid file id");
} else if (e.message === "Invalid DEK version") {
error(400, "Invalid DEK version");
}
}
throw e;
}
};
export const getFileThumbnailInformation = async (userId: number, fileId: number) => {
const thumbnail = await getFileThumbnail(userId, fileId);
if (!thumbnail) {
error(404, "File or its thumbnail not found");
}
return { updatedAt: thumbnail.updatedAt, encContentIv: thumbnail.encContentIv };
};
export const getFileThumbnailStream = async (userId: number, fileId: number) => {
const thumbnail = await getFileThumbnail(userId, fileId);
const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId);
if (!thumbnail) {
error(404, "File or its thumbnail not found");
}
@@ -133,7 +49,13 @@ export const uploadFileThumbnail = async (
try {
await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 }));
const oldPath = await updateFileThumbnail(userId, fileId, dekVersion, path, encContentIv);
const oldPath = await MediaRepo.updateFileThumbnail(
userId,
fileId,
dekVersion,
path,
encContentIv,
);
safeUnlink(oldPath); // Intended
} catch (e) {
await safeUnlink(path);
@@ -149,27 +71,8 @@ export const uploadFileThumbnail = async (
}
};
export const getFileList = async (userId: number) => {
const fileIds = await getAllFileIds(userId);
return { files: fileIds };
};
export const scanDuplicateFiles = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
return { files: fileIds };
};
export const scanMissingFileThumbnails = async (userId: number) => {
const fileIds = await getMissingFileThumbnails(userId);
return { files: fileIds };
};
export const uploadFile = async (
params: Omit<NewFile, "path" | "encContentHash">,
params: Omit<FileRepo.NewFile, "path" | "encContentHash">,
encContentStream: Readable,
encContentHash: Promise<string>,
) => {
@@ -201,7 +104,7 @@ export const uploadFile = async (
throw new Error("Invalid checksum");
}
const { id: fileId } = await registerFile({
const { id: fileId } = await FileRepo.registerFile({
...params,
path,
encContentHash: hash,

View File

@@ -1,31 +0,0 @@
import { error } from "@sveltejs/kit";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialHsk, getAllValidHsks } from "$lib/server/db/hsk";
export const getHskList = async (userId: number) => {
const hsks = await getAllValidHsks(userId);
return {
encHsks: hsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
encHsk,
})),
};
};
export const registerInitialActiveHsk = async (
userId: number,
createdBy: number,
mekVersion: number,
encHsk: string,
) => {
try {
await registerInitialHsk(userId, createdBy, mekVersion, encHsk);
} catch (e) {
if (e instanceof IntegrityError && e.message === "HSK already registered") {
error(409, "Initial HSK already registered");
}
throw e;
}
};

View File

@@ -1,38 +0,0 @@
import { error } from "@sveltejs/kit";
import { setUserClientStateToActive } from "$lib/server/db/client";
import { IntegrityError } from "$lib/server/db/error";
import { registerInitialMek, getAllValidClientMeks } from "$lib/server/db/mek";
import { verifyClientEncMekSig } from "$lib/server/modules/mek";
export const getClientMekList = async (userId: number, clientId: number) => {
const clientMeks = await getAllValidClientMeks(userId, clientId);
return {
encMeks: clientMeks.map(({ version, state, encMek, encMekSig }) => ({
version,
state,
encMek,
encMekSig,
})),
};
};
export const registerInitialActiveMek = async (
userId: number,
createdBy: number,
encMek: string,
encMekSig: string,
) => {
if (!(await verifyClientEncMekSig(userId, createdBy, 1, encMek, encMekSig))) {
error(400, "Invalid signature");
}
try {
await registerInitialMek(userId, createdBy, encMek, encMekSig);
await setUserClientStateToActive(userId, createdBy);
} catch (e) {
if (e instanceof IntegrityError && e.message === "MEK already registered") {
error(409, "Initial MEK already registered");
}
throw e;
}
};

View File

@@ -1,15 +0,0 @@
import { error } from "@sveltejs/kit";
import { getUser, setUserNickname } from "$lib/server/db/user";
export const getUserInformation = async (userId: number) => {
const user = await getUser(userId);
if (!user) {
error(500, "Invalid session id");
}
return { email: user.email, nickname: user.nickname };
};
export const changeNickname = async (userId: number, nickname: string) => {
await setUserNickname(userId, nickname);
};

View File

@@ -1,10 +1,6 @@
import { callPostApi } from "$lib/hooks";
import { TRPCClientError } from "@trpc/client";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import type {
SessionUpgradeRequest,
SessionUpgradeResponse,
SessionUpgradeVerifyRequest,
} from "$lib/server/schemas";
import { useTRPC } from "$trpc/client";
export const requestSessionUpgrade = async (
encryptKeyBase64: string,
@@ -13,27 +9,45 @@ export const requestSessionUpgrade = async (
signKey: CryptoKey,
force = false,
) => {
let res = await callPostApi<SessionUpgradeRequest>("/api/auth/upgradeSession", {
const trpc = useTRPC();
let id, challenge;
try {
({ id, challenge } = await trpc.auth.upgradeSession.mutate({
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
});
if (res.status === 403) return [false, "Unregistered client"] as const;
else if (!res.ok) return [false] as const;
const { id, challenge }: SessionUpgradeResponse = await res.json();
}));
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const;
}
return [false] as const;
}
const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey);
res = await callPostApi<SessionUpgradeVerifyRequest>("/api/auth/upgradeSession/verify", {
try {
await trpc.auth.verifySessionUpgrade.mutate({
id,
answerSig: encodeToBase64(answerSig),
force,
});
if (res.status === 409) return [false, "Already logged in"] as const;
else return [res.ok] as const;
} catch (e) {
if (e instanceof TRPCClientError && e.data?.code === "CONFLICT") {
return [false, "Already logged in"] as const;
}
return [false] as const;
}
return [true] as const;
};
export const requestLogout = async () => {
const res = await callPostApi("/api/auth/logout");
return res.ok;
const trpc = useTRPC();
try {
await trpc.auth.logout.mutate();
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,31 +1,40 @@
import { callPostApi } from "$lib/hooks";
import { generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
import type { CategoryCreateRequest, CategoryFileRemoveRequest } from "$lib/server/schemas";
import type { MasterKey } from "$lib/stores";
import { useTRPC } from "$trpc/client";
export const requestCategoryCreation = async (
name: string,
parentId: "root" | number,
masterKey: MasterKey,
) => {
const trpc = useTRPC();
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
const res = await callPostApi<CategoryCreateRequest>("/api/category/create", {
try {
await trpc.category.create.mutate({
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
dekVersion: dataKeyVersion,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
return res.ok;
return true;
} catch {
// TODO: Error Handling
return false;
}
};
export const requestFileRemovalFromCategory = async (fileId: number, categoryId: number) => {
const res = await callPostApi<CategoryFileRemoveRequest>(
`/api/category/${categoryId}/file/remove`,
{ file: fileId },
);
return res.ok;
const trpc = useTRPC();
try {
await trpc.category.removeFile.mutate({ id: categoryId, file: fileId });
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -11,11 +11,8 @@ import {
downloadFile,
} from "$lib/modules/file";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
import type {
FileThumbnailInfoResponse,
FileThumbnailUploadRequest,
FileListResponse,
} from "$lib/server/schemas";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { useTRPC } from "$trpc/client";
export const requestFileDownload = async (
fileId: number,
@@ -48,16 +45,21 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
};
export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId);
if (cache) return cache;
if (cache || !dataKey) return cache;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null;
const trpc = useTRPC();
let thumbnailInfo;
try {
thumbnailInfo = await trpc.file.thumbnail.query({ id: fileId });
} catch {
// TODO: Error Handling
return null;
}
const { contentIv: thumbnailEncryptedIv } = thumbnailInfo;
const { contentIv: thumbnailEncryptedIv }: FileThumbnailInfoResponse = await res.json();
res = await callGetApi(`/api/file/${fileId}/thumbnail/download`);
const res = await callGetApi(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailEncrypted = await res.arrayBuffer();
@@ -68,10 +70,15 @@ export const requestFileThumbnailDownload = async (fileId: number, dataKey: Cryp
};
export const requestDeletedFilesCleanup = async () => {
const res = await callGetApi("/api/file/list");
if (!res.ok) return;
const trpc = useTRPC();
let liveFiles;
try {
liveFiles = await trpc.file.list.query();
} catch {
// TODO: Error Handling
return;
}
const { files: liveFiles }: FileListResponse = await res.json();
const liveFilesSet = new Set(liveFiles);
const maybeCachedFiles = await getAllFileInfos();

View File

@@ -1,4 +1,4 @@
import { callGetApi, callPostApi } from "$lib/hooks";
import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB";
import {
encodeToBase64,
@@ -9,16 +9,9 @@ import {
signMasterKeyWrapped,
verifyMasterKeyWrapped,
} from "$lib/modules/crypto";
import type {
ClientRegisterRequest,
ClientRegisterResponse,
ClientRegisterVerifyRequest,
InitialHmacSecretRegisterRequest,
MasterKeyListResponse,
InitialMasterKeyRegisterRequest,
} from "$lib/server/schemas";
import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { useTRPC } from "$trpc/client";
export const requestClientRegistration = async (
encryptKeyBase64: string,
@@ -26,21 +19,24 @@ export const requestClientRegistration = async (
verifyKeyBase64: string,
signKey: CryptoKey,
) => {
let res = await callPostApi<ClientRegisterRequest>("/api/client/register", {
const trpc = useTRPC();
try {
const { id, challenge } = await trpc.client.register.mutate({
encPubKey: encryptKeyBase64,
sigPubKey: verifyKeyBase64,
});
if (!res.ok) return false;
const { id, challenge }: ClientRegisterResponse = await res.json();
const answer = await decryptChallenge(challenge, decryptKey);
const answerSig = await signMessageRSA(answer, signKey);
res = await callPostApi<ClientRegisterVerifyRequest>("/api/client/register/verify", {
await trpc.client.verify.mutate({
id,
answerSig: encodeToBase64(answerSig),
});
return res.ok;
return true;
} catch {
// TODO: Error Handling
return false;
}
};
export const requestClientRegistrationAndSessionUpgrade = async (
@@ -73,10 +69,16 @@ export const requestClientRegistrationAndSessionUpgrade = async (
};
export const requestMasterKeyDownload = async (decryptKey: CryptoKey, verifyKey: CryptoKey) => {
const res = await callGetApi("/api/mek/list");
if (!res.ok) return false;
const trpc = useTRPC();
let masterKeysWrapped;
try {
masterKeysWrapped = await trpc.mek.list.query();
} catch {
// TODO: Error Handling
return false;
}
const { meks: masterKeysWrapped }: MasterKeyListResponse = await res.json();
const masterKeys = await Promise.all(
masterKeysWrapped.map(
async ({ version, state, mek: masterKeyWrapped, mekSig: masterKeyWrappedSig }) => {
@@ -108,17 +110,32 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
hmacSecretWrapped: string,
signKey: CryptoKey,
) => {
let res = await callPostApi<InitialMasterKeyRegisterRequest>("/api/mek/register/initial", {
const trpc = useTRPC();
try {
await trpc.mek.registerInitial.mutate({
mek: masterKeyWrapped,
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
});
if (!res.ok) {
return res.status === 403 || res.status === 409;
} catch (e) {
if (
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true;
}
// TODO: Error Handling
return false;
}
res = await callPostApi<InitialHmacSecretRegisterRequest>("/api/hsk/register/initial", {
try {
await trpc.hsk.registerInitial.mutate({
mekVersion: 1,
hsk: hmacSecretWrapped,
});
return res.ok;
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,10 +1,13 @@
import { callPostApi } from "$lib/hooks";
import type { PasswordChangeRequest } from "$lib/server/schemas";
import { useTRPC } from "$trpc/client";
export const requestPasswordChange = async (oldPassword: string, newPassword: string) => {
const res = await callPostApi<PasswordChangeRequest>("/api/auth/changePassword", {
oldPassword,
newPassword,
});
return res.ok;
const trpc = useTRPC();
try {
await trpc.auth.changePassword.mutate({ oldPassword, newPassword });
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,5 +1,4 @@
import { callPostApi } from "$lib/hooks";
import type { LoginRequest } from "$lib/server/schemas";
import { useTRPC } from "$trpc/client";
export { requestLogout } from "$lib/services/auth";
export { requestDeletedFilesCleanup } from "$lib/services/file";
@@ -9,6 +8,13 @@ export {
} from "$lib/services/key";
export const requestLogin = async (email: string, password: string) => {
const res = await callPostApi<LoginRequest>("/api/auth/login", { email, password });
return res.ok;
const trpc = useTRPC();
try {
await trpc.auth.login.mutate({ email, password });
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -5,8 +5,12 @@
import { goto } from "$app/navigation";
import { FullscreenDiv } from "$lib/components/atoms";
import { Categories, IconEntryButton, TopBar } from "$lib/components/molecules";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem2";
import { getFileInfo } from "$lib/modules/filesystem2";
import {
getFileInfo,
getCategoryInfo,
type FileInfo,
type CategoryInfo,
} from "$lib/modules/filesystem";
import { captureVideoThumbnail } from "$lib/modules/thumbnail";
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte";
@@ -24,7 +28,8 @@
let { data } = $props();
let info = $derived(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!));
let info: Writable<FileInfo | null> | undefined = $state();
let categories: Writable<CategoryInfo | null>[] = $state([]);
let isAddToCategoryBottomSheetOpen = $state(false);
@@ -80,14 +85,19 @@
};
$effect(() => {
data.id;
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
isDownloadRequested = false;
viewerType = undefined;
});
$effect(() => {
if ($info.data?.dataKey && $info.data?.contentIv) {
const contentType = $info.data.contentType;
categories =
$info?.categoryIds.map((id) => getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)) ?? [];
});
$effect(() => {
if ($info && $info.dataKey && $info.contentIv) {
const contentType = $info.contentType;
if (contentType.startsWith("image")) {
viewerType = "image";
} else if (contentType.startsWith("video")) {
@@ -97,23 +107,21 @@
untrack(() => {
if (!downloadStatus && !isDownloadRequested) {
isDownloadRequested = true;
requestFileDownload(data.id, $info.data.contentIv!, $info.data.dataKey!).then(
async (buffer) => {
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
const blob = await updateViewer(buffer, contentType);
if (!viewerType) {
FileSaver.saveAs(blob, $info.data.name);
FileSaver.saveAs(blob, $info.name);
}
},
);
});
}
});
}
});
$effect(() => {
if ($info.status === "success" && $downloadStatus?.status === "decrypted") {
if ($info && $downloadStatus?.status === "decrypted") {
untrack(
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.data.contentType),
() => !isDownloadRequested && updateViewer($downloadStatus.result!, $info.contentType),
);
}
});
@@ -125,11 +133,11 @@
<title>파일</title>
</svelte:head>
<TopBar title={$info.data?.name} />
<TopBar title={$info?.name} />
<FullscreenDiv>
<div class="space-y-4 pb-4">
<DownloadStatus status={downloadStatus} />
{#if $info.status === "success" && viewerType}
{#if $info && viewerType}
<div class="flex w-full justify-center">
{#snippet viewerLoading(message: string)}
<p class="text-gray-500">{message}</p>
@@ -137,7 +145,7 @@
{#if viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.data.name} onerror={convertHeicToJpeg} />
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}
@@ -148,7 +156,7 @@
<video bind:this={videoElement} src={fileBlobUrl} controls muted></video>
<IconEntryButton
icon={IconCamera}
onclick={() => updateThumbnail($info.data.dataKey!, $info.data.dataKeyVersion!)}
onclick={() => updateThumbnail($info.dataKey!, $info.dataKeyVersion!)}
class="w-full"
>
이 장면을 썸네일로 설정하기
@@ -164,7 +172,7 @@
<p class="text-lg font-bold">카테고리</p>
<div class="space-y-1">
<Categories
categoryIds={$info.data?.categoryIds ?? []}
{categories}
categoryMenuIcon={IconClose}
onCategoryClick={({ id }) => goto(`/category/${id}`)}
onCategoryMenuClick={({ id }) => removeFromCategory(id)}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { BottomDiv, BottomSheet, Button, FullscreenDiv } from "$lib/components/atoms";
import { SubCategories } from "$lib/components/molecules";
import { CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, type CategoryInfoStore } from "$lib/modules/filesystem2";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { requestCategoryCreation } from "./service";
@@ -13,7 +14,7 @@
let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props();
let category: CategoryInfoStore | undefined = $state();
let category: Writable<CategoryInfo | null> | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
@@ -24,20 +25,20 @@
});
</script>
{#if $category?.status === "success"}
{#if $category}
<BottomSheet bind:isOpen class="flex flex-col">
<FullscreenDiv>
<SubCategories
class="py-4"
info={$category.data}
info={$category}
onSubCategoryClick={({ id }) =>
(category = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))}
onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)}
subCategoryCreatePosition="top"
/>
{#if $category.data.id !== "root"}
{#if $category.id !== "root"}
<BottomDiv>
<Button onclick={() => onAddToCategoryClick($category.data.id as number)} class="w-full">
<Button onclick={() => onAddToCategoryClick($category.id)} class="w-full">
이 카테고리에 추가하기
</Button>
</BottomDiv>
@@ -49,8 +50,8 @@
<CategoryCreateModal
bind:isOpen={isCategoryCreateModalOpen}
onCreateClick={async (name: string) => {
if (await requestCategoryCreation(name, $category!.data!.id, $masterKeyStore?.get(1)!)) {
category = getCategoryInfo($category!.data!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
if (await requestCategoryCreation(name, $category!.id, $masterKeyStore?.get(1)!)) {
category = getCategoryInfo($category!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;

View File

@@ -1,8 +1,7 @@
import { callPostApi } from "$lib/hooks";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { CategoryFileAddRequest } from "$lib/server/schemas";
import { requestFileThumbnailUpload } from "$lib/services/file";
import { useTRPC } from "$trpc/client";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
export { requestFileDownload } from "$lib/services/file";
@@ -23,8 +22,13 @@ export const requestThumbnailUpload = async (
};
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
const res = await callPostApi<CategoryFileAddRequest>(`/api/category/${categoryId}/file/add`, {
file: fileId,
});
return res.ok;
const trpc = useTRPC();
try {
await trpc.category.addFile.mutate({ id: categoryId, file: fileId });
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { get, type Writable } from "svelte/store";
import { getFileInfo } from "$lib/modules/filesystem2";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatNetworkSpeed } from "$lib/modules/util";
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
@@ -17,10 +17,14 @@
let { status }: Props = $props();
let fileInfo = $derived(getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!));
let fileInfo: Writable<FileInfo | null> | undefined = $state();
$effect(() => {
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
});
</script>
{#if $fileInfo.status === "success"}
{#if $fileInfo}
<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"}
@@ -38,8 +42,8 @@
{/if}
</div>
<div class="flex-grow overflow-hidden">
<p title={$fileInfo.data.name} class="truncate font-medium">
{$fileInfo.data.name}
<p title={$fileInfo.name} class="truncate font-medium">
{$fileInfo.name}
</p>
<p class="text-xs text-gray-800">
{#if $status.status === "download-pending"}

View File

@@ -1,17 +1,18 @@
<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";
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
import { getFileInfo, type FileInfoStore } from "$lib/modules/filesystem2";
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
import { formatFileSize } from "$lib/modules/util";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
interface FileCache {
index: FileCacheIndex;
fileInfo: FileInfoStore;
fileInfo: Writable<FileInfo | null>;
}
let fileCache: FileCache[] | undefined = $state();

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import type { FileCacheIndex } from "$lib/indexedDB";
import type { FileInfoStore } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatFileSize } from "$lib/modules/util";
import IconDraft from "~icons/material-symbols/draft";
@@ -9,7 +10,7 @@
interface Props {
index: FileCacheIndex;
info: FileInfoStore;
info: Writable<FileInfo | null>;
onDeleteClick: (fileId: number) => void;
}
@@ -27,8 +28,8 @@
</div>
{/if}
<div class="flex-grow overflow-hidden">
{#if $info.status === "success"}
<p title={$info.data.name} class="truncate font-medium">{$info.data.name}</p>
{#if $info}
<p title={$info.name} class="truncate font-medium">{$info.name}</p>
{:else}
<p class="font-medium">삭제된 파일</p>
{/if}

View File

@@ -5,7 +5,7 @@
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
import { IconEntryButton, TopBar } from "$lib/components/molecules";
import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
import { getFileInfo } from "$lib/modules/filesystem2";
import { getFileInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import File from "./File.svelte";
import {
@@ -21,8 +21,8 @@
const generateAllThumbnails = () => {
persistentStates.files.forEach(({ info }) => {
const fileInfo = get(info);
if (fileInfo.data) {
requestThumbnailGeneration(fileInfo.data);
if (fileInfo) {
requestThumbnailGeneration(fileInfo);
}
});
};

View File

@@ -1,14 +1,14 @@
import { error } from "@sveltejs/kit";
import { callPostApi } from "$lib/hooks";
import type { MissingThumbnailFileScanResponse } from "$lib/server/schemas";
import { useTRPC } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const res = await callPostApi("/api/file/scanMissingThumbnails", undefined, { fetch });
if (!res.ok) {
const trpc = useTRPC(fetch);
try {
const files = await trpc.file.listWithoutThumbnail.query();
return { files };
} catch {
error(500, "Internal server error");
}
const { files }: MissingThumbnailFileScanResponse = await res.json();
return { files };
};

View File

@@ -13,14 +13,14 @@
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo, FileInfoStore } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import type { GenerationStatus } from "./service.svelte";
import IconCamera from "~icons/material-symbols/camera";
interface Props {
info: FileInfoStore;
info: Writable<FileInfo | null>;
onclick: (selectedFile: FileInfo) => void;
onGenerateThumbnailClick: (selectedFile: FileInfo) => void;
generationStatus?: Writable<GenerationStatus>;
@@ -29,18 +29,18 @@
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
</script>
{#if $info.status === "success"}
{#if $info}
<ActionEntryButton
class="h-14"
onclick={() => onclick($info.data)}
onclick={() => onclick($info)}
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined}
onActionButtonClick={() => onGenerateThumbnailClick($info.data)}
onActionButtonClick={() => onGenerateThumbnailClick($info)}
actionButtonClass="text-gray-800"
>
{@const subtext =
$generationStatus && $generationStatus !== "uploaded"
? subtexts[$generationStatus]
: formatDateTime($info.data.createdAt ?? $info.data.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={$info.data.name} {subtext} />
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
<DirectoryEntryLabel type="file" name={$info.name} {subtext} />
</ActionEntryButton>
{/if}

View File

@@ -2,7 +2,7 @@ import { limitFunction } from "p-limit";
import { get, writable, type Writable } from "svelte/store";
import { encryptData } from "$lib/modules/crypto";
import { storeFileThumbnailCache } from "$lib/modules/file";
import type { FileInfo, FileInfoStore } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
@@ -17,7 +17,7 @@ export type GenerationStatus =
interface File {
id: number;
info: FileInfoStore;
info: Writable<FileInfo | null>;
status?: Writable<GenerationStatus>;
}

View File

@@ -1,8 +1,9 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, useCategoryFileRecursionToggle } from "$lib/modules/filesystem2";
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -18,9 +19,9 @@
let { data } = $props();
let context = createContext();
let info = $derived(getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!));
let toggleFileRecursion = useCategoryFileRecursionToggle();
let isFileRecursive = $derived($info.data?.isFileRecursive);
let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false);
@@ -28,8 +29,19 @@
let isCategoryDeleteModalOpen = $state(false);
$effect(() => {
if (isFileRecursive !== undefined && $info.data?.isFileRecursive !== isFileRecursive) {
$toggleFileRecursion.mutate({ id: data.id as number, isFileRecursive });
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
});
</script>
@@ -39,13 +51,13 @@
</svelte:head>
{#if data.id !== "root"}
<TopBar title={$info.data?.name} />
<TopBar title={$info?.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info.status === "success"}
{#if $info && isFileRecursive !== undefined}
<Category
bind:isFileRecursive
info={$info.data}
info={$info}
onFileClick={({ id }) => goto(`/file/${id}`)}
onFileRemoveClick={async ({ id }) => {
await requestFileRemovalFromCategory(id, data.id as number);

View File

@@ -1,8 +1,7 @@
import { getContext, setContext } from "svelte";
import { callPostApi } from "$lib/hooks";
import { encryptString } from "$lib/modules/crypto";
import type { SelectedCategory } from "$lib/components/molecules";
import type { CategoryRenameRequest } from "$lib/server/schemas";
import { useTRPC } from "$trpc/client";
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
@@ -18,17 +17,31 @@ export const useContext = () => {
};
export const requestCategoryRename = async (category: SelectedCategory, newName: string) => {
const trpc = useTRPC();
const newNameEncrypted = await encryptString(newName, category.dataKey);
const res = await callPostApi<CategoryRenameRequest>(`/api/category/${category.id}/rename`, {
dekVersion: category.dataKeyVersion.toISOString(),
try {
await trpc.category.rename.mutate({
id: category.id,
dekVersion: category.dataKeyVersion,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
return res.ok;
return true;
} catch {
// TODO: Error Handling
return false;
}
};
export const requestCategoryDeletion = async (category: SelectedCategory) => {
const res = await callPostApi(`/api/category/${category.id}/delete`);
return res.ok;
const trpc = useTRPC();
try {
await trpc.category.delete.mutate({ id: category.id });
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,23 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { goto } from "$app/navigation";
import { FloatingButton } from "$lib/components/atoms";
import { TopBar } from "$lib/components/molecules";
import {
storeFileCache,
deleteFileCache,
storeFileThumbnailCache,
deleteFileThumbnailCache,
} from "$lib/modules/file";
import {
getDirectoryInfo,
useDirectoryCreation,
useDirectoryRename,
useDirectoryDeletion,
useFileUpload,
useFileRename,
useFileDeletion,
} from "$lib/modules/filesystem2";
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
import DirectoryCreateModal from "./DirectoryCreateModal.svelte";
import DirectoryEntries from "./DirectoryEntries";
@@ -28,23 +15,21 @@
import EntryMenuBottomSheet from "./EntryMenuBottomSheet.svelte";
import EntryRenameModal from "./EntryRenameModal.svelte";
import UploadStatusCard from "./UploadStatusCard.svelte";
import { createContext, requestHmacSecretDownload } from "./service.svelte";
import {
createContext,
requestHmacSecretDownload,
requestDirectoryCreation,
requestFileUpload,
requestEntryRename,
requestEntryDeletion,
} from "./service.svelte";
import IconAdd from "~icons/material-symbols/add";
let { data } = $props();
let context = createContext();
let info = $derived(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!));
let requestDirectoryCreation = $derived(useDirectoryCreation(data.id, $masterKeyStore?.get(1)!));
let requestDirectoryRename = useDirectoryRename();
let requestDirectoryDeletion = $derived(useDirectoryDeletion(data.id));
let requestFileUpload = $derived(
useFileUpload(data.id, $masterKeyStore?.get(1)!, $hmacSecretStore?.get(1)!),
);
let requestFileRename = $derived(useFileRename());
let requestFileDeletion = $derived(useFileDeletion(data.id));
let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state();
let duplicatedFile: File | undefined = $state();
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
@@ -62,24 +47,21 @@
if (!files || files.length === 0) return;
for (const file of files) {
$requestFileUpload
.mutateAsync({
file,
onDuplicate: () => {
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
return new Promise((resolve) => {
duplicatedFile = file;
resolveForDuplicateFileModal = resolve;
isDuplicateFileModalOpen = true;
});
},
})
.then((res) => {
if (res) {
storeFileCache(res.fileId, res.fileBuffer); // Intended
if (res.thumbnailBuffer) {
storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended
}
}
if (!res) return;
// TODO: FIXME
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
})
.catch((e: Error) => {
// TODO: FIXME
console.error(e);
});
}
@@ -91,6 +73,10 @@
throw new Error("Failed to download hmac secrets");
}
});
$effect(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
});
</script>
<svelte:head>
@@ -101,17 +87,17 @@
<div class="flex h-full flex-col">
{#if data.id !== "root"}
<TopBar title={$info.data?.name} class="flex-shrink-0" />
<TopBar title={$info?.name} class="flex-shrink-0" />
{/if}
{#if $info.status === "success"}
{#if $info}
<div class={["flex flex-grow flex-col px-4 pb-4", data.id === "root" && "pt-4"]}>
<div class="flex gap-x-2">
<UploadStatusCard onclick={() => goto("/file/uploads")} />
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
</div>
{#key $info.data.id}
{#key $info}
<DirectoryEntries
info={$info.data}
info={$info}
onEntryClick={({ type, id }) => goto(`/${type}/${id}`)}
onEntryMenuClick={(entry) => {
context.selectedEntry = entry;
@@ -144,8 +130,11 @@
<DirectoryCreateModal
bind:isOpen={isDirectoryCreateModalOpen}
onCreateClick={async (name) => {
$requestDirectoryCreation.mutate({ name });
return true; // TODO
if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<DuplicateFileModal
@@ -175,45 +164,20 @@
<EntryRenameModal
bind:isOpen={isEntryRenameModalOpen}
onRenameClick={async (newName: string) => {
if (context.selectedEntry!.type === "directory") {
$requestDirectoryRename.mutate({
id: context.selectedEntry!.id,
dataKey: context.selectedEntry!.dataKey,
dataKeyVersion: context.selectedEntry!.dataKeyVersion,
newName,
});
return true; // TODO
} else {
$requestFileRename.mutate({
id: context.selectedEntry!.id,
dataKey: context.selectedEntry!.dataKey,
dataKeyVersion: context.selectedEntry!.dataKeyVersion,
newName,
});
return true; // TODO
if (await requestEntryRename(context.selectedEntry!, newName)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>
<EntryDeleteModal
bind:isOpen={isEntryDeleteModalOpen}
onDeleteClick={async () => {
if (context.selectedEntry!.type === "directory") {
const res = await $requestDirectoryDeletion.mutateAsync({ id: context.selectedEntry!.id });
if (!res) return false;
await Promise.all(
res.deletedFiles.flatMap((fileId) => [
deleteFileCache(fileId),
deleteFileThumbnailCache(fileId),
]),
);
return true; // TODO
} else {
await $requestFileDeletion.mutateAsync({ id: context.selectedEntry!.id });
await Promise.all([
deleteFileCache(context.selectedEntry!.id),
deleteFileThumbnailCache(context.selectedEntry!.id),
]);
return true; // TODO
if (await requestEntryDeletion(context.selectedEntry!)) {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
return true;
}
return false;
}}
/>

View File

@@ -1,12 +1,12 @@
<script lang="ts">
import { derived } from "svelte/store";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import {
getDirectoryInfo,
getFileInfo,
type DirectoryInfo,
type SubDirectoryInfo,
type FileInfo,
} from "$lib/modules/filesystem2";
} from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/modules/util";
import {
fileUploadStatusStore,
@@ -30,84 +30,88 @@
interface DirectoryEntry {
name?: string;
info: SubDirectoryInfo;
info: Writable<DirectoryInfo | null>;
}
type FileEntry =
| {
type: "file";
name?: string;
info: FileInfo;
info: Writable<FileInfo | null>;
}
| {
type: "uploading-file";
name: string;
info: FileUploadStatus;
info: Writable<FileUploadStatus>;
};
let subDirectories = $derived(
derived(
info.subDirectoryIds.map((id) => getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) => {
const subDirectories = infos
.filter(($info) => $info.status === "success")
.map(
($info) =>
({
name: $info.data.name,
info: $info.data as SubDirectoryInfo,
}) satisfies DirectoryEntry,
);
sortEntries(subDirectories, sortBy);
return subDirectories;
},
),
);
let files = $derived(
derived(
info.fileIds.map((id) => getFileInfo(id, $masterKeyStore?.get(1)?.key!)),
(infos) =>
infos
.filter(($info) => $info.status === "success")
.map(
($info) =>
({
let subDirectories: DirectoryEntry[] = $state([]);
let files: FileEntry[] = $state([]);
$effect(() => {
// TODO: Fix duplicated requests
subDirectories = info.subDirectoryIds.map((id) => {
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!);
return { name: get(info)?.name, info };
});
files = info.fileIds
.map((id): FileEntry => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
type: "file",
name: $info.data.name,
info: $info.data,
}) satisfies FileEntry,
),
),
);
let uploadingFiles = $derived(
derived($fileUploadStatusStore, (statuses) =>
statuses
.filter(({ parentId, status }) => parentId === info.id && isFileUploading(status))
.map(
($status) =>
({
name: get(info)?.name,
info,
};
})
.concat(
$fileUploadStatusStore
.filter((statusStore) => {
const { parentId, status } = get(statusStore);
return parentId === info.id && isFileUploading(status);
})
.map((status) => ({
type: "uploading-file",
name: $status.name,
info: $status,
}) satisfies FileEntry,
),
),
name: get(status).name,
info: status,
})),
);
let everyFiles = $derived(
derived([files, uploadingFiles], ([$files, $uploadingFiles]) => {
const allFiles = [...$files, ...$uploadingFiles];
sortEntries(allFiles, sortBy);
return allFiles;
const sort = () => {
sortEntries(subDirectories, sortBy);
sortEntries(files, sortBy);
};
return untrack(() => {
sort();
const unsubscribes = subDirectories
.map((subDirectory) =>
subDirectory.info.subscribe((value) => {
if (subDirectory.name === value?.name) return;
subDirectory.name = value?.name;
sort();
}),
)
.concat(
files.map((file) =>
file.info.subscribe((value) => {
if (file.name === value?.name) return;
file.name = value?.name;
sort();
}),
),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
{#if $subDirectories.length + $everyFiles.length > 0}
{#if subDirectories.length + files.length > 0}
<div class="space-y-1 pb-[4.5rem]">
{#each $subDirectories as { info }}
{#each subDirectories as { info }}
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{/each}
{#each $everyFiles as file}
{#each files as file}
{#if file.type === "file"}
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
{:else}

View File

@@ -1,7 +1,8 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { FileInfo } from "$lib/modules/filesystem2";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/modules/util";
import { requestFileThumbnailDownload } from "./service";
import type { SelectedEntry } from "../service.svelte";
@@ -9,7 +10,7 @@
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
info: FileInfo;
info: Writable<FileInfo | null>;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
@@ -19,22 +20,22 @@
let thumbnail: string | undefined = $state();
const openFile = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info!;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if (info.dataKey) {
requestFileThumbnailDownload(info.id, info.dataKey)
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
@@ -48,16 +49,18 @@
});
</script>
<ActionEntryButton
{#if $info}
<ActionEntryButton
class="h-14"
onclick={openFile}
actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu}
>
>
<DirectoryEntryLabel
type="file"
{thumbnail}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
name={$info.name}
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
/>
</ActionEntryButton>
</ActionEntryButton>
{/if}

View File

@@ -1,13 +1,16 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { ActionEntryButton } from "$lib/components/atoms";
import { DirectoryEntryLabel } from "$lib/components/molecules";
import type { SubDirectoryInfo } from "$lib/modules/filesystem2";
import type { DirectoryInfo } from "$lib/modules/filesystem";
import type { SelectedEntry } from "../service.svelte";
import IconMoreVert from "~icons/material-symbols/more-vert";
type SubDirectoryInfo = DirectoryInfo & { id: number };
interface Props {
info: SubDirectoryInfo;
info: Writable<DirectoryInfo | null>;
onclick: (selectedEntry: SelectedEntry) => void;
onOpenMenuClick: (selectedEntry: SelectedEntry) => void;
}
@@ -15,25 +18,27 @@
let { info, onclick, onOpenMenuClick }: Props = $props();
const openDirectory = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
const openMenu = () => {
const { id, dataKey, dataKeyVersion, name } = info;
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
};
</script>
<ActionEntryButton
{#if $info}
<ActionEntryButton
class="h-14"
onclick={openDirectory}
actionButtonIcon={IconMoreVert}
onActionButtonClick={openMenu}
>
<DirectoryEntryLabel type="directory" name={info.name} />
</ActionEntryButton>
>
<DirectoryEntryLabel type="directory" name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -1,35 +1,36 @@
<script lang="ts">
import type { Writable } from "svelte/store";
import { formatNetworkSpeed } from "$lib/modules/util";
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
import IconDraft from "~icons/material-symbols/draft";
interface Props {
status: FileUploadStatus;
status: Writable<FileUploadStatus>;
}
let { status }: Props = $props();
</script>
{#if isFileUploading(status.status)}
{#if isFileUploading($status.status)}
<div class="flex h-14 gap-x-4 p-2">
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
<IconDraft class="text-gray-600" />
</div>
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
<p title={status.name} class="truncate font-medium">
{status.name}
<p title={$status.name} class="truncate font-medium">
{$status.name}
</p>
<p class="text-xs">
{#if status.status === "encryption-pending"}
{#if $status.status === "encryption-pending"}
준비 중
{:else if status.status === "encrypting"}
{:else if $status.status === "encrypting"}
암호화하는 중
{:else if status.status === "upload-pending"}
{:else if $status.status === "upload-pending"}
업로드를 기다리는 중
{:else if status.status === "uploading"}
전송됨 {Math.floor((status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed((status.rate ?? 0) * 8)}
{:else if $status.status === "uploading"}
전송됨 {Math.floor(($status.progress ?? 0) * 100)}% ·
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
{/if}
</p>
</div>

View File

@@ -1,5 +1,4 @@
import { getContext, setContext } from "svelte";
import { callGetApi, callPostApi } from "$lib/hooks";
import { storeHmacSecrets } from "$lib/indexedDB";
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
import {
@@ -9,14 +8,8 @@ import {
deleteFileThumbnailCache,
uploadFile,
} from "$lib/modules/file";
import type {
DirectoryRenameRequest,
DirectoryCreateRequest,
FileRenameRequest,
HmacSecretListResponse,
DirectoryDeleteResponse,
} from "$lib/server/schemas";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
import { useTRPC } from "$trpc/client";
export interface SelectedEntry {
type: "directory" | "file";
@@ -40,10 +33,16 @@ export const useContext = () => {
export const requestHmacSecretDownload = async (masterKey: CryptoKey) => {
// TODO: MEK rotation
const res = await callGetApi("/api/hsk/list");
if (!res.ok) return false;
const trpc = useTRPC();
let hmacSecretsWrapped;
try {
hmacSecretsWrapped = await trpc.hsk.list.query();
} catch {
// TODO: Error Handling
return false;
}
const { hsks: hmacSecretsWrapped }: HmacSecretListResponse = await res.json();
const hmacSecrets = await Promise.all(
hmacSecretsWrapped.map(async ({ version, state, hsk: hmacSecretWrapped }) => {
const { hmacSecret } = await unwrapHmacSecret(hmacSecretWrapped, masterKey);
@@ -62,18 +61,24 @@ export const requestDirectoryCreation = async (
parentId: "root" | number,
masterKey: MasterKey,
) => {
const trpc = useTRPC();
const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(name, dataKey);
const res = await callPostApi<DirectoryCreateRequest>("/api/directory/create", {
try {
await trpc.directory.create.mutate({
parent: parentId,
mekVersion: masterKey.version,
dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(),
dekVersion: dataKeyVersion,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
});
return res.ok;
return true;
} catch {
// TODO: Error Handling
return false;
}
};
export const requestFileUpload = async (
@@ -95,37 +100,51 @@ export const requestFileUpload = async (
};
export const requestEntryRename = async (entry: SelectedEntry, newName: string) => {
const trpc = useTRPC();
const newNameEncrypted = await encryptString(newName, entry.dataKey);
let res;
try {
if (entry.type === "directory") {
res = await callPostApi<DirectoryRenameRequest>(`/api/directory/${entry.id}/rename`, {
dekVersion: entry.dataKeyVersion.toISOString(),
await trpc.directory.rename.mutate({
id: entry.id,
dekVersion: entry.dataKeyVersion,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
} else {
res = await callPostApi<FileRenameRequest>(`/api/file/${entry.id}/rename`, {
dekVersion: entry.dataKeyVersion.toISOString(),
await trpc.file.rename.mutate({
id: entry.id,
dekVersion: entry.dataKeyVersion,
name: newNameEncrypted.ciphertext,
nameIv: newNameEncrypted.iv,
});
}
return res.ok;
return true;
} catch {
// TODO: Error Handling
return false;
}
};
export const requestEntryDeletion = async (entry: SelectedEntry) => {
const res = await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
if (!res.ok) return false;
const trpc = useTRPC();
try {
if (entry.type === "directory") {
const { deletedFiles }: DirectoryDeleteResponse = await res.json();
const { deletedFiles } = await trpc.directory.delete.mutate({ id: entry.id });
await Promise.all(
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
deletedFiles.flatMap((fileId) => [
deleteFileCache(fileId),
deleteFileThumbnailCache(fileId),
]),
);
return true;
} else {
await trpc.file.delete.mutate({ id: entry.id });
await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]);
}
return true;
} catch {
// TODO: Error Handling
return false;
}
};

View File

@@ -1,14 +1,14 @@
import { error } from "@sveltejs/kit";
import { callGetApi } from "$lib/hooks";
import type { UserInfoResponse } from "$lib/server/schemas";
import { useTRPC } from "$trpc/client";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch }) => {
const res = await callGetApi("/api/user", { fetch });
if (!res.ok) {
const trpc = useTRPC(fetch);
try {
const { nickname } = await trpc.user.get.query();
return { nickname };
} catch {
error(500, "Internal server error");
}
const { nickname }: UserInfoResponse = await res.json();
return { nickname };
};

View File

@@ -1,8 +1,6 @@
<script lang="ts">
import { QueryClient, QueryClientProvider } from "@tanstack/svelte-query";
import { onMount } from "svelte";
import { get } from "svelte/store";
import { browser } from "$app/environment";
import { goto as svelteGoto } from "$app/navigation";
import {
fileUploadStatusStore,
@@ -12,19 +10,10 @@
clientKeyStore,
masterKeyStore,
} from "$lib/stores";
import "../app.css";
let { children } = $props();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
},
},
});
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
if (
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
@@ -35,8 +24,6 @@
};
onMount(async () => {
window.__TANSTACK_QUERY_CLIENT__ = queryClient;
const goto = async (url: string) => {
const whitelist = ["/auth/login", "/key", "/client/pending"];
if (!whitelist.some((path) => location.pathname.startsWith(path))) {
@@ -56,6 +43,4 @@
<svelte:window onbeforeunload={protectFileUploadAndDownload} />
<QueryClientProvider client={queryClient}>
{@render children()}
</QueryClientProvider>
{@render children()}

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { passwordChangeRequest } from "$lib/server/schemas";
import { changePassword } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { sessionId, userId } = await authorize(locals, "any");
const zodRes = passwordChangeRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { oldPassword, newPassword } = zodRes.data;
await changePassword(userId, sessionId, oldPassword, newPassword);
return text("Password changed", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,21 +0,0 @@
import { error, text } from "@sveltejs/kit";
import env from "$lib/server/loadenv";
import { loginRequest } from "$lib/server/schemas";
import { login } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request, cookies }) => {
const zodRes = loginRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { email, password } = zodRes.data;
const { sessionIdSigned } = await login(email, password, locals.ip, locals.userAgent);
cookies.set("sessionId", sessionIdSigned, {
path: "/",
maxAge: env.session.exp / 1000,
secure: true,
sameSite: "strict",
});
return text("Logged in", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,13 +0,0 @@
import { text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { logout } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, cookies }) => {
const { sessionId } = await authorize(locals, "any");
await logout(sessionId);
cookies.delete("sessionId", { path: "/" });
return text("Logged out", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,26 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
sessionUpgradeRequest,
sessionUpgradeResponse,
type SessionUpgradeResponse,
} from "$lib/server/schemas";
import { createSessionUpgradeChallenge } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { sessionId, userId } = await authorize(locals, "notClient");
const zodRes = sessionUpgradeRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data;
const { id, challenge } = await createSessionUpgradeChallenge(
sessionId,
userId,
locals.ip,
encPubKey,
sigPubKey,
);
return json(sessionUpgradeResponse.parse({ id, challenge } satisfies SessionUpgradeResponse));
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { sessionUpgradeVerifyRequest } from "$lib/server/schemas";
import { verifySessionUpgradeChallenge } from "$lib/server/services/auth";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { sessionId, userId } = await authorize(locals, "notClient");
const zodRes = sessionUpgradeVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { id, answerSig, force } = zodRes.data;
await verifySessionUpgradeChallenge(sessionId, userId, locals.ip, id, answerSig, force);
return text("Session upgraded", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,33 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryInfoResponse, type CategoryInfoResponse } from "$lib/server/schemas";
import { getCategoryInformation } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.union([z.enum(["root"]), z.coerce.number().int().positive()]),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { metadata, categories } = await getCategoryInformation(userId, id);
return json(
categoryInfoResponse.parse({
metadata: metadata && {
parent: metadata.parentId,
mekVersion: metadata.mekVersion,
dek: metadata.encDek,
dekVersion: metadata.dekVersion.toISOString(),
name: metadata.encName.ciphertext,
nameIv: metadata.encName.iv,
},
subCategories: categories,
} satisfies CategoryInfoResponse),
);
};

View File

@@ -1,20 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { deleteCategory } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
await deleteCategory(userId, id);
return text("Category deleted", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,25 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryFileAddRequest } from "$lib/server/schemas";
import { addCategoryFile } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = categoryFileAddRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { file } = bodyZodRes.data;
await addCategoryFile(userId, id, file);
return text("File added", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,36 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryFileListResponse, type CategoryFileListResponse } from "$lib/server/schemas";
import { getCategoryFiles } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, url, params }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const queryZodRes = z
.object({
recurse: z
.enum(["true", "false"])
.transform((value) => value === "true")
.nullable(),
})
.safeParse({ recurse: url.searchParams.get("recurse") });
if (!queryZodRes.success) error(400, "Invalid query parameters");
const { recurse } = queryZodRes.data;
const { files } = await getCategoryFiles(userId, id, recurse ?? false);
return json(
categoryFileListResponse.parse({
files: files.map(({ id, isRecursive }) => ({ file: id, isRecursive })),
} satisfies CategoryFileListResponse),
);
};

View File

@@ -1,25 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryFileRemoveRequest } from "$lib/server/schemas";
import { removeCategoryFile } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = categoryFileRemoveRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { file } = bodyZodRes.data;
await removeCategoryFile(userId, id, file);
return text("File removed", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,25 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { categoryRenameRequest } from "$lib/server/schemas";
import { renameCategory } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = categoryRenameRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { dekVersion, name, nameIv } = bodyZodRes.data;
await renameCategory(userId, id, new Date(dekVersion), { ciphertext: name, iv: nameIv });
return text("Category renamed", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,27 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
categoryCreateRequest,
categoryCreateResponse,
type CategoryCreateResponse,
} from "$lib/server/schemas";
import { createCategory } from "$lib/server/services/category";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = categoryCreateRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
const { id } = await createCategory({
userId,
parentId: parent,
mekVersion,
encDek: dek,
dekVersion: new Date(dekVersion),
encName: { ciphertext: name, iv: nameIv },
});
return json(categoryCreateResponse.parse({ category: id } satisfies CategoryCreateResponse));
};

View File

@@ -1,11 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientListResponse, type ClientListResponse } from "$lib/server/schemas";
import { getUserClientList } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "anyClient");
const { userClients } = await getUserClientList(userId);
return json(clientListResponse.parse({ clients: userClients } satisfies ClientListResponse));
};

View File

@@ -1,20 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
clientRegisterRequest,
clientRegisterResponse,
type ClientRegisterResponse,
} from "$lib/server/schemas";
import { registerUserClient } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "notClient");
const zodRes = clientRegisterRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { encPubKey, sigPubKey } = zodRes.data;
const { id, challenge } = await registerUserClient(userId, locals.ip, encPubKey, sigPubKey);
return json(clientRegisterResponse.parse({ id, challenge } satisfies ClientRegisterResponse));
};

View File

@@ -1,16 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientRegisterVerifyRequest } from "$lib/server/schemas";
import { verifyUserClient } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "notClient");
const zodRes = clientRegisterVerifyRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { id, answerSig } = zodRes.data;
await verifyUserClient(userId, locals.ip, id, answerSig);
return text("Client verified", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,17 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { clientStatusResponse, type ClientStatusResponse } from "$lib/server/schemas";
import { getUserClientStatus } from "$lib/server/services/client";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId, clientId } = await authorize(locals, "anyClient");
const { state, isInitialMekNeeded } = await getUserClientStatus(userId, clientId);
return json(
clientStatusResponse.parse({
id: clientId,
state,
isInitialMekNeeded,
} satisfies ClientStatusResponse),
);
};

View File

@@ -1,34 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { directoryInfoResponse, type DirectoryInfoResponse } from "$lib/server/schemas";
import { getDirectoryInformation } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.union([z.enum(["root"]), z.coerce.number().int().positive()]),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { metadata, directories, files } = await getDirectoryInformation(userId, id);
return json(
directoryInfoResponse.parse({
metadata: metadata && {
parent: metadata.parentId,
mekVersion: metadata.mekVersion,
dek: metadata.encDek,
dekVersion: metadata.dekVersion.toISOString(),
name: metadata.encName.ciphertext,
nameIv: metadata.encName.iv,
},
subDirectories: directories,
files,
} satisfies DirectoryInfoResponse),
);
};

View File

@@ -1,26 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { directoryDeleteResponse, type DirectoryDeleteResponse } from "$lib/server/schemas";
import { deleteDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { directories, files } = await deleteDirectory(userId, id);
return json(
directoryDeleteResponse.parse({
deletedDirectories: directories,
deletedFiles: files,
} satisfies DirectoryDeleteResponse),
);
};

View File

@@ -1,25 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { directoryRenameRequest } from "$lib/server/schemas";
import { renameDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = directoryRenameRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { dekVersion, name, nameIv } = bodyZodRes.data;
await renameDirectory(userId, id, new Date(dekVersion), { ciphertext: name, iv: nameIv });
return text("Directory renamed", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,27 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
directoryCreateRequest,
directoryCreateResponse,
type DirectoryCreateResponse,
} from "$lib/server/schemas";
import { createDirectory } from "$lib/server/services/directory";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = directoryCreateRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
const { id } = await createDirectory({
userId,
parentId: parent,
mekVersion,
encDek: dek,
dekVersion: new Date(dekVersion),
encName: { ciphertext: name, iv: nameIv },
});
return json(directoryCreateResponse.parse({ directory: id } satisfies DirectoryCreateResponse));
};

View File

@@ -1,48 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { fileInfoResponse, type FileInfoResponse } from "$lib/server/schemas";
import { getFileInformation } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const {
parentId,
mekVersion,
encDek,
dekVersion,
contentType,
encContentIv,
encName,
encCreatedAt,
encLastModifiedAt,
categories,
} = await getFileInformation(userId, id);
return json(
fileInfoResponse.parse({
parent: parentId,
mekVersion,
dek: encDek,
dekVersion: dekVersion.toISOString(),
contentType: contentType,
contentIv: encContentIv,
name: encName.ciphertext,
nameIv: encName.iv,
createdAt: encCreatedAt?.ciphertext,
createdAtIv: encCreatedAt?.iv,
lastModifiedAt: encLastModifiedAt.ciphertext,
lastModifiedAtIv: encLastModifiedAt.iv,
categories,
} satisfies FileInfoResponse),
);
};

View File

@@ -1,20 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { deleteFile } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
await deleteFile(userId, id);
return text("File deleted", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,25 +0,0 @@
import { error, text } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { fileRenameRequest } from "$lib/server/schemas";
import { renameFile } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, params, request }) => {
const { userId } = await authorize(locals, "activeClient");
const paramsZodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!paramsZodRes.success) error(400, "Invalid path parameters");
const { id } = paramsZodRes.data;
const bodyZodRes = fileRenameRequest.safeParse(await request.json());
if (!bodyZodRes.success) error(400, "Invalid request body");
const { dekVersion, name, nameIv } = bodyZodRes.data;
await renameFile(userId, id, new Date(dekVersion), { ciphertext: name, iv: nameIv });
return text("File renamed", { headers: { "Content-Type": "text/plain" } });
};

View File

@@ -1,26 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { z } from "zod";
import { authorize } from "$lib/server/modules/auth";
import { fileThumbnailInfoResponse, type FileThumbnailInfoResponse } from "$lib/server/schemas";
import { getFileThumbnailInformation } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals, params }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = z
.object({
id: z.coerce.number().int().positive(),
})
.safeParse(params);
if (!zodRes.success) error(400, "Invalid path parameters");
const { id } = zodRes.data;
const { updatedAt, encContentIv } = await getFileThumbnailInformation(userId, id);
return json(
fileThumbnailInfoResponse.parse({
updatedAt: updatedAt.toISOString(),
contentIv: encContentIv,
} satisfies FileThumbnailInfoResponse),
);
};

View File

@@ -1,11 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { fileListResponse, type FileListResponse } from "$lib/server/schemas";
import { getFileList } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "activeClient");
const { files } = await getFileList(userId);
return json(fileListResponse.parse({ files } satisfies FileListResponse));
};

View File

@@ -1,20 +0,0 @@
import { error, json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
duplicateFileScanRequest,
duplicateFileScanResponse,
type DuplicateFileScanResponse,
} from "$lib/server/schemas";
import { scanDuplicateFiles } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals, request }) => {
const { userId } = await authorize(locals, "activeClient");
const zodRes = duplicateFileScanRequest.safeParse(await request.json());
if (!zodRes.success) error(400, "Invalid request body");
const { hskVersion, contentHmac } = zodRes.data;
const { files } = await scanDuplicateFiles(userId, hskVersion, contentHmac);
return json(duplicateFileScanResponse.parse({ files } satisfies DuplicateFileScanResponse));
};

View File

@@ -1,16 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import {
missingThumbnailFileScanResponse,
type MissingThumbnailFileScanResponse,
} from "$lib/server/schemas/file";
import { scanMissingFileThumbnails } from "$lib/server/services/file";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "activeClient");
const { files } = await scanMissingFileThumbnails(userId);
return json(
missingThumbnailFileScanResponse.parse({ files } satisfies MissingThumbnailFileScanResponse),
);
};

View File

@@ -1,20 +0,0 @@
import { json } from "@sveltejs/kit";
import { authorize } from "$lib/server/modules/auth";
import { hmacSecretListResponse, type HmacSecretListResponse } from "$lib/server/schemas";
import { getHskList } from "$lib/server/services/hsk";
import type { RequestHandler } from "./$types";
export const GET: RequestHandler = async ({ locals }) => {
const { userId } = await authorize(locals, "activeClient");
const { encHsks } = await getHskList(userId);
return json(
hmacSecretListResponse.parse({
hsks: encHsks.map(({ version, state, mekVersion, encHsk }) => ({
version,
state,
mekVersion,
hsk: encHsk,
})),
} satisfies HmacSecretListResponse),
);
};

Some files were not shown because too many files have changed in this diff Show More