7 Commits
dev ... v0.6.0

Author SHA1 Message Date
static
90ac5ba4c3 Merge pull request #15 from kmc7468/dev
v0.6.0
2025-12-27 14:22:26 +09:00
static
dfffa004ac Merge pull request #13 from kmc7468/dev
v0.5.1
2025-07-12 19:56:12 +09:00
static
0cd55a413d Merge pull request #12 from kmc7468/dev
v0.5.0
2025-07-12 06:01:08 +09:00
static
361d966a59 Merge pull request #10 from kmc7468/dev
v0.4.0
2025-01-30 21:06:50 +09:00
static
aef43b8bfa Merge pull request #6 from kmc7468/dev
v0.3.0
2025-01-18 13:29:09 +09:00
static
7f128cccf6 Merge pull request #5 from kmc7468/dev
v0.2.0
2025-01-13 03:53:14 +09:00
static
a198e5f6dc Merge pull request #2 from kmc7468/dev
v0.1.0
2025-01-09 06:24:31 +09:00
196 changed files with 3484 additions and 6824 deletions

View File

@@ -12,7 +12,6 @@ node_modules
/data /data
/library /library
/thumbnails /thumbnails
/uploads
# OS # OS
.DS_Store .DS_Store

View File

@@ -12,4 +12,3 @@ USER_CLIENT_CHALLENGE_EXPIRES=
SESSION_UPGRADE_CHALLENGE_EXPIRES= SESSION_UPGRADE_CHALLENGE_EXPIRES=
LIBRARY_PATH= LIBRARY_PATH=
THUMBNAILS_PATH= THUMBNAILS_PATH=
UPLOADS_PATH=

View File

@@ -1,45 +0,0 @@
name: Docker Image Build
on:
release:
types: [published]
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=raw,value=latest
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max

1
.gitignore vendored
View File

@@ -10,7 +10,6 @@ node_modules
/data /data
/library /library
/thumbnails /thumbnails
/uploads
# OS # OS
.DS_Store .DS_Store

View File

@@ -9,7 +9,6 @@ services:
volumes: volumes:
- ./data/library:/app/data/library - ./data/library:/app/data/library
- ./data/thumbnails:/app/data/thumbnails - ./data/thumbnails:/app/data/thumbnails
- ./data/uploads:/app/data/uploads
environment: environment:
# ArkVault # ArkVault
- DATABASE_HOST=database - DATABASE_HOST=database
@@ -21,7 +20,6 @@ services:
- SESSION_UPGRADE_CHALLENGE_EXPIRES - SESSION_UPGRADE_CHALLENGE_EXPIRES
- LIBRARY_PATH=/app/data/library - LIBRARY_PATH=/app/data/library
- THUMBNAILS_PATH=/app/data/thumbnails - THUMBNAILS_PATH=/app/data/thumbnails
- UPLOADS_PATH=/app/data/uploads
# SvelteKit # SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
- XFF_DEPTH=${TRUST_PROXY:-} - XFF_DEPTH=${TRUST_PROXY:-}

View File

@@ -1,7 +1,7 @@
{ {
"name": "arkvault", "name": "arkvault",
"private": true, "private": true,
"version": "0.9.0", "version": "0.6.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@@ -16,14 +16,12 @@
"db:migrate": "kysely migrate" "db:migrate": "kysely migrate"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.1", "@eslint/compat": "^2.0.0",
"@eslint/js": "^9.39.2", "@iconify-json/material-symbols": "^1.2.50",
"@iconify-json/material-symbols": "^1.2.51", "@sveltejs/adapter-node": "^5.4.0",
"@noble/hashes": "^2.0.1", "@sveltejs/kit": "^2.49.2",
"@sveltejs/adapter-node": "^5.5.1", "@sveltejs/vite-plugin-svelte": "^6.2.1",
"@sveltejs/kit": "^2.49.5", "@tanstack/svelte-virtual": "^3.13.13",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tanstack/svelte-virtual": "^3.13.18",
"@trpc/client": "^11.8.1", "@trpc/client": "^11.8.1",
"@types/file-saver": "^2.0.7", "@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34", "@types/ms": "^0.7.34",
@@ -32,40 +30,40 @@
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"axios": "^1.13.2", "axios": "^1.13.2",
"dexie": "^4.2.1", "dexie": "^4.2.1",
"es-hangul": "^2.3.8",
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.14.0", "eslint-plugin-svelte": "^3.13.1",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.36.0", "exifreader": "^4.33.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"globals": "^17.0.0", "globals": "^16.5.0",
"heic2any": "^0.0.4", "heic2any": "^0.0.4",
"kysely-ctl": "^0.20.0", "kysely-ctl": "^0.19.0",
"lru-cache": "^11.2.4", "lru-cache": "^11.2.4",
"mime": "^4.1.0", "mime": "^4.1.0",
"p-limit": "^7.2.0", "p-limit": "^7.2.0",
"prettier": "^3.8.0", "prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1", "prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2", "prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.46.4", "svelte": "^5.46.1",
"svelte-check": "^4.3.5", "svelte-check": "^4.3.5",
"tailwindcss": "^3.4.19", "tailwindcss": "^3.4.19",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.53.0", "typescript-eslint": "^8.50.1",
"unplugin-icons": "^23.0.1", "unplugin-icons": "^22.5.0",
"vite": "^7.3.1" "vite": "^7.3.0"
}, },
"dependencies": { "dependencies": {
"@fastify/busboy": "^3.2.0",
"@trpc/server": "^11.8.1", "@trpc/server": "^11.8.1",
"argon2": "^0.44.0", "argon2": "^0.44.0",
"kysely": "^0.28.9", "kysely": "^0.28.9",
"ms": "^2.1.3", "ms": "^2.1.3",
"node-schedule": "^2.1.1", "node-schedule": "^2.1.1",
"pg": "^8.17.1", "pg": "^8.16.3",
"superjson": "^2.2.6", "superjson": "^2.2.6",
"uuid": "^13.0.0", "uuid": "^13.0.0",
"zod": "^4.3.5" "zod": "^4.2.1"
}, },
"engines": { "engines": {
"node": "^22.0.0", "node": "^22.0.0",

787
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import type { ClientInit } from "@sveltejs/kit"; import type { ClientInit } from "@sveltejs/kit";
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB"; import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
import { prepareFileCache } from "$lib/modules/file"; import { prepareFileCache } from "$lib/modules/file";
import { prepareOpfs } from "$lib/modules/opfs";
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores"; import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
const requestPersistentStorage = async () => { const requestPersistentStorage = async () => {
@@ -45,6 +46,7 @@ export const init: ClientInit = async () => {
prepareClientKeyStore(), prepareClientKeyStore(),
prepareMasterKeyStore(), prepareMasterKeyStore(),
prepareHmacSecretStore(), prepareHmacSecretStore(),
prepareOpfs(),
]); ]);
cleanupDanglingInfos(); // Intended cleanupDanglingInfos(); // Intended

View File

@@ -8,7 +8,6 @@ import {
cleanupExpiredSessionUpgradeChallenges, cleanupExpiredSessionUpgradeChallenges,
} from "$lib/server/db/session"; } from "$lib/server/db/session";
import { authenticate, setAgentInfo } from "$lib/server/middlewares"; import { authenticate, setAgentInfo } from "$lib/server/middlewares";
import { cleanupExpiredUploadSessions } from "$lib/server/services/upload";
export const init: ServerInit = async () => { export const init: ServerInit = async () => {
await migrateDB(); await migrateDB();
@@ -17,7 +16,6 @@ export const init: ServerInit = async () => {
cleanupExpiredUserClientChallenges(); cleanupExpiredUserClientChallenges();
cleanupExpiredSessions(); cleanupExpiredSessions();
cleanupExpiredSessionUpgradeChallenges(); cleanupExpiredSessionUpgradeChallenges();
cleanupExpiredUploadSessions();
}); });
}; };

View File

@@ -1,53 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
import IconClose from "~icons/material-symbols/close";
interface Props {
children: Snippet;
class?: ClassValue;
onclick?: () => void;
onRemoveClick?: () => void;
removable?: boolean;
selected?: boolean;
}
let {
children,
class: className,
onclick = () => (selected = !selected),
onRemoveClick,
removable = false,
selected = $bindable(false),
}: Props = $props();
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
onclick={onclick && (() => setTimeout(onclick, 100))}
class={[
"inline-flex cursor-pointer items-center gap-x-1 rounded-lg px-3 py-1.5 text-sm font-medium transition active:scale-95",
selected
? "bg-primary-500 text-white active:bg-primary-400"
: "bg-gray-100 text-gray-700 active:bg-gray-200",
className,
]}
>
<span>
{@render children()}
</span>
{#if removable && selected}
<button
onclick={(e) => {
e.stopPropagation();
if (onRemoveClick) {
setTimeout(onRemoveClick, 100);
}
}}
>
<IconClose />
</button>
{/if}
</div>

View File

@@ -1,70 +0,0 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import type { Snippet } from "svelte";
import type { ClassValue } from "svelte/elements";
interface Props {
class?: ClassValue;
count: number;
estimateItemHeight: (index: number) => number;
getItemKey?: (index: number) => string | number;
item: Snippet<[index: number]>;
itemGap?: number;
placeholder?: Snippet;
}
let {
class: className,
count,
estimateItemHeight,
getItemKey,
item,
itemGap,
placeholder,
}: Props = $props();
let element: HTMLElement | undefined = $state();
let scrollMargin = $state(0);
let virtualizer = $derived(
createWindowVirtualizer({
count,
estimateSize: estimateItemHeight,
gap: itemGap,
getItemKey: getItemKey,
scrollMargin,
}),
);
const measureItem = (node: HTMLElement) => {
$effect(() => $virtualizer.measureElement(node));
};
$effect(() => {
if (!element) return;
const observer = new ResizeObserver(() => {
scrollMargin = Math.round(element!.getBoundingClientRect().top + window.scrollY);
});
observer.observe(element.parentElement!);
return () => observer.disconnect();
});
</script>
<div bind:this={element} class={["relative", className]}>
<div style:height="{$virtualizer.getTotalSize()}px">
{#each $virtualizer.getVirtualItems() as virtualItem (virtualItem.key)}
<div
class="absolute left-0 top-0 w-full"
style:transform="translateY({virtualItem.start - scrollMargin}px)"
data-index={virtualItem.index}
use:measureItem
>
{@render item(virtualItem.index)}
</div>
{/each}
</div>
{#if placeholder && count === 0}
{@render placeholder()}
{/if}
</div>

View File

@@ -49,7 +49,7 @@
</div> </div>
</div> </div>
<style lang="postcss"> <style>
#container:active:not(:has(#action-button:active)) { #container:active:not(:has(#action-button:active)) {
@apply bg-gray-100; @apply bg-gray-100;
} }

View File

@@ -1,34 +1,42 @@
<script lang="ts"> <script lang="ts">
import { getFileThumbnail } from "$lib/modules/file"; import type { Writable } from "svelte/store";
import type { SummarizedFileInfo } from "$lib/modules/filesystem"; import type { FileInfo } from "$lib/modules/filesystem";
import { requestFileThumbnailDownload } from "$lib/services/file";
import IconFavorite from "~icons/material-symbols/favorite";
interface Props { interface Props {
info: SummarizedFileInfo; info: Writable<FileInfo | null>;
onclick?: (file: SummarizedFileInfo) => void; onclick?: (file: FileInfo) => void;
} }
let { info, onclick }: Props = $props(); let { info, onclick }: Props = $props();
let thumbnail = $derived(getFileThumbnail(info)); let thumbnail: string | undefined = $state();
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script> </script>
<button {#if $info}
onclick={onclick && (() => setTimeout(() => onclick(info), 100))} <button
class="relative aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90" onclick={() => onclick?.($info)}
> class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
{#if $thumbnail} >
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" /> {#if thumbnail}
<img src={thumbnail} alt={$info.name} class="h-full w-full object-cover" />
{:else} {:else}
<div class="h-full w-full bg-gray-100"></div> <div class="h-full w-full bg-gray-100"></div>
{/if} {/if}
{#if info.isFavorite} </button>
<div class={["absolute bottom-0.5 right-0.5"]}> {/if}
<IconFavorite
class="text-sm text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
/>
</div>
{/if}
</button>

View File

@@ -1,7 +1,5 @@
export { default as BottomSheet } from "./BottomSheet.svelte"; export { default as BottomSheet } from "./BottomSheet.svelte";
export * from "./buttons"; export * from "./buttons";
export { default as Chip } from "./Chip.svelte";
export * from "./divs"; export * from "./divs";
export * from "./inputs"; export * from "./inputs";
export { default as Modal } from "./Modal.svelte"; export { default as Modal } from "./Modal.svelte";
export { default as RowVirtualizer } from "./RowVirtualizer.svelte";

View File

@@ -28,7 +28,7 @@
</div> </div>
</div> </div>
<style lang="postcss"> <style>
input:focus, input:focus,
input:not(:placeholder-shown) { input:not(:placeholder-shown) {
@apply border-primary-300; @apply border-primary-300;

View File

@@ -1,44 +0,0 @@
<script module lang="ts">
import type { DataKey } from "$lib/modules/filesystem";
export interface SelectedCategory {
id: number;
dataKey?: DataKey;
name: string;
}
</script>
<script lang="ts">
import type { Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { ActionEntryButton } from "$lib/components/atoms";
import { CategoryLabel } from "$lib/components/molecules";
import type { SubCategoryInfo } from "$lib/modules/filesystem";
import { sortEntries } from "$lib/utils";
interface Props {
categories: SubCategoryInfo[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
}
let { categories, categoryMenuIcon, onCategoryClick, onCategoryMenuClick }: Props = $props();
let categoriesWithName = $derived(sortEntries([...categories]));
</script>
{#if categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as category (category.id)}
<ActionEntryButton
class="h-12"
onclick={() => onCategoryClick(category)}
actionButtonIcon={categoryMenuIcon}
onActionButtonClick={() => onCategoryMenuClick?.(category)}
>
<CategoryLabel name={category.name} />
</ActionEntryButton>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,63 @@
<script lang="ts">
import { untrack, type Component } from "svelte";
import type { SvelteHTMLElements } from "svelte/elements";
import { get, type Writable } from "svelte/store";
import type { CategoryInfo } from "$lib/modules/filesystem";
import { SortBy, sortEntries } from "$lib/utils";
import Category from "./Category.svelte";
import type { SelectedCategory } from "./service";
interface Props {
categories: Writable<CategoryInfo | null>[];
categoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
onCategoryClick: (category: SelectedCategory) => void;
onCategoryMenuClick?: (category: SelectedCategory) => void;
sortBy?: SortBy;
}
let {
categories,
categoryMenuIcon,
onCategoryClick,
onCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
}: Props = $props();
let categoriesWithName: { name?: string; info: Writable<CategoryInfo | null> }[] = $state([]);
$effect(() => {
categoriesWithName = categories.map((category) => ({
name: get(category)?.name,
info: category,
}));
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 categoriesWithName.length > 0}
<div class="space-y-1">
{#each categoriesWithName as { info }}
<Category
{info}
menuIcon={categoryMenuIcon}
onclick={onCategoryClick}
onMenuClick={onCategoryMenuClick}
/>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,43 @@
<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 { CategoryInfo } from "$lib/modules/filesystem";
import type { SelectedCategory } from "./service";
interface Props {
info: Writable<CategoryInfo | null>;
menuIcon?: Component<SvelteHTMLElements["svg"]>;
onclick: (category: SelectedCategory) => void;
onMenuClick?: (category: SelectedCategory) => void;
}
let { info, menuIcon, onclick, onMenuClick }: Props = $props();
const openCategory = () => {
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 as CategoryInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onMenuClick!({ id, dataKey, dataKeyVersion, name });
};
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openCategory}
actionButtonIcon={menuIcon}
onActionButtonClick={openMenu}
>
<CategoryLabel name={$info.name!} />
</ActionEntryButton>
{/if}

View File

@@ -0,0 +1,2 @@
export { default } from "./Categories.svelte";
export * from "./service";

View File

@@ -0,0 +1,6 @@
export interface SelectedCategory {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -1,8 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Component } from "svelte"; import type { Component } from "svelte";
import type { ClassValue, SvelteHTMLElements } from "svelte/elements"; import type { ClassValue, SvelteHTMLElements } from "svelte/elements";
import type { Writable } from "svelte/store";
import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules"; import { Categories, IconEntryButton, type SelectedCategory } from "$lib/components/molecules";
import type { CategoryInfo } from "$lib/modules/filesystem"; import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import IconAddCircle from "~icons/material-symbols/add-circle"; import IconAddCircle from "~icons/material-symbols/add-circle";
@@ -10,9 +12,9 @@
class?: ClassValue; class?: ClassValue;
info: CategoryInfo; info: CategoryInfo;
onSubCategoryClick: (subCategory: SelectedCategory) => void; onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick?: () => void; onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick?: (category: SelectedCategory) => void; onSubCategoryMenuClick?: (category: SelectedCategory) => void;
subCategoryCreatePosition?: "top" | "bottom" | "none"; subCategoryCreatePosition?: "top" | "bottom";
subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>; subCategoryMenuIcon?: Component<SvelteHTMLElements["svg"]>;
} }
@@ -22,9 +24,17 @@
onSubCategoryClick, onSubCategoryClick,
onSubCategoryCreateClick, onSubCategoryCreateClick,
onSubCategoryMenuClick, onSubCategoryMenuClick,
subCategoryCreatePosition = "none", subCategoryCreatePosition = "bottom",
subCategoryMenuIcon, subCategoryMenuIcon,
}: Props = $props(); }: Props = $props();
let subCategories: Writable<CategoryInfo | null>[] = $state([]);
$effect(() => {
subCategories = info.subCategoryIds.map((id) =>
getCategoryInfo(id, $masterKeyStore?.get(1)?.key!),
);
});
</script> </script>
<div class={["space-y-1", className]}> <div class={["space-y-1", className]}>
@@ -43,12 +53,14 @@
{#if subCategoryCreatePosition === "top"} {#if subCategoryCreatePosition === "top"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}
{#key info}
<Categories <Categories
categories={info.subCategories} categories={subCategories}
categoryMenuIcon={subCategoryMenuIcon} categoryMenuIcon={subCategoryMenuIcon}
onCategoryClick={onSubCategoryClick} onCategoryClick={onSubCategoryClick}
onCategoryMenuClick={onSubCategoryMenuClick} onCategoryMenuClick={onSubCategoryMenuClick}
/> />
{/key}
{#if subCategoryCreatePosition === "bottom"} {#if subCategoryCreatePosition === "bottom"}
{@render subCategoryCreate()} {@render subCategoryCreate()}
{/if} {/if}

View File

@@ -8,11 +8,10 @@
children?: Snippet; children?: Snippet;
class?: ClassValue; class?: ClassValue;
onBackClick?: () => void; onBackClick?: () => void;
showBackButton?: boolean;
title?: string; title?: string;
} }
let { children, class: className, onBackClick, showBackButton = true, title }: Props = $props(); let { children, class: className, onBackClick, title }: Props = $props();
</script> </script>
<div <div
@@ -21,16 +20,12 @@
className, className,
]} ]}
> >
<div class="w-[2.3rem] flex-shrink-0">
{#if showBackButton}
<button <button
onclick={onBackClick ?? (() => history.back())} onclick={onBackClick || (() => history.back())}
class="w-full rounded-full p-1 text-2xl active:bg-black active:bg-opacity-[0.04]" class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
> >
<IconArrowBack /> <IconArrowBack class="text-2xl" />
</button> </button>
{/if}
</div>
{#if title} {#if title}
<p class="flex-grow truncate text-center text-lg font-semibold">{title}</p> <p class="flex-grow truncate text-center text-lg font-semibold">{title}</p>
{/if} {/if}

View File

@@ -1,7 +1,7 @@
export * from "./ActionModal.svelte"; export * from "./ActionModal.svelte";
export { default as ActionModal } from "./ActionModal.svelte"; export { default as ActionModal } from "./ActionModal.svelte";
export * from "./Categories.svelte"; export * from "./Categories";
export { default as Categories } from "./Categories.svelte"; export { default as Categories } from "./Categories";
export { default as IconEntryButton } from "./IconEntryButton.svelte"; export { default as IconEntryButton } from "./IconEntryButton.svelte";
export * from "./labels"; export * from "./labels";
export { default as SubCategories } from "./SubCategories.svelte"; export { default as SubCategories } from "./SubCategories.svelte";

View File

@@ -5,11 +5,9 @@
import IconFolder from "~icons/material-symbols/folder"; import IconFolder from "~icons/material-symbols/folder";
import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload"; import IconDriveFolderUpload from "~icons/material-symbols/drive-folder-upload";
import IconDraft from "~icons/material-symbols/draft"; import IconDraft from "~icons/material-symbols/draft";
import IconFavorite from "~icons/material-symbols/favorite";
interface Props { interface Props {
class?: ClassValue; class?: ClassValue;
isFavorite?: boolean;
name: string; name: string;
subtext?: string; subtext?: string;
textClass?: ClassValue; textClass?: ClassValue;
@@ -19,7 +17,6 @@
let { let {
class: className, class: className,
isFavorite = false,
name, name,
subtext, subtext,
textClass: textClassName, textClass: textClassName,
@@ -29,7 +26,7 @@
</script> </script>
{#snippet iconSnippet()} {#snippet iconSnippet()}
<div class="relative flex h-10 w-10 items-center justify-center text-xl"> <div class="flex h-10 w-10 items-center justify-center text-xl">
{#if thumbnail} {#if thumbnail}
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" /> <img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
{:else if type === "directory"} {:else if type === "directory"}
@@ -39,14 +36,6 @@
{:else} {:else}
<IconDraft class="text-blue-400" /> <IconDraft class="text-blue-400" />
{/if} {/if}
{#if isFavorite}
<div class={["absolute bottom-0 right-0", !thumbnail && "rounded-full bg-white p-0.5"]}>
<IconFavorite
class="text-xs text-red-500"
style="filter: drop-shadow(0 0 1px white) drop-shadow(0 0 1px white)"
/>
</div>
{/if}
</div> </div>
{/snippet} {/snippet}

View File

@@ -0,0 +1,107 @@
<script lang="ts">
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 FileInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import { SortBy, sortEntries } from "$lib/utils";
import File from "./File.svelte";
import type { SelectedFile } from "./service";
import IconMoreVert from "~icons/material-symbols/more-vert";
interface Props {
info: CategoryInfo;
onFileClick: (file: SelectedFile) => void;
onFileRemoveClick: (file: SelectedFile) => void;
onSubCategoryClick: (subCategory: SelectedCategory) => void;
onSubCategoryCreateClick: () => void;
onSubCategoryMenuClick: (subCategory: SelectedCategory) => void;
sortBy?: SortBy;
isFileRecursive: boolean;
}
let {
info,
onFileClick,
onFileRemoveClick,
onSubCategoryClick,
onSubCategoryCreateClick,
onSubCategoryMenuClick,
sortBy = SortBy.NAME_ASC,
isFileRecursive = $bindable(),
}: Props = $props();
let files: { name?: string; info: Writable<FileInfo | null>; isRecursive: boolean }[] = $state(
[],
);
$effect(() => {
files =
info.files
?.filter(({ isRecursive }) => isFileRecursive || !isRecursive)
.map(({ id, isRecursive }) => {
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
return {
name: get(info)?.name,
info,
isRecursive,
};
}) ?? [];
const sort = () => {
sortEntries(files, sortBy);
};
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">
<div class="space-y-4 bg-white p-4">
{#if info.id !== "root"}
<p class="text-lg font-bold text-gray-800">하위 카테고리</p>
{/if}
<SubCategories
{info}
{onSubCategoryClick}
{onSubCategoryCreateClick}
{onSubCategoryMenuClick}
subCategoryMenuIcon={IconMoreVert}
/>
</div>
{#if info.id !== "root"}
<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>
<CheckBox bind:checked={isFileRecursive}>
<p class="font-medium">하위 카테고리의 파일</p>
</CheckBox>
</div>
<div class="space-y-1">
{#key info}
{#each files as { info, isRecursive }}
<File
{info}
onclick={onFileClick}
onRemoveClick={!isRecursive ? onFileRemoveClick : undefined}
/>
{:else}
<p class="text-gray-500 text-center">이 카테고리에 추가된 파일이 없어요.</p>
{/each}
{/key}
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,59 @@
<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/filesystem";
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
import IconClose from "~icons/material-symbols/close";
interface Props {
info: Writable<FileInfo | null>;
onclick: (selectedFile: SelectedFile) => void;
onRemoveClick?: (selectedFile: SelectedFile) => void;
}
let { info, onclick, onRemoveClick }: Props = $props();
let thumbnail: string | undefined = $state();
const openFile = () => {
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 as FileInfo;
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
};
$effect(() => {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;
})
.catch(() => {
// TODO: Error Handling
thumbnail = undefined;
});
} else {
thumbnail = undefined;
}
});
</script>
{#if $info}
<ActionEntryButton
class="h-12"
onclick={openFile}
actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={removeFile}
>
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
</ActionEntryButton>
{/if}

View File

@@ -0,0 +1,2 @@
export { default } from "./Category.svelte";
export * from "./service";

View File

@@ -0,0 +1,8 @@
export { requestFileThumbnailDownload } from "$lib/services/file";
export interface SelectedFile {
id: number;
dataKey: CryptoKey;
dataKeyVersion: Date;
name: string;
}

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { createWindowVirtualizer } from "@tanstack/svelte-virtual";
import { untrack } from "svelte";
import { get, type Writable } from "svelte/store";
import { FileThumbnailButton } from "$lib/components/atoms";
import type { FileInfo } from "$lib/modules/filesystem";
import { formatDate, formatDateSortable, SortBy, sortEntries } from "$lib/utils";
interface Props {
files: Writable<FileInfo | null>[];
onFileClick?: (file: FileInfo) => void;
}
let { files, onFileClick }: Props = $props();
type FileEntry =
| { date?: undefined; contentType?: undefined; info: Writable<FileInfo | null> }
| { date: Date; contentType: string; info: Writable<FileInfo | null> };
type Row =
| { type: "header"; key: string; label: string }
| { type: "items"; key: string; items: FileEntry[] };
let filesWithDate: FileEntry[] = $state([]);
let rows: Row[] = $state([]);
let listElement: HTMLDivElement | undefined = $state();
const virtualizer = createWindowVirtualizer({
count: 0,
getItemKey: (index) => rows[index]!.key,
estimateSize: () => 1000, // TODO
});
const measureRow = (node: HTMLElement) => {
$virtualizer.measureElement(node);
return {
update: () => $virtualizer.measureElement(node),
};
};
$effect(() => {
filesWithDate = files.map((file) => {
const info = get(file);
if (info) {
return {
date: info.createdAt ?? info.lastModifiedAt,
contentType: info.contentType,
info: file,
};
} else {
return { info: file };
}
});
const buildRows = () => {
const map = new Map<string, FileEntry[]>();
for (const file of filesWithDate) {
if (
!file.date ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
continue;
}
const date = formatDateSortable(file.date);
const entries = map.get(date) ?? [];
entries.push(file);
map.set(date, entries);
}
const newRows: Row[] = [];
const sortedDates = Array.from(map.keys()).sort((a, b) => b.localeCompare(a));
for (const date of sortedDates) {
const entries = map.get(date)!;
sortEntries(entries, SortBy.DATE_DESC);
newRows.push({
type: "header",
key: `header-${date}`,
label: formatDate(entries[0]!.date!),
});
newRows.push({
type: "items",
key: `items-${date}`,
items: entries,
});
}
rows = newRows;
$virtualizer.setOptions({ count: rows.length });
};
return untrack(() => {
buildRows();
const unsubscribes = filesWithDate.map((file) =>
file.info.subscribe((value) => {
const newDate = value?.createdAt ?? value?.lastModifiedAt;
const newContentType = value?.contentType;
if (file.date?.getTime() === newDate?.getTime() && file.contentType === newContentType) {
return;
}
file.date = newDate;
file.contentType = newContentType;
buildRows();
}),
);
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
});
});
</script>
<div bind:this={listElement} class="relative flex flex-grow flex-col">
<div style="height: {$virtualizer.getTotalSize()}px;">
{#each $virtualizer.getVirtualItems() as virtualRow (virtualRow.key)}
{@const row = rows[virtualRow.index]!}
<div
use:measureRow
data-index={virtualRow.index}
class="absolute left-0 top-0 w-full"
style="transform: translateY({virtualRow.start}px);"
>
{#if row.type === "header"}
<p class="pb-2 font-medium">{row.label}</p>
{:else}
<div class="grid grid-cols-4 gap-1 pb-4">
{#each row.items as { info }}
<FileThumbnailButton {info} onclick={onFileClick} />
{/each}
</div>
{/if}
</div>
{/each}
</div>
{#if $virtualizer.getVirtualItems().length === 0}
<div class="flex h-full flex-grow items-center justify-center">
<p class="text-gray-500">
{#if files.length === 0}
업로드된 파일이 없어요.
{:else if filesWithDate.length === 0}
파일 목록을 불러오고 있어요.
{:else}
사진 또는 동영상이 없어요.
{/if}
</p>
</div>
{/if}
</div>

View File

@@ -1 +1,4 @@
export * from "./Category";
export { default as Category } from "./Category";
export { default as Gallery } from "./Gallery.svelte";
export * from "./modals"; export * from "./modals";

View File

@@ -1,2 +0,0 @@
export * from "./serviceWorker";
export * from "./upload";

View File

@@ -1 +0,0 @@
export const DECRYPTED_FILE_URL_PREFIX = "/_internal/decryptedFile/";

View File

@@ -1,6 +0,0 @@
export const AES_GCM_IV_SIZE = 12;
export const AES_GCM_TAG_SIZE = 16;
export const ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE;
export const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD;

View File

@@ -20,12 +20,7 @@ export const getFileCacheIndex = async () => {
}; };
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => { export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
await cacheIndex.fileCache.put({ await cacheIndex.fileCache.put(fileCacheIndex);
fileId: fileCacheIndex.fileId,
cachedAt: fileCacheIndex.cachedAt,
lastRetrievedAt: fileCacheIndex.lastRetrievedAt,
size: fileCacheIndex.size,
});
}; };
export const deleteFileCacheIndex = async (fileId: number) => { export const deleteFileCacheIndex = async (fileId: number) => {

View File

@@ -1,10 +1,11 @@
import { Dexie, type EntityTable } from "dexie"; import { Dexie, type EntityTable } from "dexie";
export type DirectoryId = "root" | number;
interface DirectoryInfo { interface DirectoryInfo {
id: number; id: number;
parentId: DirectoryId; parentId: DirectoryId;
name: string; name: string;
isFavorite: boolean;
} }
interface FileInfo { interface FileInfo {
@@ -14,16 +15,17 @@ interface FileInfo {
contentType: string; contentType: string;
createdAt?: Date; createdAt?: Date;
lastModifiedAt: Date; lastModifiedAt: Date;
categoryIds?: number[]; categoryIds: number[];
isFavorite: boolean;
} }
export type CategoryId = "root" | number;
interface CategoryInfo { interface CategoryInfo {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;
name: string; name: string;
files?: { id: number; isRecursive: boolean }[]; files: { id: number; isRecursive: boolean }[];
isFileRecursive?: boolean; isFileRecursive: boolean;
} }
const filesystem = new Dexie("filesystem") as Dexie & { const filesystem = new Dexie("filesystem") as Dexie & {
@@ -48,23 +50,6 @@ filesystem
}); });
}); });
filesystem.version(4).upgrade(async (trx) => {
await Promise.all([
trx
.table("directory")
.toCollection()
.modify((directory) => {
directory.isFavorite = false;
}),
trx
.table("file")
.toCollection()
.modify((file) => {
file.isFavorite = false;
}),
]);
});
export const getDirectoryInfos = async (parentId: DirectoryId) => { export const getDirectoryInfos = async (parentId: DirectoryId) => {
return await filesystem.directory.where({ parentId }).toArray(); return await filesystem.directory.where({ parentId }).toArray();
}; };
@@ -74,27 +59,13 @@ export const getDirectoryInfo = async (id: number) => {
}; };
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => { export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
await filesystem.directory.upsert(directoryInfo.id, { await filesystem.directory.put(directoryInfo);
parentId: directoryInfo.parentId,
name: directoryInfo.name,
isFavorite: directoryInfo.isFavorite,
});
}; };
export const deleteDirectoryInfo = async (id: number) => { export const deleteDirectoryInfo = async (id: number) => {
await filesystem.directory.delete(id); await filesystem.directory.delete(id);
}; };
export const deleteDanglingDirectoryInfos = async (
parentId: DirectoryId,
validIds: Set<number>,
) => {
await filesystem.directory
.where({ parentId })
.and((directory) => !validIds.has(directory.id))
.delete();
};
export const getAllFileInfos = async () => { export const getAllFileInfos = async () => {
return await filesystem.file.toArray(); return await filesystem.file.toArray();
}; };
@@ -107,37 +78,14 @@ export const getFileInfo = async (id: number) => {
return await filesystem.file.get(id); return await filesystem.file.get(id);
}; };
export const bulkGetFileInfos = async (ids: number[]) => {
return await filesystem.file.bulkGet(ids);
};
export const storeFileInfo = async (fileInfo: FileInfo) => { export const storeFileInfo = async (fileInfo: FileInfo) => {
await filesystem.file.upsert(fileInfo.id, { await filesystem.file.put(fileInfo);
parentId: fileInfo.parentId,
name: fileInfo.name,
contentType: fileInfo.contentType,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
categoryIds: fileInfo.categoryIds,
isFavorite: fileInfo.isFavorite,
});
}; };
export const deleteFileInfo = async (id: number) => { export const deleteFileInfo = async (id: number) => {
await filesystem.file.delete(id); await filesystem.file.delete(id);
}; };
export const bulkDeleteFileInfos = async (ids: number[]) => {
await filesystem.file.bulkDelete(ids);
};
export const deleteDanglingFileInfos = async (parentId: DirectoryId, validIds: Set<number>) => {
await filesystem.file
.where({ parentId })
.and((file) => !validIds.has(file.id))
.delete();
};
export const getCategoryInfos = async (parentId: CategoryId) => { export const getCategoryInfos = async (parentId: CategoryId) => {
return await filesystem.category.where({ parentId }).toArray(); return await filesystem.category.where({ parentId }).toArray();
}; };
@@ -147,12 +95,7 @@ export const getCategoryInfo = async (id: number) => {
}; };
export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => { export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.upsert(categoryInfo.id, { await filesystem.category.put(categoryInfo);
parentId: categoryInfo.parentId,
name: categoryInfo.name,
files: categoryInfo.files,
isFileRecursive: categoryInfo.isFileRecursive,
});
}; };
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => { export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
@@ -163,13 +106,6 @@ export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id); await filesystem.category.delete(id);
}; };
export const deleteDanglingCategoryInfos = async (parentId: CategoryId, validIds: Set<number>) => {
await filesystem.category
.where({ parentId })
.and((category) => !validIds.has(category.id))
.delete();
};
export const cleanupDanglingInfos = async () => { export const cleanupDanglingInfos = async () => {
const validDirectoryIds: number[] = []; const validDirectoryIds: number[] = [];
const validFileIds: number[] = []; const validFileIds: number[] = [];

View File

@@ -70,12 +70,12 @@ export const storeMasterKeys = async (keys: MasterKey[]) => {
}; };
export const getHmacSecrets = async () => { export const getHmacSecrets = async () => {
return (await keyStore.hmacSecret.toArray()).filter(({ secret }) => secret.extractable); return await keyStore.hmacSecret.toArray();
}; };
export const storeHmacSecrets = async (secrets: HmacSecret[]) => { export const storeHmacSecrets = async (secrets: HmacSecret[]) => {
if (secrets.some(({ secret }) => !secret.extractable)) { if (secrets.some(({ secret }) => secret.extractable)) {
throw new Error("Hmac secrets must be extractable"); throw new Error("Hmac secrets must be nonextractable");
} }
await keyStore.hmacSecret.bulkPut(secrets); await keyStore.hmacSecret.bulkPut(secrets);
}; };

View File

@@ -1,15 +1,8 @@
import { AES_GCM_IV_SIZE } from "$lib/constants"; import { encodeString, decodeString, encodeToBase64, decodeFromBase64 } from "./util";
import {
encodeString,
decodeString,
encodeToBase64,
decodeFromBase64,
concatenateBuffers,
} from "./utils";
export const generateMasterKey = async () => { export const generateMasterKey = async () => {
return { return {
masterKey: await crypto.subtle.generateKey( masterKey: await window.crypto.subtle.generateKey(
{ {
name: "AES-KW", name: "AES-KW",
length: 256, length: 256,
@@ -22,7 +15,7 @@ export const generateMasterKey = async () => {
export const generateDataKey = async () => { export const generateDataKey = async () => {
return { return {
dataKey: await crypto.subtle.generateKey( dataKey: await window.crypto.subtle.generateKey(
{ {
name: "AES-GCM", name: "AES-GCM",
length: 256, length: 256,
@@ -35,9 +28,9 @@ export const generateDataKey = async () => {
}; };
export const makeAESKeyNonextractable = async (key: CryptoKey) => { export const makeAESKeyNonextractable = async (key: CryptoKey) => {
return await crypto.subtle.importKey( return await window.crypto.subtle.importKey(
"raw", "raw",
await crypto.subtle.exportKey("raw", key), await window.crypto.subtle.exportKey("raw", key),
key.algorithm, key.algorithm,
false, false,
key.usages, key.usages,
@@ -45,12 +38,12 @@ export const makeAESKeyNonextractable = async (key: CryptoKey) => {
}; };
export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => { export const wrapDataKey = async (dataKey: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW")); return encodeToBase64(await window.crypto.subtle.wrapKey("raw", dataKey, masterKey, "AES-KW"));
}; };
export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => { export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey) => {
return { return {
dataKey: await crypto.subtle.unwrapKey( dataKey: await window.crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(dataKeyWrapped), decodeFromBase64(dataKeyWrapped),
masterKey, masterKey,
@@ -63,12 +56,12 @@ export const unwrapDataKey = async (dataKeyWrapped: string, masterKey: CryptoKey
}; };
export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => { export const wrapHmacSecret = async (hmacSecret: CryptoKey, masterKey: CryptoKey) => {
return encodeToBase64(await crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW")); return encodeToBase64(await window.crypto.subtle.wrapKey("raw", hmacSecret, masterKey, "AES-KW"));
}; };
export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => { export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: CryptoKey) => {
return { return {
hmacSecret: await crypto.subtle.unwrapKey( hmacSecret: await window.crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(hmacSecretWrapped), decodeFromBase64(hmacSecretWrapped),
masterKey, masterKey,
@@ -77,15 +70,15 @@ export const unwrapHmacSecret = async (hmacSecretWrapped: string, masterKey: Cry
name: "HMAC", name: "HMAC",
hash: "SHA-256", hash: "SHA-256",
} satisfies HmacImportParams, } satisfies HmacImportParams,
true, // Extractable false, // Nonextractable
["sign", "verify"], ["sign", "verify"],
), ),
}; };
}; };
export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => { export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = window.crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt( const ciphertext = await window.crypto.subtle.encrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
iv, iv,
@@ -93,18 +86,14 @@ export const encryptData = async (data: BufferSource, dataKey: CryptoKey) => {
dataKey, dataKey,
data, data,
); );
return { ciphertext, iv: iv.buffer }; return { ciphertext, iv: encodeToBase64(iv.buffer) };
}; };
export const decryptData = async ( export const decryptData = async (ciphertext: BufferSource, iv: string, dataKey: CryptoKey) => {
ciphertext: BufferSource, return await window.crypto.subtle.decrypt(
iv: string | BufferSource,
dataKey: CryptoKey,
) => {
return await crypto.subtle.decrypt(
{ {
name: "AES-GCM", name: "AES-GCM",
iv: typeof iv === "string" ? decodeFromBase64(iv) : iv, iv: decodeFromBase64(iv),
} satisfies AesGcmParams, } satisfies AesGcmParams,
dataKey, dataKey,
ciphertext, ciphertext,
@@ -113,22 +102,9 @@ export const decryptData = async (
export const encryptString = async (plaintext: string, dataKey: CryptoKey) => { export const encryptString = async (plaintext: string, dataKey: CryptoKey) => {
const { ciphertext, iv } = await encryptData(encodeString(plaintext), dataKey); const { ciphertext, iv } = await encryptData(encodeString(plaintext), dataKey);
return { ciphertext: encodeToBase64(ciphertext), iv: encodeToBase64(iv) }; return { ciphertext: encodeToBase64(ciphertext), iv };
}; };
export const decryptString = async (ciphertext: string, iv: string, dataKey: CryptoKey) => { export const decryptString = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return decodeString(await decryptData(decodeFromBase64(ciphertext), iv, dataKey)); return decodeString(await decryptData(decodeFromBase64(ciphertext), iv, dataKey));
}; };
export const encryptChunk = async (chunk: ArrayBuffer, dataKey: CryptoKey) => {
const { ciphertext, iv } = await encryptData(chunk, dataKey);
return concatenateBuffers(iv, ciphertext).buffer;
};
export const decryptChunk = async (encryptedChunk: ArrayBuffer, dataKey: CryptoKey) => {
return await decryptData(
encryptedChunk.slice(AES_GCM_IV_SIZE),
encryptedChunk.slice(0, AES_GCM_IV_SIZE),
dataKey,
);
};

View File

@@ -1,4 +1,4 @@
export * from "./aes"; export * from "./aes";
export * from "./rsa"; export * from "./rsa";
export * from "./sha"; export * from "./sha";
export * from "./utils"; export * from "./util";

View File

@@ -1,7 +1,7 @@
import { encodeString, encodeToBase64, decodeFromBase64 } from "./utils"; import { encodeString, encodeToBase64, decodeFromBase64 } from "./util";
export const generateEncryptionKeyPair = async () => { export const generateEncryptionKeyPair = async () => {
const keyPair = await crypto.subtle.generateKey( const keyPair = await window.crypto.subtle.generateKey(
{ {
name: "RSA-OAEP", name: "RSA-OAEP",
modulusLength: 4096, modulusLength: 4096,
@@ -18,7 +18,7 @@ export const generateEncryptionKeyPair = async () => {
}; };
export const generateSigningKeyPair = async () => { export const generateSigningKeyPair = async () => {
const keyPair = await crypto.subtle.generateKey( const keyPair = await window.crypto.subtle.generateKey(
{ {
name: "RSA-PSS", name: "RSA-PSS",
modulusLength: 4096, modulusLength: 4096,
@@ -37,7 +37,7 @@ export const generateSigningKeyPair = async () => {
export const exportRSAKey = async (key: CryptoKey) => { export const exportRSAKey = async (key: CryptoKey) => {
const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const); const format = key.type === "public" ? ("spki" as const) : ("pkcs8" as const);
return { return {
key: await crypto.subtle.exportKey(format, key), key: await window.crypto.subtle.exportKey(format, key),
format, format,
}; };
}; };
@@ -54,14 +54,14 @@ export const importEncryptionKeyPairFromBase64 = async (
name: "RSA-OAEP", name: "RSA-OAEP",
hash: "SHA-256", hash: "SHA-256",
}; };
const encryptKey = await crypto.subtle.importKey( const encryptKey = await window.crypto.subtle.importKey(
"spki", "spki",
decodeFromBase64(encryptKeyBase64), decodeFromBase64(encryptKeyBase64),
algorithm, algorithm,
true, true,
["encrypt", "wrapKey"], ["encrypt", "wrapKey"],
); );
const decryptKey = await crypto.subtle.importKey( const decryptKey = await window.crypto.subtle.importKey(
"pkcs8", "pkcs8",
decodeFromBase64(decryptKeyBase64), decodeFromBase64(decryptKeyBase64),
algorithm, algorithm,
@@ -79,14 +79,14 @@ export const importSigningKeyPairFromBase64 = async (
name: "RSA-PSS", name: "RSA-PSS",
hash: "SHA-256", hash: "SHA-256",
}; };
const signKey = await crypto.subtle.importKey( const signKey = await window.crypto.subtle.importKey(
"pkcs8", "pkcs8",
decodeFromBase64(signKeyBase64), decodeFromBase64(signKeyBase64),
algorithm, algorithm,
true, true,
["sign"], ["sign"],
); );
const verifyKey = await crypto.subtle.importKey( const verifyKey = await window.crypto.subtle.importKey(
"spki", "spki",
decodeFromBase64(verifyKeyBase64), decodeFromBase64(verifyKeyBase64),
algorithm, algorithm,
@@ -98,11 +98,17 @@ export const importSigningKeyPairFromBase64 = async (
export const makeRSAKeyNonextractable = async (key: CryptoKey) => { export const makeRSAKeyNonextractable = async (key: CryptoKey) => {
const { key: exportedKey, format } = await exportRSAKey(key); const { key: exportedKey, format } = await exportRSAKey(key);
return await crypto.subtle.importKey(format, exportedKey, key.algorithm, false, key.usages); return await window.crypto.subtle.importKey(
format,
exportedKey,
key.algorithm,
false,
key.usages,
);
}; };
export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => { export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey) => {
return await crypto.subtle.decrypt( return await window.crypto.subtle.decrypt(
{ {
name: "RSA-OAEP", name: "RSA-OAEP",
} satisfies RsaOaepParams, } satisfies RsaOaepParams,
@@ -113,7 +119,7 @@ export const decryptChallenge = async (challenge: string, decryptKey: CryptoKey)
export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => { export const wrapMasterKey = async (masterKey: CryptoKey, encryptKey: CryptoKey) => {
return encodeToBase64( return encodeToBase64(
await crypto.subtle.wrapKey("raw", masterKey, encryptKey, { await window.crypto.subtle.wrapKey("raw", masterKey, encryptKey, {
name: "RSA-OAEP", name: "RSA-OAEP",
} satisfies RsaOaepParams), } satisfies RsaOaepParams),
); );
@@ -125,7 +131,7 @@ export const unwrapMasterKey = async (
extractable = false, extractable = false,
) => { ) => {
return { return {
masterKey: await crypto.subtle.unwrapKey( masterKey: await window.crypto.subtle.unwrapKey(
"raw", "raw",
decodeFromBase64(masterKeyWrapped), decodeFromBase64(masterKeyWrapped),
decryptKey, decryptKey,
@@ -140,7 +146,7 @@ export const unwrapMasterKey = async (
}; };
export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => { export const signMessageRSA = async (message: BufferSource, signKey: CryptoKey) => {
return await crypto.subtle.sign( return await window.crypto.subtle.sign(
{ {
name: "RSA-PSS", name: "RSA-PSS",
saltLength: 32, // SHA-256 saltLength: 32, // SHA-256
@@ -155,7 +161,7 @@ export const verifySignatureRSA = async (
signature: BufferSource, signature: BufferSource,
verifyKey: CryptoKey, verifyKey: CryptoKey,
) => { ) => {
return await crypto.subtle.verify( return await window.crypto.subtle.verify(
{ {
name: "RSA-PSS", name: "RSA-PSS",
saltLength: 32, // SHA-256 saltLength: 32, // SHA-256

View File

@@ -1,13 +1,10 @@
import HmacWorker from "$workers/hmac?worker";
import type { ComputeMessage, ResultMessage } from "$workers/hmac";
export const digestMessage = async (message: BufferSource) => { export const digestMessage = async (message: BufferSource) => {
return await crypto.subtle.digest("SHA-256", message); return await window.crypto.subtle.digest("SHA-256", message);
}; };
export const generateHmacSecret = async () => { export const generateHmacSecret = async () => {
return { return {
hmacSecret: await crypto.subtle.generateKey( hmacSecret: await window.crypto.subtle.generateKey(
{ {
name: "HMAC", name: "HMAC",
hash: "SHA-256", hash: "SHA-256",
@@ -18,24 +15,6 @@ export const generateHmacSecret = async () => {
}; };
}; };
export const signMessageHmac = async (message: Blob, hmacSecret: CryptoKey) => { export const signMessageHmac = async (message: BufferSource, hmacSecret: CryptoKey) => {
const stream = message.stream(); return await window.crypto.subtle.sign("HMAC", hmacSecret, message);
const hmacSecretRaw = new Uint8Array(await crypto.subtle.exportKey("raw", hmacSecret));
const worker = new HmacWorker();
return new Promise<Uint8Array>((resolve, reject) => {
worker.onmessage = ({ data }: MessageEvent<ResultMessage>) => {
resolve(data.result);
worker.terminate();
};
worker.onerror = ({ error }) => {
reject(error);
worker.terminate();
};
worker.postMessage({ stream, key: hmacSecretRaw } satisfies ComputeMessage, {
transfer: [stream, hmacSecretRaw.buffer],
});
});
}; };

View File

@@ -9,8 +9,8 @@ export const decodeString = (data: ArrayBuffer) => {
return textDecoder.decode(data); return textDecoder.decode(data);
}; };
export const encodeToBase64 = (data: ArrayBuffer | Uint8Array) => { export const encodeToBase64 = (data: ArrayBuffer) => {
return btoa(String.fromCharCode(...(data instanceof ArrayBuffer ? new Uint8Array(data) : data))); return btoa(String.fromCharCode(...new Uint8Array(data)));
}; };
export const decodeFromBase64 = (data: string) => { export const decodeFromBase64 = (data: string) => {

View File

@@ -1,12 +1,15 @@
import { LRUCache } from "lru-cache";
import { import {
getFileCacheIndex as getFileCacheIndexFromIndexedDB, getFileCacheIndex as getFileCacheIndexFromIndexedDB,
storeFileCacheIndex, storeFileCacheIndex,
deleteFileCacheIndex, deleteFileCacheIndex,
type FileCacheIndex, type FileCacheIndex,
} from "$lib/indexedDB"; } from "$lib/indexedDB";
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs"; import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
const fileCacheIndex = new Map<number, FileCacheIndex>(); const fileCacheIndex = new Map<number, FileCacheIndex>();
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
export const prepareFileCache = async () => { export const prepareFileCache = async () => {
for (const cache of await getFileCacheIndexFromIndexedDB()) { for (const cache of await getFileCacheIndexFromIndexedDB()) {
@@ -48,3 +51,30 @@ export const deleteFileCache = async (fileId: number) => {
await deleteFile(`/cache/${fileId}`); await deleteFile(`/cache/${fileId}`);
await deleteFileCacheIndex(fileId); await deleteFileCacheIndex(fileId);
}; };
export const getFileThumbnailCache = async (fileId: number) => {
const thumbnail = loadedThumbnails.get(fileId);
if (thumbnail) return thumbnail;
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (!thumbnailBuffer) return null;
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
loadedThumbnails.set(fileId, thumbnailUrl);
return thumbnailUrl;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory("/thumbnail/file");
};

View File

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

View File

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

View File

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

View File

@@ -1,77 +0,0 @@
import { LRUCache } from "lru-cache";
import { writable, type Writable } from "svelte/store";
import { browser } from "$app/environment";
import { decryptChunk } from "$lib/modules/crypto";
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
import { getThumbnailUrl } from "$lib/modules/thumbnail";
const loadedThumbnails = new LRUCache<number, Writable<string>>({ max: 100 });
const loadingThumbnails = new Map<number, Writable<string | undefined>>();
const fetchFromOpfs = async (fileId: number) => {
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
if (thumbnailBuffer) {
return getThumbnailUrl(thumbnailBuffer);
}
};
const fetchFromServer = async (fileId: number, dataKey: CryptoKey) => {
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailBuffer = await decryptChunk(await res.arrayBuffer(), dataKey);
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
return getThumbnailUrl(thumbnailBuffer);
};
export const getFileThumbnail = (file: SummarizedFileInfo) => {
if (
!browser ||
!(file.contentType.startsWith("image/") || file.contentType.startsWith("video/"))
) {
return undefined;
}
const thumbnail = loadedThumbnails.get(file.id);
if (thumbnail) return thumbnail;
let loadingThumbnail = loadingThumbnails.get(file.id);
if (loadingThumbnail) return loadingThumbnail;
loadingThumbnail = writable(undefined);
loadingThumbnails.set(file.id, loadingThumbnail);
fetchFromOpfs(file.id)
.then((thumbnail) => thumbnail ?? (file.dataKey && fetchFromServer(file.id, file.dataKey.key)))
.then((thumbnail) => {
if (thumbnail) {
loadingThumbnail.set(thumbnail);
loadedThumbnails.set(file.id, loadingThumbnail as Writable<string>);
}
loadingThumbnails.delete(file.id);
});
return loadingThumbnail;
};
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
const oldThumbnail = loadedThumbnails.get(fileId);
if (oldThumbnail) {
oldThumbnail.set(getThumbnailUrl(thumbnailBuffer));
} else {
loadedThumbnails.set(fileId, writable(getThumbnailUrl(thumbnailBuffer)));
}
};
export const deleteFileThumbnailCache = async (fileId: number) => {
loadedThumbnails.delete(fileId);
await deleteFile(`/thumbnail/file/${fileId}`);
};
export const deleteAllFileThumbnailCaches = async () => {
loadedThumbnails.clear();
await deleteDirectory(`/thumbnail/file`);
};

View File

@@ -1,263 +0,0 @@
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
import { CHUNK_SIZE } from "$lib/constants";
import { encodeToBase64, generateDataKey, wrapDataKey, encryptString } from "$lib/modules/crypto";
import { signMessageHmac } from "$lib/modules/crypto";
import { generateThumbnail } from "$lib/modules/thumbnail";
import { uploadBlob } from "$lib/modules/upload";
import type { MasterKey, HmacSecret } from "$lib/stores";
import { Scheduler } from "$lib/utils";
import { trpc } from "$trpc/client";
export interface FileUploadState {
id: string;
name: string;
parentId: DirectoryId;
status:
| "queued"
| "encryption-pending"
| "encrypting"
| "upload-pending"
| "uploading"
| "uploaded"
| "canceled"
| "error";
progress?: number;
rate?: number;
estimated?: number;
}
export type LiveFileUploadState = FileUploadState & {
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
};
const scheduler = new Scheduler<
{ fileId: number; fileBuffer?: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
>();
let uploadingFiles: FileUploadState[] = $state([]);
const isFileUploading = (status: FileUploadState["status"]) =>
["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
export const getUploadingFiles = (parentId?: DirectoryId) => {
return uploadingFiles.filter(
(file) =>
(parentId === undefined || file.parentId === parentId) && isFileUploading(file.status),
);
};
export const clearUploadedFiles = () => {
uploadingFiles = uploadingFiles.filter((file) => isFileUploading(file.status));
};
const requestDuplicateFileScan = limitFunction(
async (
state: FileUploadState,
file: File,
hmacSecret: HmacSecret,
onDuplicate: () => Promise<boolean>,
) => {
state.status = "encryption-pending";
const fileSigned = encodeToBase64(await signMessageHmac(file, hmacSecret.secret));
const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (files.length === 0 || (await onDuplicate())) {
return { fileSigned };
} else {
return {};
}
},
{ concurrency: 1 },
);
const getFileType = (file: File) => {
if (file.type) return file.type;
if (file.name.endsWith(".heic")) return "image/heic";
throw new Error("Unknown file type");
};
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
if (!dateTimeOriginal) return undefined;
const [date, time] = dateTimeOriginal.split(" ");
if (!date || !time) return undefined;
const [year, month, day] = date.split(":").map(Number);
const [hour, minute, second] = time.split(":").map(Number);
if (!year || !month || !day || !hour || !minute || !second) return undefined;
if (!offsetTimeOriginal) {
// No timezone information.. Assume local timezone
return new Date(year, month - 1, day, hour, minute, second);
}
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
return new Date(utcDate - offsetMs);
};
interface FileMetadata {
parentId: "root" | number;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
}
const requestFileMetadataEncryption = limitFunction(
async (
state: FileUploadState,
file: Blob,
fileMetadata: FileMetadata,
masterKey: MasterKey,
hmacSecret: HmacSecret,
) => {
state.status = "encrypting";
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const [nameEncrypted, createdAtEncrypted, lastModifiedAtEncrypted, thumbnailBuffer] =
await Promise.all([
encryptString(fileMetadata.name, dataKey),
fileMetadata.createdAt &&
encryptString(fileMetadata.createdAt.getTime().toString(), dataKey),
encryptString(fileMetadata.lastModifiedAt.getTime().toString(), dataKey),
generateThumbnail(file).then((blob) => blob?.arrayBuffer()),
]);
const { uploadId } = await trpc().upload.startFileUpload.mutate({
chunks: Math.ceil(file.size / CHUNK_SIZE),
parent: fileMetadata.parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion,
hskVersion: hmacSecret.version,
contentType: file.type,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
});
state.status = "upload-pending";
return { uploadId, thumbnailBuffer, dataKey, dataKeyVersion };
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (
state: FileUploadState,
uploadId: string,
file: Blob,
fileSigned: string,
thumbnailBuffer: ArrayBuffer | undefined,
dataKey: CryptoKey,
dataKeyVersion: Date,
) => {
state.status = "uploading";
await uploadBlob(uploadId, file, dataKey, {
onProgress(s) {
state.progress = s.progress;
state.rate = s.rate;
},
});
const { file: fileId } = await trpc().upload.completeFileUpload.mutate({
uploadId,
contentHmac: fileSigned,
});
if (thumbnailBuffer) {
try {
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({
file: fileId,
dekVersion: dataKeyVersion,
});
await uploadBlob(uploadId, new Blob([thumbnailBuffer]), dataKey);
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
} catch (e) {
console.error(e);
}
}
state.status = "uploaded";
return { fileId };
},
{ concurrency: 1 },
);
export const uploadFile = async (
file: File,
parentId: "root" | number,
masterKey: MasterKey,
hmacSecret: HmacSecret,
onDuplicate: () => Promise<boolean>,
) => {
uploadingFiles.push({
id: crypto.randomUUID(),
name: file.name,
parentId,
status: "queued",
});
const state = uploadingFiles.at(-1)!;
return await scheduler.schedule(file.size, async () => {
try {
const { fileSigned } = await requestDuplicateFileScan(state, file, hmacSecret, onDuplicate);
if (!fileSigned) {
state.status = "canceled";
uploadingFiles = uploadingFiles.filter((file) => file !== state);
return;
}
let fileBuffer;
const fileType = getFileType(file);
const fileMetadata: FileMetadata = {
parentId,
name: file.name,
lastModifiedAt: new Date(file.lastModified),
};
if (fileType.startsWith("image/")) {
fileBuffer = await file.arrayBuffer();
fileMetadata.createdAt = extractExifDateTime(fileBuffer);
}
const blob = new Blob([file], { type: fileType });
const { uploadId, thumbnailBuffer, dataKey, dataKeyVersion } =
await requestFileMetadataEncryption(state, blob, fileMetadata, masterKey, hmacSecret);
const { fileId } = await requestFileUpload(
state,
uploadId,
blob,
fileSigned,
thumbnailBuffer,
dataKey,
dataKeyVersion,
);
return { fileId, fileBuffer, thumbnailBuffer };
} catch (e) {
state.status = "error";
throw e;
}
});
};

View File

@@ -0,0 +1,264 @@
import axios from "axios";
import ExifReader from "exifreader";
import { limitFunction } from "p-limit";
import { writable, type Writable } from "svelte/store";
import {
encodeToBase64,
generateDataKey,
wrapDataKey,
encryptData,
encryptString,
digestMessage,
signMessageHmac,
} from "$lib/modules/crypto";
import { generateThumbnail } from "$lib/modules/thumbnail";
import type {
FileThumbnailUploadRequest,
FileUploadRequest,
FileUploadResponse,
} from "$lib/server/schemas";
import {
fileUploadStatusStore,
type MasterKey,
type HmacSecret,
type FileUploadStatus,
} from "$lib/stores";
import { trpc } from "$trpc/client";
const requestDuplicateFileScan = limitFunction(
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const files = await trpc().file.listByHash.query({
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (files.length === 0 || (await onDuplicate())) {
return { fileBuffer, fileSigned };
} else {
return {};
}
},
{ concurrency: 1 },
);
const getFileType = (file: File) => {
if (file.type) return file.type;
if (file.name.endsWith(".heic")) return "image/heic";
throw new Error("Unknown file type");
};
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
const exif = ExifReader.load(fileBuffer);
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
if (!dateTimeOriginal) return undefined;
const [date, time] = dateTimeOriginal.split(" ");
if (!date || !time) return undefined;
const [year, month, day] = date.split(":").map(Number);
const [hour, minute, second] = time.split(":").map(Number);
if (!year || !month || !day || !hour || !minute || !second) return undefined;
if (!offsetTimeOriginal) {
// No timezone information.. Assume local timezone
return new Date(year, month - 1, day, hour, minute, second);
}
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
return new Date(utcDate - offsetMs);
};
const encryptFile = limitFunction(
async (
status: Writable<FileUploadStatus>,
file: File,
fileBuffer: ArrayBuffer,
masterKey: MasterKey,
) => {
status.update((value) => {
value.status = "encrypting";
return value;
});
const fileType = getFileType(file);
let createdAt;
if (fileType.startsWith("image/")) {
createdAt = extractExifDateTime(fileBuffer);
}
const { dataKey, dataKeyVersion } = await generateDataKey();
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
const fileEncrypted = await encryptData(fileBuffer, dataKey);
const fileEncryptedHash = encodeToBase64(await digestMessage(fileEncrypted.ciphertext));
const nameEncrypted = await encryptString(file.name, dataKey);
const createdAtEncrypted =
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
const thumbnail = await generateThumbnail(fileBuffer, fileType);
const thumbnailBuffer = await thumbnail?.arrayBuffer();
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
status.update((value) => {
value.status = "upload-pending";
return value;
});
return {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
};
},
{ concurrency: 4 },
);
const requestFileUpload = limitFunction(
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => {
status.update((value) => {
value.status = "uploading";
return value;
});
const res = await axios.post("/api/file/upload", form, {
onUploadProgress: ({ progress, rate, estimated }) => {
status.update((value) => {
value.progress = progress;
value.rate = rate;
value.estimated = estimated;
return value;
});
},
});
const { file }: FileUploadResponse = res.data;
if (thumbnailForm) {
try {
await axios.post(`/api/file/${file}/thumbnail/upload`, thumbnailForm);
} catch (e) {
// TODO
console.error(e);
}
}
status.update((value) => {
value.status = "uploaded";
return value;
});
return { fileId: file };
},
{ concurrency: 1 },
);
export const uploadFile = async (
file: File,
parentId: "root" | number,
hmacSecret: HmacSecret,
masterKey: MasterKey,
onDuplicate: () => Promise<boolean>,
): Promise<
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
> => {
const status = writable<FileUploadStatus>({
name: file.name,
parentId,
status: "encryption-pending",
});
fileUploadStatusStore.update((value) => {
value.push(status);
return value;
});
try {
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
file,
hmacSecret,
onDuplicate,
);
if (!fileBuffer || !fileSigned) {
status.update((value) => {
value.status = "canceled";
return value;
});
fileUploadStatusStore.update((value) => {
value = value.filter((v) => v !== status);
return value;
});
return undefined;
}
const {
dataKeyWrapped,
dataKeyVersion,
fileType,
fileEncrypted,
fileEncryptedHash,
nameEncrypted,
createdAtEncrypted,
lastModifiedAtEncrypted,
thumbnail,
} = await encryptFile(status, file, fileBuffer, masterKey);
const form = new FormData();
form.set(
"metadata",
JSON.stringify({
parent: parentId,
mekVersion: masterKey.version,
dek: dataKeyWrapped,
dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
contentType: fileType,
contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext,
nameIv: nameEncrypted.iv,
createdAt: createdAtEncrypted?.ciphertext,
createdAtIv: createdAtEncrypted?.iv,
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
} satisfies FileUploadRequest),
);
form.set("content", new Blob([fileEncrypted.ciphertext]));
form.set("checksum", fileEncryptedHash);
let thumbnailForm = null;
if (thumbnail) {
thumbnailForm = new FormData();
thumbnailForm.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: thumbnail.iv,
} satisfies FileThumbnailUploadRequest),
);
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
}
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
} catch (e) {
status.update((value) => {
value.status = "error";
return value;
});
throw e;
}
};

View File

@@ -0,0 +1,370 @@
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 { trpc } from "$trpc/client";
export type DirectoryInfo =
| {
id: "root";
parentId?: undefined;
dataKey?: undefined;
dataKeyVersion?: undefined;
name?: undefined;
subDirectoryIds: number[];
fileIds: number[];
}
| {
id: number;
parentId: DirectoryId;
dataKey?: CryptoKey;
dataKeyVersion?: Date;
name: string;
subDirectoryIds: number[];
fileIds: number[];
};
export interface FileInfo {
id: number;
parentId: DirectoryId;
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,
parentId: directory.parentId,
name: directory.name,
subDirectoryIds,
fileIds,
});
}
};
const fetchDirectoryInfoFromServer = async (
id: DirectoryId,
info: Writable<DirectoryInfo | null>,
masterKey: CryptoKey,
) => {
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,
parentId: metadata!.parent,
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,
) => {
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,
parentId: metadata.parent,
dataKey,
dataKeyVersion: new Date(metadata.dekVersion),
contentType: metadata.contentType,
contentIv: metadata.contentIv,
name,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
await storeFileInfo({
id,
parentId: metadata.parent,
name,
contentType: metadata.contentType,
createdAt,
lastModifiedAt,
categoryIds: metadata.categories,
});
};
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
await fetchFileInfoFromIndexedDB(id, info);
await fetchFileInfoFromServer(id, info, masterKey);
};
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
// TODO: MEK rotation
let info = fileInfoStore.get(fileId);
if (!info) {
info = writable(null);
fileInfoStore.set(fileId, info);
}
fetchFileInfo(fileId, info, masterKey); // Intended
return info;
};
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,
) => {
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,123 +0,0 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { CategoryInfo, MaybeCategoryInfo } from "./types";
const cache = new FilesystemCache<CategoryId, MaybeCategoryInfo>({
async fetchFromIndexedDB(id) {
const [category, subCategories] = await Promise.all([
id !== "root" ? IndexedDB.getCategoryInfo(id) : undefined,
IndexedDB.getCategoryInfos(id),
]);
const files = category?.files
? await Promise.all(
category.files.map(async (file) => {
const fileInfo = await IndexedDB.getFileInfo(file.id);
return fileInfo
? {
id: file.id,
parentId: fileInfo.parentId,
contentType: fileInfo.contentType,
name: fileInfo.name,
createdAt: fileInfo.createdAt,
lastModifiedAt: fileInfo.lastModifiedAt,
isFavorite: fileInfo.isFavorite,
isRecursive: file.isRecursive,
}
: undefined;
}),
)
: undefined;
if (id === "root") {
return {
id,
exists: true,
subCategories,
};
} else if (category) {
return {
id,
exists: true,
parentId: category.parentId,
name: category.name,
subCategories,
files: files?.filter((file) => !!file) ?? [],
isFileRecursive: category.isFileRecursive ?? false,
};
}
},
async fetchFromServer(id, cachedInfo, masterKey) {
try {
const category = await trpc().category.get.query({ id, recurse: true });
const [subCategories, files, metadata] = await Promise.all([
Promise.all(
category.subCategories.map(async (category) => ({
id: category.id,
parentId: id,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
category.files &&
Promise.all(
category.files.map(async (file) => ({
id: file.id,
parentId: file.parent,
contentType: file.contentType,
isFavorite: file.isFavorite,
isRecursive: file.isRecursive,
...(await decryptFileMetadata(file, masterKey)),
})),
),
category.metadata && decryptCategoryMetadata(category.metadata, masterKey),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: category.metadata!.parent,
subCategories,
files: files!,
isFileRecursive: cachedInfo?.isFileRecursive ?? false,
...metadata!,
}
: { id, subCategories },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteCategoryInfo(id as number);
return { id, exists: false };
}
throw e;
}
},
});
const storeToIndexedDB = (info: CategoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeCategoryInfo(info);
// TODO: Bulk Upsert
new Map(info.files.map((file) => [file.id, file])).forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
}
// TODO: Bulk Upsert
info.subCategories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
void IndexedDB.deleteDanglingCategoryInfos(
info.id,
new Set(info.subCategories.map(({ id }) => id)),
);
return { ...info, exists: true as const };
};
export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => {
return cache.get(id, masterKey);
};

View File

@@ -1,134 +0,0 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptDirectoryMetadata, decryptFileMetadata } from "./internal.svelte";
import type { DirectoryInfo, MaybeDirectoryInfo } from "./types";
const cache = new FilesystemCache<DirectoryId, MaybeDirectoryInfo>({
async fetchFromIndexedDB(id) {
const [directory, subDirectories, files] = await Promise.all([
id !== "root" ? IndexedDB.getDirectoryInfo(id) : undefined,
IndexedDB.getDirectoryInfos(id),
IndexedDB.getFileInfos(id),
]);
if (id === "root") {
return {
id,
exists: true,
subDirectories,
files,
};
} else if (directory) {
return {
id,
exists: true,
parentId: directory.parentId,
name: directory.name,
subDirectories,
files,
isFavorite: directory.isFavorite,
};
}
},
async fetchFromServer(id, _cachedInfo, masterKey) {
try {
const directory = await trpc().directory.get.query({ id });
const [subDirectories, files, metadata] = await Promise.all([
Promise.all(
directory.subDirectories.map(async (directory) => ({
id: directory.id,
parentId: id,
isFavorite: directory.isFavorite,
...(await decryptDirectoryMetadata(directory, masterKey)),
})),
),
Promise.all(
directory.files.map(async (file) => ({
id: file.id,
parentId: id,
contentType: file.contentType,
isFavorite: file.isFavorite,
...(await decryptFileMetadata(file, masterKey)),
})),
),
directory.metadata && decryptDirectoryMetadata(directory.metadata, masterKey),
]);
return storeToIndexedDB(
id !== "root"
? {
id,
parentId: directory.metadata!.parent,
subDirectories,
files,
isFavorite: directory.metadata!.isFavorite,
...metadata!,
}
: { id, subDirectories, files },
);
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteDirectoryInfo(id as number);
return { id, exists: false as const };
}
throw e;
}
},
});
const storeToIndexedDB = (info: DirectoryInfo) => {
if (info.id !== "root") {
void IndexedDB.storeDirectoryInfo(info);
}
// TODO: Bulk Upsert
info.subDirectories.forEach((subDirectory) => {
void IndexedDB.storeDirectoryInfo(subDirectory);
});
// TODO: Bulk Upsert
info.files.forEach((file) => {
void IndexedDB.storeFileInfo(file);
});
void IndexedDB.deleteDanglingDirectoryInfos(
info.id,
new Set(info.subDirectories.map(({ id }) => id)),
);
void IndexedDB.deleteDanglingFileInfos(info.id, new Set(info.files.map(({ id }) => id)));
return { ...info, exists: true as const };
};
export const getDirectoryInfo = (
id: DirectoryId,
masterKey: CryptoKey,
options?: {
serverResponse?: {
parent: DirectoryId;
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
isFavorite: boolean;
};
},
) => {
return cache.get(id, masterKey, {
fetchFromServer:
options?.serverResponse &&
(async (cachedValue) => {
const metadata = await decryptDirectoryMetadata(options!.serverResponse!, masterKey);
return storeToIndexedDB({
subDirectories: [],
files: [],
...cachedValue,
id: id as number,
parentId: options!.serverResponse!.parent,
isFavorite: options!.serverResponse!.isFavorite,
...metadata,
});
}),
});
};

View File

@@ -1,213 +0,0 @@
import * as IndexedDB from "$lib/indexedDB";
import { trpc, isTRPCClientError } from "$trpc/client";
import { FilesystemCache, decryptFileMetadata, decryptCategoryMetadata } from "./internal.svelte";
import type { FileInfo, MaybeFileInfo } from "./types";
const cache = new FilesystemCache<number, MaybeFileInfo>({
async fetchFromIndexedDB(id) {
const file = await IndexedDB.getFileInfo(id);
const categories = file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined;
if (file) {
return {
id,
exists: true,
parentId: file.parentId,
contentType: file.contentType,
name: file.name,
createdAt: file.createdAt,
lastModifiedAt: file.lastModifiedAt,
categories: categories?.filter((category) => !!category) ?? [],
isFavorite: file.isFavorite,
};
}
},
async fetchFromServer(id, _cachedInfo, masterKey) {
try {
const file = await trpc().file.get.query({ id });
const [categories, metadata] = await Promise.all([
Promise.all(
file.categories.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(file, masterKey),
]);
return storeToIndexedDB({
id,
isLegacy: file.isLegacy,
parentId: file.parent,
dataKey: metadata.dataKey,
contentType: file.contentType,
name: metadata.name,
createdAt: metadata.createdAt,
lastModifiedAt: metadata.lastModifiedAt,
categories,
isFavorite: file.isFavorite,
});
} catch (e) {
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
await IndexedDB.deleteFileInfo(id);
return { id, exists: false as const };
}
throw e;
}
},
async bulkFetchFromIndexedDB(ids) {
const files = await IndexedDB.bulkGetFileInfos([...ids]);
const categories = await Promise.all(
files.map(async (file) =>
file?.categoryIds
? await Promise.all(
file.categoryIds.map(async (categoryId) => {
const category = await IndexedDB.getCategoryInfo(categoryId);
return category
? { id: category.id, parentId: category.parentId, name: category.name }
: undefined;
}),
)
: undefined,
),
);
return new Map(
files
.filter((file) => !!file)
.map((file, index) => [
file.id,
{
...file,
exists: true,
categories: categories[index]?.filter((category) => !!category) ?? [],
},
]),
);
},
async bulkFetchFromServer(ids, masterKey) {
const idsArray = [...ids.keys()];
const filesRaw = await trpc().file.bulkGet.query({ ids: idsArray });
const files = await Promise.all(
filesRaw.map(async ({ id, categories: categoriesRaw, ...metadataRaw }) => {
const [categories, metadata] = await Promise.all([
Promise.all(
categoriesRaw.map(async (category) => ({
id: category.id,
parentId: category.parent,
...(await decryptCategoryMetadata(category, masterKey)),
})),
),
decryptFileMetadata(metadataRaw, masterKey),
]);
return {
id,
exists: true as const,
isLegacy: metadataRaw.isLegacy,
parentId: metadataRaw.parent,
contentType: metadataRaw.contentType,
categories,
isFavorite: metadataRaw.isFavorite,
...metadata,
};
}),
);
const existingIds = new Set(filesRaw.map(({ id }) => id));
const deletedIds = idsArray.filter((id) => !existingIds.has(id));
void IndexedDB.bulkDeleteFileInfos(deletedIds);
return new Map<number, MaybeFileInfo>([
...bulkStoreToIndexedDB(files),
...deletedIds.map((id) => [id, { id, exists: false }] as const),
]);
},
});
const storeToIndexedDB = (info: FileInfo) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
info.categories.forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return { ...info, exists: true as const };
};
const bulkStoreToIndexedDB = (infos: FileInfo[]) => {
// TODO: Bulk Upsert
infos.forEach((info) => {
void IndexedDB.storeFileInfo({
...info,
categoryIds: info.categories.map(({ id }) => id),
});
});
// TODO: Bulk Upsert
new Map(
infos.flatMap(({ categories }) => categories).map((category) => [category.id, category]),
).forEach((category) => {
void IndexedDB.storeCategoryInfo(category);
});
return infos.map((info) => [info.id, { ...info, exists: true }] as const);
};
export const getFileInfo = (
id: number,
masterKey: CryptoKey,
options?: {
serverResponse?: {
parent: DirectoryId;
dek: string;
dekVersion: Date;
contentType: string;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
isFavorite: boolean;
};
},
) => {
return cache.get(id, masterKey, {
fetchFromServer:
options?.serverResponse &&
(async (cachedValue) => {
const metadata = await decryptFileMetadata(options!.serverResponse!, masterKey);
return storeToIndexedDB({
categories: [],
...cachedValue,
id,
parentId: options!.serverResponse!.parent,
contentType: options!.serverResponse!.contentType,
isFavorite: options!.serverResponse!.isFavorite,
...metadata,
});
}),
});
};
export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => {
return cache.bulkGet(new Set(ids), masterKey);
};

View File

@@ -1,180 +0,0 @@
import { untrack } from "svelte";
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
interface FilesystemCacheOptions<K, V> {
fetchFromIndexedDB: (key: K) => Promise<V | undefined>;
fetchFromServer: (key: K, cachedValue: V | undefined, masterKey: CryptoKey) => Promise<V>;
bulkFetchFromIndexedDB?: (keys: Set<K>) => Promise<Map<K, V>>;
bulkFetchFromServer?: (
keys: Map<K, { cachedValue: V | undefined }>,
masterKey: CryptoKey,
) => Promise<Map<K, V>>;
}
export class FilesystemCache<K, V extends object> {
private map = new Map<K, { value?: V; promise?: Promise<V> }>();
constructor(private readonly options: FilesystemCacheOptions<K, V>) {}
get(
key: K,
masterKey: CryptoKey,
options?: { fetchFromServer?: (cachedValue: V | undefined) => Promise<V> },
) {
return untrack(() => {
let state = this.map.get(key);
if (state?.promise) return state.value ?? state.promise;
const { promise: newPromise, resolve } = Promise.withResolvers<V>();
if (!state) {
const newState = $state({});
state = newState;
this.map.set(key, newState);
}
(state.value
? Promise.resolve($state.snapshot(state.value) as V)
: this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
if (loadedInfo) {
state.value = loadedInfo;
resolve(state.value);
}
return loadedInfo;
})
)
.then(
(cachedInfo) =>
options?.fetchFromServer?.(cachedInfo) ??
this.options.fetchFromServer(key, cachedInfo, masterKey),
)
.then((loadedInfo) => {
if (state.value) {
Object.assign(state.value, loadedInfo);
} else {
state.value = loadedInfo;
}
resolve(state.value);
})
.finally(() => {
state.promise = undefined;
});
state.promise = newPromise;
return state.value ?? newPromise;
});
}
bulkGet(keys: Set<K>, masterKey: CryptoKey) {
return untrack(() => {
const newPromises = new Map(
keys
.keys()
.filter((key) => this.map.get(key)?.promise === undefined)
.map((key) => [key, Promise.withResolvers<V>()]),
);
newPromises.forEach(({ promise }, key) => {
const state = this.map.get(key);
if (state) {
state.promise = promise;
} else {
const newState = $state({ promise });
this.map.set(key, newState);
}
});
const resolve = (loadedInfos: Map<K, V>) => {
loadedInfos.forEach((loadedInfo, key) => {
const state = this.map.get(key)!;
if (state.value) {
Object.assign(state.value, loadedInfo);
} else {
state.value = loadedInfo;
}
newPromises.get(key)!.resolve(state.value);
});
return loadedInfos;
};
this.options.bulkFetchFromIndexedDB!(
new Set(newPromises.keys().filter((key) => this.map.get(key)!.value === undefined)),
)
.then(resolve)
.then(() =>
this.options.bulkFetchFromServer!(
new Map(
newPromises.keys().map((key) => [key, { cachedValue: this.map.get(key)!.value }]),
),
masterKey,
),
)
.then(resolve)
.finally(() => {
newPromises.forEach((_, key) => {
this.map.get(key)!.promise = undefined;
});
});
const bottleneckPromises = Array.from(
keys
.keys()
.filter((key) => this.map.get(key)!.value === undefined)
.map((key) => this.map.get(key)!.promise!),
);
const makeResult = () =>
new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const));
return bottleneckPromises.length > 0
? Promise.all(bottleneckPromises).then(makeResult)
: makeResult();
});
}
}
export const decryptDirectoryMetadata = async (
metadata: { dek: string; dekVersion: Date; name: string; nameIv: string },
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
};
};
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
};
export const decryptFileMetadata = async (
metadata: {
dek: string;
dekVersion: Date;
name: string;
nameIv: string;
createdAt?: string;
createdAtIv?: string;
lastModifiedAt: string;
lastModifiedAtIv: string;
},
masterKey: CryptoKey,
) => {
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
const [name, createdAt, lastModifiedAt] = await Promise.all([
decryptString(metadata.name, metadata.nameIv, dataKey),
metadata.createdAt
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
: undefined,
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
]);
return {
dataKey: { key: dataKey, version: metadata.dekVersion },
name,
createdAt,
lastModifiedAt,
};
};
export const decryptCategoryMetadata = decryptDirectoryMetadata;

View File

@@ -1,79 +0,0 @@
export type DataKey = { key: CryptoKey; version: Date };
export interface LocalDirectoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite: boolean;
}
export interface RootDirectoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subDirectories: SubDirectoryInfo[];
files: SummarizedFileInfo[];
isFavorite?: undefined;
}
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
export type MaybeDirectoryInfo =
| (DirectoryInfo & { exists: true })
| ({ id: DirectoryId; exists: false } & AllUndefined<Omit<DirectoryInfo, "id">>);
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "subDirectories" | "files">;
export interface FileInfo {
id: number;
isLegacy?: boolean;
parentId: DirectoryId;
dataKey?: DataKey;
contentType: string;
name: string;
createdAt?: Date;
lastModifiedAt: Date;
categories: FileCategoryInfo[];
isFavorite: boolean;
}
export type MaybeFileInfo =
| (FileInfo & { exists: true })
| ({ id: number; exists: false } & AllUndefined<Omit<FileInfo, "id">>);
export type SummarizedFileInfo = Omit<FileInfo, "categories">;
export type CategoryFileInfo = SummarizedFileInfo & { isRecursive: boolean };
export interface LocalCategoryInfo {
id: number;
parentId: DirectoryId;
dataKey?: DataKey;
name: string;
subCategories: SubCategoryInfo[];
files: CategoryFileInfo[];
isFileRecursive: boolean;
}
export interface RootCategoryInfo {
id: "root";
parentId?: undefined;
dataKey?: undefined;
name?: undefined;
subCategories: SubCategoryInfo[];
files?: undefined;
isFileRecursive?: undefined;
}
export type CategoryInfo = LocalCategoryInfo | RootCategoryInfo;
export type MaybeCategoryInfo =
| (CategoryInfo & { exists: true })
| ({ id: CategoryId; exists: false } & AllUndefined<Omit<CategoryInfo, "id">>);
export type SubCategoryInfo = Omit<
LocalCategoryInfo,
"subCategories" | "files" | "isFileRecursive"
>;
export type FileCategoryInfo = Omit<SubCategoryInfo, "dataKey">;

View File

@@ -1,22 +0,0 @@
export const parseRangeHeader = (value: string | null) => {
if (!value) return undefined;
const firstRange = value.split(",")[0]!.trim();
const parts = firstRange.replace(/bytes=/, "").split("-");
return {
start: parts[0] ? parseInt(parts[0], 10) : undefined,
end: parts[1] ? parseInt(parts[1], 10) : undefined,
};
};
export const getContentRangeHeader = (range?: { start: number; end: number; total: number }) => {
return range && { "Content-Range": `bytes ${range.start}-${range.end}/${range.total}` };
};
export const parseContentDigestHeader = (value: string | null) => {
if (!value) return undefined;
const firstDigest = value.split(",")[0]!.trim();
const match = firstDigest.match(/^sha-256=:([A-Za-z0-9+/=]+):$/);
return match?.[1];
};

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
import { storeClientKey } from "$lib/indexedDB"; import { storeClientKey } from "$lib/indexedDB";
import type { ClientKeys } from "$lib/stores"; import type { ClientKeys } from "$lib/stores";
const SerializedClientKeysSchema = z.intersection( const serializedClientKeysSchema = z.intersection(
z.object({ z.object({
generator: z.literal("ArkVault"), generator: z.literal("ArkVault"),
exportedAt: z.iso.datetime(), exportedAt: z.iso.datetime(),
@@ -16,7 +16,7 @@ const SerializedClientKeysSchema = z.intersection(
}), }),
); );
type SerializedClientKeys = z.infer<typeof SerializedClientKeysSchema>; type SerializedClientKeys = z.infer<typeof serializedClientKeysSchema>;
type DeserializedClientKeys = { type DeserializedClientKeys = {
encryptKeyBase64: string; encryptKeyBase64: string;
@@ -43,7 +43,7 @@ export const serializeClientKeys = ({
}; };
export const deserializeClientKeys = (serialized: string) => { export const deserializeClientKeys = (serialized: string) => {
const zodRes = SerializedClientKeysSchema.safeParse(JSON.parse(serialized)); const zodRes = serializedClientKeysSchema.safeParse(JSON.parse(serialized));
if (zodRes.success) { if (zodRes.success) {
return { return {
encryptKeyBase64: zodRes.data.encryptKey, encryptKeyBase64: zodRes.data.encryptKey,

View File

@@ -1,5 +1,13 @@
let rootHandle: FileSystemDirectoryHandle | null = null;
export const prepareOpfs = async () => {
rootHandle = await navigator.storage.getDirectory();
};
const getFileHandle = async (path: string, create = true) => { const getFileHandle = async (path: string, create = true) => {
if (path[0] !== "/") { if (!rootHandle) {
throw new Error("OPFS not prepared");
} else if (path[0] !== "/") {
throw new Error("Path must be absolute"); throw new Error("Path must be absolute");
} }
@@ -9,7 +17,7 @@ const getFileHandle = async (path: string, create = true) => {
} }
try { try {
let directoryHandle = await navigator.storage.getDirectory(); let directoryHandle = rootHandle;
for (const part of parts.slice(0, -1)) { for (const part of parts.slice(0, -1)) {
if (!part) continue; if (!part) continue;
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create }); directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
@@ -26,15 +34,12 @@ const getFileHandle = async (path: string, create = true) => {
} }
}; };
export const getFile = async (path: string) => { export const readFile = async (path: string) => {
const { fileHandle } = await getFileHandle(path, false); const { fileHandle } = await getFileHandle(path, false);
if (!fileHandle) return null; if (!fileHandle) return null;
return await fileHandle.getFile(); const file = await fileHandle.getFile();
}; return await file.arrayBuffer();
export const readFile = async (path: string) => {
return (await getFile(path))?.arrayBuffer() ?? null;
}; };
export const writeFile = async (path: string, data: ArrayBuffer) => { export const writeFile = async (path: string, data: ArrayBuffer) => {
@@ -56,7 +61,9 @@ export const deleteFile = async (path: string) => {
}; };
const getDirectoryHandle = async (path: string) => { const getDirectoryHandle = async (path: string) => {
if (path[0] !== "/") { if (!rootHandle) {
throw new Error("OPFS not prepared");
} else if (path[0] !== "/") {
throw new Error("Path must be absolute"); throw new Error("Path must be absolute");
} }
@@ -66,7 +73,7 @@ const getDirectoryHandle = async (path: string) => {
} }
try { try {
let directoryHandle = await navigator.storage.getDirectory(); let directoryHandle = rootHandle;
let parentHandle; let parentHandle;
for (const part of parts.slice(1)) { for (const part of parts.slice(1)) {
if (!part) continue; if (!part) continue;

View File

@@ -52,6 +52,7 @@ const generateImageThumbnail = (imageUrl: string) => {
.catch(reject); .catch(reject);
}; };
image.onerror = reject; image.onerror = reject;
image.src = imageUrl; image.src = imageUrl;
}); });
}; };
@@ -84,27 +85,31 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
}); });
}; };
export const generateThumbnail = async (blob: Blob) => { export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
let url; let url;
try { try {
if (blob.type.startsWith("image/")) { if (fileType.startsWith("image/")) {
url = URL.createObjectURL(blob); const fileBlob = new Blob([fileBuffer], { type: fileType });
url = URL.createObjectURL(fileBlob);
try { try {
return await generateImageThumbnail(url); return await generateImageThumbnail(url);
} catch { } catch {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
url = undefined; url = undefined;
if (blob.type === "image/heic") { if (fileType === "image/heic") {
const { default: heic2any } = await import("heic2any"); const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL((await heic2any({ blob, toType: "image/png" })) as Blob); url = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
);
return await generateImageThumbnail(url); return await generateImageThumbnail(url);
} else { } else {
return null; return null;
} }
} }
} else if (blob.type.startsWith("video/")) { } else if (fileType.startsWith("video/")) {
url = URL.createObjectURL(blob); url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
return await generateVideoThumbnail(url); return await generateVideoThumbnail(url);
} }
return null; return null;

View File

@@ -1,183 +0,0 @@
import axios from "axios";
import pLimit from "p-limit";
import { ENCRYPTION_OVERHEAD, CHUNK_SIZE } from "$lib/constants";
import { encryptChunk, digestMessage, encodeToBase64 } from "$lib/modules/crypto";
import { BoundedQueue } from "$lib/utils";
interface UploadStats {
progress: number;
rate: number;
}
interface EncryptedChunk {
index: number;
data: ArrayBuffer;
hash: string;
}
const createSpeedMeter = (timeWindow = 3000, minInterval = 200, warmupPeriod = 500) => {
const samples: { t: number; b: number }[] = [];
let lastSpeed = 0;
let startTime: number | null = null;
return (bytesNow?: number) => {
if (bytesNow === undefined) return lastSpeed;
const now = performance.now();
// Initialize start time on first call
if (startTime === null) {
startTime = now;
}
// Check if enough time has passed since the last sample
const lastSample = samples[samples.length - 1];
if (lastSample && now - lastSample.t < minInterval) {
return lastSpeed;
}
samples.push({ t: now, b: bytesNow });
// Remove old samples outside the time window
const cutoff = now - timeWindow;
while (samples.length > 2 && samples[0]!.t < cutoff) samples.shift();
// Need at least 2 samples to calculate speed
if (samples.length < 2) {
return lastSpeed;
}
const first = samples[0]!;
const dt = now - first.t;
const db = bytesNow - first.b;
if (dt >= minInterval) {
const instantSpeed = (db / dt) * 1000;
// Apply EMA for smoother speed transitions
const alpha = 0.3;
const rawSpeed =
lastSpeed === 0 ? instantSpeed : alpha * instantSpeed + (1 - alpha) * lastSpeed;
// Apply warmup ramp to prevent initial overestimation
const elapsed = now - startTime;
const warmupWeight = Math.min(1, elapsed / warmupPeriod);
lastSpeed = rawSpeed * warmupWeight;
}
return lastSpeed;
};
};
const encryptChunkData = async (
chunk: Blob,
dataKey: CryptoKey,
): Promise<{ data: ArrayBuffer; hash: string }> => {
const encrypted = await encryptChunk(await chunk.arrayBuffer(), dataKey);
const hash = encodeToBase64(await digestMessage(encrypted));
return { data: encrypted, hash };
};
const uploadEncryptedChunk = async (
uploadId: string,
chunkIndex: number,
encrypted: ArrayBuffer,
hash: string,
onChunkProgress: (chunkIndex: number, loaded: number) => void,
) => {
await axios.post(`/api/upload/${uploadId}/chunks/${chunkIndex + 1}`, encrypted, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Digest": `sha-256=:${hash}:`,
},
onUploadProgress(e) {
onChunkProgress(chunkIndex, e.loaded ?? 0);
},
});
onChunkProgress(chunkIndex, encrypted.byteLength);
};
export const uploadBlob = async (
uploadId: string,
blob: Blob,
dataKey: CryptoKey,
options?: { concurrency?: number; onProgress?: (s: UploadStats) => void },
) => {
const onProgress = options?.onProgress;
const networkConcurrency = options?.concurrency ?? 4;
const maxQueueSize = 8;
const totalChunks = Math.ceil(blob.size / CHUNK_SIZE);
const totalBytes = blob.size + totalChunks * ENCRYPTION_OVERHEAD;
const uploadedByChunk = new Array<number>(totalChunks).fill(0);
const speedMeter = createSpeedMeter(3000, 200);
const emit = () => {
if (!onProgress) return;
const uploadedBytes = uploadedByChunk.reduce((a, b) => a + b, 0);
const rate = speedMeter(uploadedBytes);
const progress = Math.min(1, uploadedBytes / totalBytes);
onProgress({ progress, rate });
};
const onChunkProgress = (idx: number, loaded: number) => {
uploadedByChunk[idx] = loaded;
emit();
};
const queue = new BoundedQueue<EncryptedChunk>(maxQueueSize);
let encryptionError: Error | null = null;
// Producer: encrypt chunks and push to queue
const encryptionProducer = async () => {
try {
for (let i = 0; i < totalChunks; i++) {
const chunk = blob.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
const { data, hash } = await encryptChunkData(chunk, dataKey);
await queue.push({ index: i, data, hash });
}
} catch (e) {
encryptionError = e instanceof Error ? e : new Error(String(e));
} finally {
queue.close();
}
};
// Consumer: upload chunks from queue with concurrency limit
const uploadConsumer = async () => {
const limit = pLimit(networkConcurrency);
const activeTasks = new Set<Promise<void>>();
while (true) {
const item = await queue.pop();
if (item === null) break;
if (encryptionError) throw encryptionError;
const task = limit(async () => {
try {
await uploadEncryptedChunk(uploadId, item.index, item.data, item.hash, onChunkProgress);
} finally {
// @ts-ignore
item.data = null;
}
});
activeTasks.add(task);
task.finally(() => activeTasks.delete(task));
if (activeTasks.size >= networkConcurrency) {
await Promise.race(activeTasks);
}
}
await Promise.all(activeTasks);
};
// Run producer and consumer concurrently
await Promise.all([encryptionProducer(), uploadConsumer()]);
onProgress?.({ progress: 1, rate: speedMeter() });
};

View File

@@ -1,4 +0,0 @@
import { z } from "zod";
export const DirectoryIdSchema = z.union([z.literal("root"), z.int().positive()]);
export const CategoryIdSchema = z.union([z.literal("root"), z.int().positive()]);

View File

@@ -1 +0,0 @@
export * from "./filesystem";

View File

@@ -2,6 +2,8 @@ import { IntegrityError } from "./error";
import db from "./kysely"; import db from "./kysely";
import type { Ciphertext } from "./schema"; import type { Ciphertext } from "./schema";
export type CategoryId = "root" | number;
interface Category { interface Category {
id: number; id: number;
parentId: CategoryId; parentId: CategoryId;

View File

@@ -1,278 +0,0 @@
import type { Selectable } from "kysely";
import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext, DirectoryTable } from "./schema";
interface Directory {
id: number;
parentId: DirectoryId;
userId: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
isFavorite: boolean;
}
const toDirectory = (row: Selectable<DirectoryTable>): Directory => ({
id: row.id,
parentId: row.parent_id ?? "root",
userId: row.user_id,
mekVersion: row.master_encryption_key_version,
encDek: row.encrypted_data_encryption_key,
dekVersion: row.data_encryption_key_version,
encName: row.encrypted_name,
isFavorite: row.is_favorite,
});
export const registerDirectory = async (params: Omit<Directory, "id" | "isFavorite">) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
const { directoryId } = await trx
.insertInto("directory")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
encrypted_name: params.encName,
})
.returning("id as directoryId")
.executeTakeFirstOrThrow();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
});
};
export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
const directories = await db
.selectFrom("directory")
.selectAll()
.where("user_id", "=", userId)
.$if(parentId === "root", (qb) => qb.where("parent_id", "is", null))
.$if(parentId !== "root", (qb) => qb.where("parent_id", "=", parentId as number))
.execute();
return directories.map(toDirectory);
};
export const getAllFavoriteDirectories = async (userId: number) => {
const directories = await db
.selectFrom("directory")
.selectAll()
.where("user_id", "=", userId)
.where("is_favorite", "=", true)
.execute();
return directories.map(toDirectory);
};
export const getDirectory = async (userId: number, directoryId: number) => {
const directory = await db
.selectFrom("directory")
.selectAll()
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.executeTakeFirst();
return directory ? toDirectory(directory) : null;
};
export const searchDirectories = async (
userId: number,
filters: {
parentId: DirectoryId;
inFavorites: boolean;
},
) => {
const directories = await db
.withRecursive("directory_tree", (db) =>
db
.selectFrom("directory")
.select("id")
.where("user_id", "=", userId)
.$if(filters.parentId === "root", (qb) => qb.where((eb) => eb.lit(false))) // directory_tree will be empty if parentId is "root"
.$if(filters.parentId !== "root", (qb) => qb.where("id", "=", filters.parentId as number))
.unionAll(
db
.selectFrom("directory as d")
.innerJoin("directory_tree as dt", "d.parent_id", "dt.id")
.select("d.id"),
),
)
.withRecursive("favorite_directory_tree", (db) =>
db
.selectFrom("directory")
.select("id")
.where("user_id", "=", userId)
.$if(!filters.inFavorites, (qb) => qb.where((eb) => eb.lit(false))) // favorite_directory_tree will be empty if inFavorites is false
.$if(filters.inFavorites, (qb) => qb.where("is_favorite", "=", true))
.unionAll((db) =>
db
.selectFrom("directory as d")
.innerJoin("favorite_directory_tree as dt", "d.parent_id", "dt.id")
.select("d.id"),
),
)
.selectFrom("directory")
.selectAll()
.where("user_id", "=", userId)
.$if(filters.parentId !== "root", (qb) =>
qb.where((eb) =>
eb.exists(eb.selectFrom("directory_tree as dt").whereRef("dt.id", "=", "parent_id")),
),
)
.$if(filters.inFavorites, (qb) =>
qb.where((eb) =>
eb.exists(
eb.selectFrom("favorite_directory_tree as dt").whereRef("dt.id", "=", "directory.id"),
),
),
)
.execute();
return directories.map(toDirectory);
};
export const setDirectoryEncName = async (
userId: number,
directoryId: number,
dekVersion: Date,
encName: Ciphertext,
) => {
await db.transaction().execute(async (trx) => {
const directory = await trx
.selectFrom("directory")
.select("data_encryption_key_version")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!directory) {
throw new IntegrityError("Directory not found");
} else if (directory.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await trx
.updateTable("directory")
.set({ encrypted_name: encName })
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: "rename",
new_name: encName,
})
.execute();
});
};
export const setDirectoryFavorite = async (
userId: number,
directoryId: number,
isFavorite: boolean,
) => {
await db.transaction().execute(async (trx) => {
const directory = await trx
.selectFrom("directory")
.select("is_favorite")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!directory) {
throw new IntegrityError("Directory not found");
} else if (directory.is_favorite === isFavorite) {
throw new IntegrityError(
isFavorite ? "Directory already favorited" : "Directory not favorited",
);
}
await trx
.updateTable("directory")
.set({ is_favorite: isFavorite })
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
})
.execute();
});
};
export const unregisterDirectory = async (userId: number, directoryId: number) => {
return await db
.transaction()
.setIsolationLevel("repeatable read") // TODO: Sufficient?
.execute(async (trx) => {
const unregisterFiles = async (parentId: number) => {
const files = await trx
.selectFrom("file")
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
.where("file.parent_id", "=", parentId)
.where("file.user_id", "=", userId)
.forUpdate("file")
.execute();
await trx
.deleteFrom("file")
.where("parent_id", "=", parentId)
.where("user_id", "=", userId)
.execute();
return files;
};
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await trx
.selectFrom("directory")
.select("id")
.where("parent_id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
const deleteRes = await trx
.deleteFrom("directory")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (deleteRes.numDeletedRows === 0n) {
throw new IntegrityError("Directory not found");
}
return files.concat(...subDirectoryFilePaths);
};
return await unregisterDirectoryRecursively(directoryId);
});
};

View File

@@ -8,14 +8,9 @@ type IntegrityErrorMessages =
| "User client already exists" | "User client already exists"
// File // File
| "Directory not found" | "Directory not found"
| "Directory already favorited"
| "Directory not favorited"
| "File not found" | "File not found"
| "File is not legacy"
| "File not found in category" | "File not found in category"
| "File already added to category" | "File already added to category"
| "File already favorited"
| "File not favorited"
| "Invalid DEK version" | "Invalid DEK version"
// HSK // HSK
| "HSK already registered" | "HSK already registered"

View File

@@ -1,9 +1,22 @@
import { sql, type Selectable } from "kysely"; import { sql, type NotNull } from "kysely";
import { jsonArrayFrom } from "kysely/helpers/postgres";
import pg from "pg"; import pg from "pg";
import { IntegrityError } from "./error"; import { IntegrityError } from "./error";
import db from "./kysely"; import db from "./kysely";
import type { Ciphertext, FileTable } from "./schema"; import type { Ciphertext } from "./schema";
export type DirectoryId = "root" | number;
interface Directory {
id: number;
parentId: DirectoryId;
userId: number;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
export type NewDirectory = Omit<Directory, "id">;
interface File { interface File {
id: number; id: number;
@@ -16,47 +29,215 @@ interface File {
hskVersion: number | null; hskVersion: number | null;
contentHmac: string | null; contentHmac: string | null;
contentType: string; contentType: string;
encContentIv: string | null; encContentIv: string;
encContentHash: string; encContentHash: string;
encName: Ciphertext; encName: Ciphertext;
encCreatedAt: Ciphertext | null; encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext; encLastModifiedAt: Ciphertext;
isFavorite: boolean;
} }
interface FileCategory { export type NewFile = Omit<File, "id">;
id: number;
parentId: CategoryId;
mekVersion: number;
encDek: string;
dekVersion: Date;
encName: Ciphertext;
}
const toFile = (row: Selectable<FileTable>): File => ({ export const registerDirectory = async (params: NewDirectory) => {
id: row.id, await db.transaction().execute(async (trx) => {
parentId: row.parent_id ?? "root", const mek = await trx
userId: row.user_id, .selectFrom("master_encryption_key")
path: row.path, .select("version")
mekVersion: row.master_encryption_key_version, .where("user_id", "=", params.userId)
encDek: row.encrypted_data_encryption_key, .where("state", "=", "active")
dekVersion: row.data_encryption_key_version, .limit(1)
hskVersion: row.hmac_secret_key_version, .forUpdate()
contentHmac: row.content_hmac, .executeTakeFirst();
contentType: row.content_type, if (mek?.version !== params.mekVersion) {
encContentIv: row.encrypted_content_iv, throw new IntegrityError("Inactive MEK version");
encContentHash: row.encrypted_content_hash, }
encName: row.encrypted_name,
encCreatedAt: row.encrypted_created_at,
encLastModifiedAt: row.encrypted_last_modified_at,
isFavorite: row.is_favorite,
});
export const registerFile = async (trx: typeof db, params: Omit<File, "id" | "isFavorite">) => { const { directoryId } = await trx
.insertInto("directory")
.values({
parent_id: params.parentId !== "root" ? params.parentId : null,
user_id: params.userId,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
encrypted_name: params.encName,
})
.returning("id as directoryId")
.executeTakeFirstOrThrow();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: "create",
new_name: params.encName,
})
.execute();
});
};
export const getAllDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
let query = db.selectFrom("directory").selectAll().where("user_id", "=", userId);
query =
parentId === "root"
? query.where("parent_id", "is", null)
: query.where("parent_id", "=", parentId);
const directories = await query.execute();
return directories.map(
(directory) =>
({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
}) satisfies Directory,
);
};
export const getDirectory = async (userId: number, directoryId: number) => {
const directory = await db
.selectFrom("directory")
.selectAll()
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.executeTakeFirst();
return directory
? ({
id: directory.id,
parentId: directory.parent_id ?? "root",
userId: directory.user_id,
mekVersion: directory.master_encryption_key_version,
encDek: directory.encrypted_data_encryption_key,
dekVersion: directory.data_encryption_key_version,
encName: directory.encrypted_name,
} satisfies Directory)
: null;
};
export const setDirectoryEncName = async (
userId: number,
directoryId: number,
dekVersion: Date,
encName: Ciphertext,
) => {
await db.transaction().execute(async (trx) => {
const directory = await trx
.selectFrom("directory")
.select("data_encryption_key_version")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!directory) {
throw new IntegrityError("Directory not found");
} else if (directory.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await trx
.updateTable("directory")
.set({ encrypted_name: encName })
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("directory_log")
.values({
directory_id: directoryId,
timestamp: new Date(),
action: "rename",
new_name: encName,
})
.execute();
});
};
export const unregisterDirectory = async (userId: number, directoryId: number) => {
return await db
.transaction()
.setIsolationLevel("repeatable read") // TODO: Sufficient?
.execute(async (trx) => {
const unregisterFiles = async (parentId: number) => {
const files = await trx
.selectFrom("file")
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
.where("file.parent_id", "=", parentId)
.where("file.user_id", "=", userId)
.forUpdate("file")
.execute();
await trx
.deleteFrom("file")
.where("parent_id", "=", parentId)
.where("user_id", "=", userId)
.execute();
return files;
};
const unregisterDirectoryRecursively = async (
directoryId: number,
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
const files = await unregisterFiles(directoryId);
const subDirectories = await trx
.selectFrom("directory")
.select("id")
.where("parent_id", "=", directoryId)
.where("user_id", "=", userId)
.execute();
const subDirectoryFilePaths = await Promise.all(
subDirectories.map(async ({ id }) => await unregisterDirectoryRecursively(id)),
);
const deleteRes = await trx
.deleteFrom("directory")
.where("id", "=", directoryId)
.where("user_id", "=", userId)
.executeTakeFirst();
if (deleteRes.numDeletedRows === 0n) {
throw new IntegrityError("Directory not found");
}
return files.concat(...subDirectoryFilePaths);
};
return await unregisterDirectoryRecursively(directoryId);
});
};
export const registerFile = async (params: NewFile) => {
if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) { if ((params.hskVersion && !params.contentHmac) || (!params.hskVersion && params.contentHmac)) {
throw new Error("Invalid arguments"); throw new Error("Invalid arguments");
} }
return await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
if (params.hskVersion) {
const hsk = await trx
.selectFrom("hmac_secret_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (hsk?.version !== params.hskVersion) {
throw new IntegrityError("Inactive HSK version");
}
}
const { fileId } = await trx const { fileId } = await trx
.insertInto("file") .insertInto("file")
.values({ .values({
@@ -87,17 +268,36 @@ export const registerFile = async (trx: typeof db, params: Omit<File, "id" | "is
}) })
.execute(); .execute();
return { id: fileId }; return { id: fileId };
});
}; };
export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => { export const getAllFilesByParent = async (userId: number, parentId: DirectoryId) => {
const files = await db let query = db.selectFrom("file").selectAll().where("user_id", "=", userId);
.selectFrom("file") query =
.selectAll() parentId === "root"
.where("user_id", "=", userId) ? query.where("parent_id", "is", null)
.$if(parentId === "root", (qb) => qb.where("parent_id", "is", null)) : query.where("parent_id", "=", parentId);
.$if(parentId !== "root", (qb) => qb.where("parent_id", "=", parentId as number)) const files = await query.execute();
.execute(); return files.map(
return files.map(toFile); (file) =>
({
id: file.id,
parentId: file.parent_id ?? "root",
userId: file.user_id,
path: file.path,
mekVersion: file.master_encryption_key_version,
encDek: file.encrypted_data_encryption_key,
dekVersion: file.data_encryption_key_version,
hskVersion: file.hmac_secret_key_version,
contentHmac: file.content_hmac,
contentType: file.content_type,
encContentIv: file.encrypted_content_iv,
encContentHash: file.encrypted_content_hash,
encName: file.encrypted_name,
encCreatedAt: file.encrypted_created_at,
encLastModifiedAt: file.encrypted_last_modified_at,
}) satisfies File,
);
}; };
export const getAllFilesByCategory = async ( export const getAllFilesByCategory = async (
@@ -106,34 +306,39 @@ export const getAllFilesByCategory = async (
recurse: boolean, recurse: boolean,
) => { ) => {
const files = await db const files = await db
.withRecursive("category_tree", (db) => .withRecursive("cte", (db) =>
db db
.selectFrom("category") .selectFrom("category")
.select(["id", sql<number>`0`.as("depth")]) .leftJoin("file_category", "category.id", "file_category.category_id")
.select(["id", "parent_id", "user_id", "file_category.file_id"])
.select(sql<number>`0`.as("depth"))
.where("id", "=", categoryId) .where("id", "=", categoryId)
.where("user_id", "=", userId)
.$if(recurse, (qb) => .$if(recurse, (qb) =>
qb.unionAll((db) => qb.unionAll((db) =>
db db
.selectFrom("category") .selectFrom("category")
.innerJoin("category_tree", "category.parent_id", "category_tree.id") .leftJoin("file_category", "category.id", "file_category.category_id")
.select(["category.id", sql<number>`depth + 1`.as("depth")]), .innerJoin("cte", "category.parent_id", "cte.id")
.select([
"category.id",
"category.parent_id",
"category.user_id",
"file_category.file_id",
])
.select(sql<number>`cte.depth + 1`.as("depth")),
), ),
), ),
) )
.selectFrom("category_tree") .selectFrom("cte")
.innerJoin("file_category", "category_tree.id", "file_category.category_id")
.innerJoin("file", "file_category.file_id", "file.id")
.select(["file_id", "depth"]) .select(["file_id", "depth"])
.selectAll("file")
.distinctOn("file_id") .distinctOn("file_id")
.where("user_id", "=", userId)
.where("file_id", "is not", null)
.$narrowType<{ file_id: NotNull }>()
.orderBy("file_id") .orderBy("file_id")
.orderBy("depth") .orderBy("depth")
.execute(); .execute();
return files.map((file) => ({ return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
...toFile(file),
isRecursive: file.depth > 0,
}));
}; };
export const getAllFileIds = async (userId: number) => { export const getAllFileIds = async (userId: number) => {
@@ -141,41 +346,6 @@ export const getAllFileIds = async (userId: number) => {
return files.map(({ id }) => id); return files.map(({ id }) => id);
}; };
export const getLegacyFiles = async (userId: number, limit: number = 100) => {
const files = await db
.selectFrom("file")
.selectAll()
.where("user_id", "=", userId)
.where("encrypted_content_iv", "is not", null)
.limit(limit)
.execute();
return files.map(toFile);
};
export const getFilesWithoutThumbnail = async (userId: number, limit: number = 100) => {
const files = await db
.selectFrom("file")
.selectAll()
.where("user_id", "=", userId)
.where((eb) =>
eb.or([eb("content_type", "like", "image/%"), eb("content_type", "like", "video/%")]),
)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("thumbnail")
.select("thumbnail.id")
.whereRef("thumbnail.file_id", "=", "file.id")
.limit(1),
),
),
)
.limit(limit)
.execute();
return files.map(toFile);
};
export const getAllFileIdsByContentHmac = async ( export const getAllFileIdsByContentHmac = async (
userId: number, userId: number,
hskVersion: number, hskVersion: number,
@@ -199,163 +369,25 @@ export const getFile = async (userId: number, fileId: number) => {
.where("user_id", "=", userId) .where("user_id", "=", userId)
.limit(1) .limit(1)
.executeTakeFirst(); .executeTakeFirst();
return file ? toFile(file) : null; return file
}; ? ({
id: file.id,
export const getFilesWithCategories = async (userId: number, fileIds: number[]) => { parentId: file.parent_id ?? "root",
const files = await db userId: file.user_id,
.selectFrom("file") path: file.path,
.selectAll() mekVersion: file.master_encryption_key_version,
.select((eb) => encDek: file.encrypted_data_encryption_key,
jsonArrayFrom( dekVersion: file.data_encryption_key_version,
eb hskVersion: file.hmac_secret_key_version,
.selectFrom("file_category") contentHmac: file.content_hmac,
.innerJoin("category", "file_category.category_id", "category.id") contentType: file.content_type,
.where("file_category.file_id", "=", eb.ref("file.id")) encContentIv: file.encrypted_content_iv,
.selectAll("category"), encContentHash: file.encrypted_content_hash,
).as("categories"), encName: file.encrypted_name,
) encCreatedAt: file.encrypted_created_at,
.where("id", "=", (eb) => eb.fn.any(eb.val(fileIds))) encLastModifiedAt: file.encrypted_last_modified_at,
.where("user_id", "=", userId) } satisfies File)
.execute(); : null;
return files.map((file) => ({
...toFile(file),
categories: file.categories.map(
(category) =>
({
id: category.id,
parentId: category.parent_id ?? "root",
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: new Date(category.data_encryption_key_version),
encName: category.encrypted_name,
}) satisfies FileCategory,
),
}));
};
export const getAllFavoriteFiles = async (userId: number) => {
const files = await db
.selectFrom("file")
.selectAll()
.where("user_id", "=", userId)
.where("is_favorite", "=", true)
.execute();
return files.map(toFile);
};
export const searchFiles = async (
userId: number,
filters: {
parentId: DirectoryId;
inFavorites: boolean;
includeCategoryIds: number[];
excludeCategoryIds: number[];
},
) => {
const baseQuery = db
.withRecursive("directory_tree", (db) =>
db
.selectFrom("directory")
.select("id")
.where("user_id", "=", userId)
.$if(filters.parentId === "root", (qb) => qb.where((eb) => eb.lit(false))) // directory_tree will be empty if parentId is "root"
.$if(filters.parentId !== "root", (qb) => qb.where("id", "=", filters.parentId as number))
.unionAll(
db
.selectFrom("directory as d")
.innerJoin("directory_tree as dt", "d.parent_id", "dt.id")
.select("d.id"),
),
)
.withRecursive("favorite_directory_tree", (db) =>
db
.selectFrom("directory")
.select("id")
.where("user_id", "=", userId)
.$if(!filters.inFavorites, (qb) => qb.where((eb) => eb.lit(false))) // favorite_directory_tree will be empty if inFavorites is false
.$if(filters.inFavorites, (qb) => qb.where("is_favorite", "=", true))
.unionAll((db) =>
db
.selectFrom("directory as d")
.innerJoin("favorite_directory_tree as dt", "d.parent_id", "dt.id")
.select("d.id"),
),
)
.withRecursive("include_category_tree", (db) =>
db
.selectFrom("category")
.select(["id", "id as root_id"])
.where("id", "=", (eb) => eb.fn.any(eb.val(filters.includeCategoryIds)))
.where("user_id", "=", userId)
.unionAll(
db
.selectFrom("category as c")
.innerJoin("include_category_tree as ct", "c.parent_id", "ct.id")
.select(["c.id", "ct.root_id"]),
),
)
.withRecursive("exclude_category_tree", (db) =>
db
.selectFrom("category")
.select("id")
.where("id", "=", (eb) => eb.fn.any(eb.val(filters.excludeCategoryIds)))
.where("user_id", "=", userId)
.unionAll((db) =>
db
.selectFrom("category as c")
.innerJoin("exclude_category_tree as ct", "c.parent_id", "ct.id")
.select("c.id"),
),
)
.selectFrom("file")
.selectAll("file")
.where("user_id", "=", userId)
.$if(filters.parentId !== "root", (qb) =>
qb.where((eb) =>
eb.exists(eb.selectFrom("directory_tree as dt").whereRef("dt.id", "=", "file.parent_id")),
),
)
.$if(filters.inFavorites, (qb) =>
qb.where((eb) =>
eb.or([
eb("is_favorite", "=", true),
eb.exists(
eb.selectFrom("favorite_directory_tree as dt").whereRef("dt.id", "=", "file.parent_id"),
),
]),
),
)
.$if(filters.excludeCategoryIds.length > 0, (qb) =>
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("file_category")
.innerJoin("exclude_category_tree", "category_id", "exclude_category_tree.id")
.whereRef("file_id", "=", "file.id"),
),
),
),
);
const files =
filters.includeCategoryIds.length > 0
? await baseQuery
.innerJoin("file_category", "file.id", "file_category.file_id")
.innerJoin(
"include_category_tree",
"file_category.category_id",
"include_category_tree.id",
)
.groupBy("file.id")
.having(
(eb) => eb.fn.count("include_category_tree.root_id").distinct(),
"=",
filters.includeCategoryIds.length,
)
.execute()
: await baseQuery.execute();
return files.map(toFile);
}; };
export const setFileEncName = async ( export const setFileEncName = async (
@@ -416,51 +448,6 @@ export const unregisterFile = async (userId: number, fileId: number) => {
}); });
}; };
export const migrateFileContent = async (
trx: typeof db,
userId: number,
fileId: number,
newPath: string,
dekVersion: Date,
encContentHash: string,
) => {
const file = await trx
.selectFrom("file")
.select(["path", "data_encryption_key_version", "encrypted_content_iv"])
.where("id", "=", fileId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
} else if (!file.encrypted_content_iv) {
throw new IntegrityError("File is not legacy");
}
await trx
.updateTable("file")
.set({
path: newPath,
encrypted_content_iv: null,
encrypted_content_hash: encContentHash,
})
.where("id", "=", fileId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: "migrate",
})
.execute();
return { oldPath: file.path };
};
export const addFileToCategory = async (fileId: number, categoryId: number) => { export const addFileToCategory = async (fileId: number, categoryId: number) => {
await db.transaction().execute(async (trx) => { await db.transaction().execute(async (trx) => {
try { try {
@@ -489,21 +476,10 @@ export const addFileToCategory = async (fileId: number, categoryId: number) => {
export const getAllFileCategories = async (fileId: number) => { export const getAllFileCategories = async (fileId: number) => {
const categories = await db const categories = await db
.selectFrom("file_category") .selectFrom("file_category")
.innerJoin("category", "file_category.category_id", "category.id") .select("category_id")
.selectAll("category")
.where("file_id", "=", fileId) .where("file_id", "=", fileId)
.execute(); .execute();
return categories.map( return categories.map(({ category_id }) => ({ id: category_id }));
(category) =>
({
id: category.id,
parentId: category.parent_id ?? "root",
mekVersion: category.master_encryption_key_version,
encDek: category.encrypted_data_encryption_key,
dekVersion: category.data_encryption_key_version,
encName: category.encrypted_name,
}) satisfies FileCategory,
);
}; };
export const removeFileFromCategory = async (fileId: number, categoryId: number) => { export const removeFileFromCategory = async (fileId: number, categoryId: number) => {
@@ -528,36 +504,3 @@ export const removeFileFromCategory = async (fileId: number, categoryId: number)
.execute(); .execute();
}); });
}; };
export const setFileFavorite = async (userId: number, fileId: number, isFavorite: boolean) => {
await db.transaction().execute(async (trx) => {
const file = await trx
.selectFrom("file")
.select("is_favorite")
.where("id", "=", fileId)
.where("user_id", "=", userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.is_favorite === isFavorite) {
throw new IntegrityError(isFavorite ? "File already favorited" : "File not favorited");
}
await trx
.updateTable("file")
.set({ is_favorite: isFavorite })
.where("id", "=", fileId)
.where("user_id", "=", userId)
.execute();
await trx
.insertInto("file_log")
.values({
file_id: fileId,
timestamp: new Date(),
action: isFavorite ? "add-to-favorites" : "remove-from-favorites",
})
.execute();
});
};

View File

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

View File

@@ -6,7 +6,7 @@ interface Thumbnail {
id: number; id: number;
path: string; path: string;
updatedAt: Date; updatedAt: Date;
encContentIv: string | null; encContentIv: string;
} }
interface FileThumbnail extends Thumbnail { interface FileThumbnail extends Thumbnail {
@@ -14,13 +14,13 @@ interface FileThumbnail extends Thumbnail {
} }
export const updateFileThumbnail = async ( export const updateFileThumbnail = async (
trx: typeof db,
userId: number, userId: number,
fileId: number, fileId: number,
dekVersion: Date, dekVersion: Date,
path: string, path: string,
encContentIv: string | null, encContentIv: string,
) => { ) => {
return await db.transaction().execute(async (trx) => {
const file = await trx const file = await trx
.selectFrom("file") .selectFrom("file")
.select("data_encryption_key_version") .select("data_encryption_key_version")
@@ -61,6 +61,7 @@ export const updateFileThumbnail = async (
) )
.execute(); .execute();
return thumbnail?.oldPath ?? null; return thumbnail?.oldPath ?? null;
});
}; };
export const getFileThumbnail = async (userId: number, fileId: number) => { export const getFileThumbnail = async (userId: number, fileId: number) => {
@@ -83,3 +84,27 @@ export const getFileThumbnail = async (userId: number, fileId: number) => {
} satisfies FileThumbnail) } satisfies FileThumbnail)
: null; : null;
}; };
export const getMissingFileThumbnails = async (userId: number, limit: number = 100) => {
const files = await db
.selectFrom("file")
.select("id")
.where("user_id", "=", userId)
.where((eb) =>
eb.or([eb("content_type", "like", "image/%"), eb("content_type", "like", "video/%")]),
)
.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("thumbnail")
.select("thumbnail.id")
.whereRef("thumbnail.file_id", "=", "file.id")
.limit(1),
),
),
)
.limit(limit)
.execute();
return files.map(({ id }) => id);
};

View File

@@ -71,7 +71,7 @@ export const getAllValidClientMeks = async (userId: number, clientId: number) =>
.selectAll() .selectAll()
.where("client_master_encryption_key.user_id", "=", userId) .where("client_master_encryption_key.user_id", "=", userId)
.where("client_master_encryption_key.client_id", "=", clientId) .where("client_master_encryption_key.client_id", "=", clientId)
.where("state", "in", ["active", "retired"]) .where((eb) => eb.or([eb("state", "=", "active"), eb("state", "=", "retired")]))
.execute(); .execute();
return clientMeks.map( return clientMeks.map(
({ user_id, client_id, version, state, encrypted_key, encrypted_key_signature }) => ({ user_id, client_id, version, state, encrypted_key, encrypted_key_signature }) =>

View File

@@ -135,7 +135,7 @@ export const up = async (db: Kysely<any>) => {
) )
.execute(); .execute();
// directory.ts // file.ts
await db.schema await db.schema
.createTable("directory") .createTable("directory")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()) .addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
@@ -162,8 +162,6 @@ export const up = async (db: Kysely<any>) => {
.addColumn("action", "text", (col) => col.notNull()) .addColumn("action", "text", (col) => col.notNull())
.addColumn("new_name", "json") .addColumn("new_name", "json")
.execute(); .execute();
// file.ts
await db.schema await db.schema
.createTable("file") .createTable("file")
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity()) .addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())

View File

@@ -53,7 +53,9 @@ export const up = async (db: Kysely<any>) => {
export const down = async (db: Kysely<any>) => { export const down = async (db: Kysely<any>) => {
await db await db
.deleteFrom("file_log") .deleteFrom("file_log")
.where("action", "in", ["add-to-category", "remove-from-category"]) .where((eb) =>
eb.or([eb("action", "=", "add-to-category"), eb("action", "=", "remove-from-category")]),
)
.execute(); .execute();
await db.schema.dropTable("file_category").execute(); await db.schema.dropTable("file_category").execute();

View File

@@ -1,76 +0,0 @@
import { Kysely, sql } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// file.ts
await db.schema
.alterTable("file")
.alterColumn("encrypted_content_iv", (col) => col.dropNotNull())
.execute();
// media.ts
await db.schema
.alterTable("thumbnail")
.alterColumn("encrypted_content_iv", (col) => col.dropNotNull())
.execute();
// upload.ts
await db.schema
.createTable("upload_session")
.addColumn("id", "uuid", (col) => col.primaryKey())
.addColumn("type", "text", (col) => col.notNull())
.addColumn("user_id", "integer", (col) => col.references("user.id").notNull())
.addColumn("path", "text", (col) => col.notNull())
.addColumn("bitmap", "bytea", (col) => col.notNull())
.addColumn("total_chunks", "integer", (col) => col.notNull())
.addColumn("uploaded_chunks", "integer", (col) =>
col
.generatedAlwaysAs(sql`bit_count(bitmap)`)
.stored()
.notNull(),
)
.addColumn("expires_at", "timestamp(3)", (col) => col.notNull())
.addColumn("parent_id", "integer", (col) => col.references("directory.id"))
.addColumn("master_encryption_key_version", "integer")
.addColumn("encrypted_data_encryption_key", "text")
.addColumn("data_encryption_key_version", "timestamp(3)")
.addColumn("hmac_secret_key_version", "integer")
.addColumn("content_type", "text")
.addColumn("encrypted_name", "json")
.addColumn("encrypted_created_at", "json")
.addColumn("encrypted_last_modified_at", "json")
.addColumn("file_id", "integer", (col) => col.references("file.id"))
.addForeignKeyConstraint(
"upload_session_fk01",
["user_id", "master_encryption_key_version"],
"master_encryption_key",
["user_id", "version"],
)
.addForeignKeyConstraint(
"upload_session_fk02",
["user_id", "hmac_secret_key_version"],
"hmac_secret_key",
["user_id", "version"],
)
.addCheckConstraint(
"upload_session_ck01",
sql`length(bitmap) = ceil(total_chunks / 8.0)::integer`,
)
.addCheckConstraint("upload_session_ck02", sql`uploaded_chunks <= total_chunks`)
.execute();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const down = async (db: Kysely<any>) => {
await db.deleteFrom("file_log").where("action", "=", "migrate").execute();
await db.schema.dropTable("upload_session").execute();
await db.schema
.alterTable("thumbnail")
.alterColumn("encrypted_content_iv", (col) => col.setNotNull())
.execute();
await db.schema
.alterTable("file")
.alterColumn("encrypted_content_iv", (col) => col.setNotNull())
.execute();
};

View File

@@ -1,31 +0,0 @@
import { Kysely } from "kysely";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const up = async (db: Kysely<any>) => {
// directory.ts
await db.schema
.alterTable("directory")
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
.execute();
// file.ts
await db.schema
.alterTable("file")
.addColumn("is_favorite", "boolean", (col) => col.notNull().defaultTo(false))
.execute();
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const down = async (db: Kysely<any>) => {
await db
.deleteFrom("file_log")
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
.execute();
await db
.deleteFrom("directory_log")
.where("action", "in", ["add-to-favorites", "remove-from-favorites"])
.execute();
await db.schema.alterTable("file").dropColumn("is_favorite").execute();
await db.schema.alterTable("directory").dropColumn("is_favorite").execute();
};

View File

@@ -1,13 +1,9 @@
import * as Initial1737357000 from "./1737357000-Initial"; import * as Initial1737357000 from "./1737357000-Initial";
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory"; import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail"; import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
import * as AddChunkedUpload1768062380 from "./1768062380-AddChunkedUpload";
import * as AddFavorites1768643000 from "./1768643000-AddFavorites";
export default { export default {
"1737357000-Initial": Initial1737357000, "1737357000-Initial": Initial1737357000,
"1737422340-AddFileCategory": AddFileCategory1737422340, "1737422340-AddFileCategory": AddFileCategory1737422340,
"1738409340-AddThumbnail": AddThumbnail1738409340, "1738409340-AddThumbnail": AddThumbnail1738409340,
"1768062380-AddChunkedUpload": AddChunkedUpload1768062380,
"1768643000-AddFavorites": AddFavorites1768643000,
}; };

View File

@@ -1,7 +1,7 @@
import type { Generated } from "kysely"; import type { Generated } from "kysely";
import type { Ciphertext } from "./utils"; import type { Ciphertext } from "./util";
export interface CategoryTable { interface CategoryTable {
id: Generated<number>; id: Generated<number>;
parent_id: number | null; parent_id: number | null;
user_id: number; user_id: number;
@@ -11,7 +11,7 @@ export interface CategoryTable {
encrypted_name: Ciphertext; encrypted_name: Ciphertext;
} }
export interface CategoryLogTable { interface CategoryLogTable {
id: Generated<number>; id: Generated<number>;
category_id: number; category_id: number;
timestamp: Date; timestamp: Date;

View File

@@ -1,6 +1,6 @@
import type { ColumnType, Generated } from "kysely"; import type { ColumnType, Generated } from "kysely";
export interface ClientTable { interface ClientTable {
id: Generated<number>; id: Generated<number>;
encryption_public_key: string; // Base64 encryption_public_key: string; // Base64
signature_public_key: string; // Base64 signature_public_key: string; // Base64
@@ -8,13 +8,13 @@ export interface ClientTable {
export type UserClientState = "challenging" | "pending" | "active"; export type UserClientState = "challenging" | "pending" | "active";
export interface UserClientTable { interface UserClientTable {
user_id: number; user_id: number;
client_id: number; client_id: number;
state: ColumnType<UserClientState, UserClientState | undefined>; state: ColumnType<UserClientState, UserClientState | undefined>;
} }
export interface UserClientChallengeTable { interface UserClientChallengeTable {
id: Generated<number>; id: Generated<number>;
user_id: number; user_id: number;
client_id: number; client_id: number;

View File

@@ -1,28 +0,0 @@
import type { ColumnType, Generated } from "kysely";
import type { Ciphertext } from "./utils";
export interface DirectoryTable {
id: Generated<number>;
parent_id: number | null;
user_id: number;
master_encryption_key_version: number;
encrypted_data_encryption_key: string; // Base64
data_encryption_key_version: Date;
encrypted_name: Ciphertext;
is_favorite: Generated<boolean>;
}
export interface DirectoryLogTable {
id: Generated<number>;
directory_id: number;
timestamp: ColumnType<Date, Date, never>;
action: "create" | "rename" | "add-to-favorites" | "remove-from-favorites";
new_name: Ciphertext | null;
}
declare module "./index" {
interface Database {
directory: DirectoryTable;
directory_log: DirectoryLogTable;
}
}

View File

@@ -1,7 +1,25 @@
import type { ColumnType, Generated } from "kysely"; import type { ColumnType, Generated } from "kysely";
import type { Ciphertext } from "./utils"; import type { Ciphertext } from "./util";
export interface FileTable { interface DirectoryTable {
id: Generated<number>;
parent_id: number | null;
user_id: number;
master_encryption_key_version: number;
encrypted_data_encryption_key: string; // Base64
data_encryption_key_version: Date;
encrypted_name: Ciphertext;
}
interface DirectoryLogTable {
id: Generated<number>;
directory_id: number;
timestamp: ColumnType<Date, Date, never>;
action: "create" | "rename";
new_name: Ciphertext | null;
}
interface FileTable {
id: Generated<number>; id: Generated<number>;
parent_id: number | null; parent_id: number | null;
user_id: number; user_id: number;
@@ -12,37 +30,31 @@ export interface FileTable {
hmac_secret_key_version: number | null; hmac_secret_key_version: number | null;
content_hmac: string | null; // Base64 content_hmac: string | null; // Base64
content_type: string; content_type: string;
encrypted_content_iv: string | null; // Base64 encrypted_content_iv: string; // Base64
encrypted_content_hash: string; // Base64 encrypted_content_hash: string; // Base64
encrypted_name: Ciphertext; encrypted_name: Ciphertext;
encrypted_created_at: Ciphertext | null; encrypted_created_at: Ciphertext | null;
encrypted_last_modified_at: Ciphertext; encrypted_last_modified_at: Ciphertext;
is_favorite: Generated<boolean>;
} }
export interface FileLogTable { interface FileLogTable {
id: Generated<number>; id: Generated<number>;
file_id: number; file_id: number;
timestamp: ColumnType<Date, Date, never>; timestamp: ColumnType<Date, Date, never>;
action: action: "create" | "rename" | "add-to-category" | "remove-from-category";
| "create"
| "rename"
| "migrate"
| "add-to-category"
| "remove-from-category"
| "add-to-favorites"
| "remove-from-favorites";
new_name: Ciphertext | null; new_name: Ciphertext | null;
category_id: number | null; category_id: number | null;
} }
export interface FileCategoryTable { interface FileCategoryTable {
file_id: number; file_id: number;
category_id: number; category_id: number;
} }
declare module "./index" { declare module "./index" {
interface Database { interface Database {
directory: DirectoryTable;
directory_log: DirectoryLogTable;
file: FileTable; file: FileTable;
file_log: FileLogTable; file_log: FileLogTable;
file_category: FileCategoryTable; file_category: FileCategoryTable;

View File

@@ -2,7 +2,7 @@ import type { ColumnType, Generated } from "kysely";
export type HskState = "active"; export type HskState = "active";
export interface HskTable { interface HskTable {
user_id: number; user_id: number;
version: number; version: number;
state: HskState; state: HskState;
@@ -10,7 +10,7 @@ export interface HskTable {
encrypted_key: string; // Base64 encrypted_key: string; // Base64
} }
export interface HskLogTable { interface HskLogTable {
id: Generated<number>; id: Generated<number>;
user_id: number; user_id: number;
hmac_secret_key_version: number; hmac_secret_key_version: number;

View File

@@ -1,14 +1,12 @@
export * from "./category"; export * from "./category";
export * from "./client"; export * from "./client";
export * from "./directory";
export * from "./file"; export * from "./file";
export * from "./hsk"; export * from "./hsk";
export * from "./media"; export * from "./media";
export * from "./mek"; export * from "./mek";
export * from "./session"; export * from "./session";
export * from "./upload";
export * from "./user"; export * from "./user";
export * from "./utils"; export * from "./util";
// eslint-disable-next-line @typescript-eslint/no-empty-object-type // eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface Database {} export interface Database {}

View File

@@ -1,13 +1,13 @@
import type { Generated } from "kysely"; import type { Generated } from "kysely";
export interface ThumbnailTable { interface ThumbnailTable {
id: Generated<number>; id: Generated<number>;
directory_id: number | null; directory_id: number | null;
file_id: number | null; file_id: number | null;
category_id: number | null; category_id: number | null;
path: string; path: string;
updated_at: Date; updated_at: Date;
encrypted_content_iv: string | null; // Base64 encrypted_content_iv: string; // Base64
} }
declare module "./index" { declare module "./index" {

View File

@@ -2,13 +2,13 @@ import type { ColumnType, Generated } from "kysely";
export type MekState = "active" | "retired" | "dead"; export type MekState = "active" | "retired" | "dead";
export interface MekTable { interface MekTable {
user_id: number; user_id: number;
version: number; version: number;
state: MekState; state: MekState;
} }
export interface MekLogTable { interface MekLogTable {
id: Generated<number>; id: Generated<number>;
user_id: number; user_id: number;
master_encryption_key_version: number; master_encryption_key_version: number;
@@ -17,7 +17,7 @@ export interface MekLogTable {
action_by: number | null; action_by: number | null;
} }
export interface ClientMekTable { interface ClientMekTable {
user_id: number; user_id: number;
client_id: number; client_id: number;
version: number; version: number;

View File

@@ -1,6 +1,6 @@
import type { ColumnType, Generated } from "kysely"; import type { ColumnType, Generated } from "kysely";
export interface SessionTable { interface SessionTable {
id: string; id: string;
user_id: number; user_id: number;
client_id: number | null; client_id: number | null;
@@ -10,7 +10,7 @@ export interface SessionTable {
last_used_by_agent: string | null; last_used_by_agent: string | null;
} }
export interface SessionUpgradeChallengeTable { interface SessionUpgradeChallengeTable {
id: Generated<number>; id: Generated<number>;
session_id: string; session_id: string;
client_id: number; client_id: number;

View File

@@ -1,30 +0,0 @@
import type { Generated } from "kysely";
import type { Ciphertext } from "./utils";
export interface UploadSessionTable {
id: string;
type: "file" | "thumbnail" | "migration";
user_id: number;
path: string;
bitmap: Buffer;
total_chunks: number;
uploaded_chunks: Generated<number>;
expires_at: Date;
parent_id: number | null;
master_encryption_key_version: number | null;
encrypted_data_encryption_key: string | null; // Base64
data_encryption_key_version: Date | null;
hmac_secret_key_version: number | null;
content_type: string | null;
encrypted_name: Ciphertext | null;
encrypted_created_at: Ciphertext | null;
encrypted_last_modified_at: Ciphertext | null;
file_id: number | null;
}
declare module "./index" {
interface Database {
upload_session: UploadSessionTable;
}
}

View File

@@ -1,6 +1,6 @@
import type { Generated } from "kysely"; import type { Generated } from "kysely";
export interface UserTable { interface UserTable {
id: Generated<number>; id: Generated<number>;
email: string; email: string;
nickname: string; nickname: string;

View File

@@ -1,192 +0,0 @@
import { sql } from "kysely";
import { IntegrityError } from "./error";
import db from "./kysely";
import type { Ciphertext } from "./schema";
interface BaseUploadSession {
id: string;
userId: number;
path: string;
bitmap: Buffer;
totalChunks: number;
uploadedChunks: number;
expiresAt: Date;
}
interface FileUploadSession extends BaseUploadSession {
type: "file";
parentId: DirectoryId;
mekVersion: number;
encDek: string;
dekVersion: Date;
hskVersion: number | null;
contentType: string;
encName: Ciphertext;
encCreatedAt: Ciphertext | null;
encLastModifiedAt: Ciphertext;
}
interface ThumbnailOrMigrationUploadSession extends BaseUploadSession {
type: "thumbnail" | "migration";
fileId: number;
dekVersion: Date;
}
export const createFileUploadSession = async (
params: Omit<FileUploadSession, "type" | "bitmap" | "uploadedChunks">,
) => {
await db.transaction().execute(async (trx) => {
const mek = await trx
.selectFrom("master_encryption_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (mek?.version !== params.mekVersion) {
throw new IntegrityError("Inactive MEK version");
}
if (params.hskVersion) {
const hsk = await trx
.selectFrom("hmac_secret_key")
.select("version")
.where("user_id", "=", params.userId)
.where("state", "=", "active")
.limit(1)
.forUpdate()
.executeTakeFirst();
if (hsk?.version !== params.hskVersion) {
throw new IntegrityError("Inactive HSK version");
}
}
await trx
.insertInto("upload_session")
.values({
id: params.id,
type: "file",
user_id: params.userId,
path: params.path,
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
total_chunks: params.totalChunks,
expires_at: params.expiresAt,
parent_id: params.parentId !== "root" ? params.parentId : null,
master_encryption_key_version: params.mekVersion,
encrypted_data_encryption_key: params.encDek,
data_encryption_key_version: params.dekVersion,
hmac_secret_key_version: params.hskVersion,
content_type: params.contentType,
encrypted_name: params.encName,
encrypted_created_at: params.encCreatedAt,
encrypted_last_modified_at: params.encLastModifiedAt,
})
.execute();
});
};
export const createThumbnailOrMigrationUploadSession = async (
params: Omit<ThumbnailOrMigrationUploadSession, "bitmap" | "uploadedChunks">,
) => {
await db.transaction().execute(async (trx) => {
const file = await trx
.selectFrom("file")
.select("data_encryption_key_version")
.where("id", "=", params.fileId)
.where("user_id", "=", params.userId)
.limit(1)
.forUpdate()
.executeTakeFirst();
if (!file) {
throw new IntegrityError("File not found");
} else if (file.data_encryption_key_version.getTime() !== params.dekVersion.getTime()) {
throw new IntegrityError("Invalid DEK version");
}
await trx
.insertInto("upload_session")
.values({
id: params.id,
type: params.type,
user_id: params.userId,
path: params.path,
bitmap: Buffer.alloc(Math.ceil(params.totalChunks / 8)),
total_chunks: params.totalChunks,
expires_at: params.expiresAt,
file_id: params.fileId,
data_encryption_key_version: params.dekVersion,
})
.execute();
});
};
export const getUploadSession = async (sessionId: string, userId: number) => {
const session = await db
.selectFrom("upload_session")
.selectAll()
.where("id", "=", sessionId)
.where("user_id", "=", userId)
.where("expires_at", ">", new Date())
.limit(1)
.executeTakeFirst();
if (!session) {
return null;
} else if (session.type === "file") {
return {
type: "file",
id: session.id,
userId: session.user_id,
path: session.path,
bitmap: session.bitmap,
totalChunks: session.total_chunks,
uploadedChunks: session.uploaded_chunks,
expiresAt: session.expires_at,
parentId: session.parent_id ?? "root",
mekVersion: session.master_encryption_key_version!,
encDek: session.encrypted_data_encryption_key!,
dekVersion: session.data_encryption_key_version!,
hskVersion: session.hmac_secret_key_version,
contentType: session.content_type!,
encName: session.encrypted_name!,
encCreatedAt: session.encrypted_created_at,
encLastModifiedAt: session.encrypted_last_modified_at!,
} satisfies FileUploadSession;
} else {
return {
type: session.type,
id: session.id,
userId: session.user_id,
path: session.path,
bitmap: session.bitmap,
totalChunks: session.total_chunks,
uploadedChunks: session.uploaded_chunks,
expiresAt: session.expires_at,
fileId: session.file_id!,
dekVersion: session.data_encryption_key_version!,
} satisfies ThumbnailOrMigrationUploadSession;
}
};
export const markChunkAsUploaded = async (sessionId: string, chunkIndex: number) => {
await db
.updateTable("upload_session")
.set({
bitmap: sql`set_bit(${sql.ref("bitmap")}, ${chunkIndex - 1}, 1)`,
})
.where("id", "=", sessionId)
.execute();
};
export const deleteUploadSession = async (trx: typeof db, sessionId: string) => {
await trx.deleteFrom("upload_session").where("id", "=", sessionId).execute();
};
export const cleanupExpiredUploadSessions = async () => {
const sessions = await db
.deleteFrom("upload_session")
.where("expires_at", "<=", new Date())
.returning("path")
.execute();
return sessions.map(({ path }) => path);
};

View File

@@ -26,5 +26,4 @@ export default {
}, },
libraryPath: env.LIBRARY_PATH || "library", libraryPath: env.LIBRARY_PATH || "library",
thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails", thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails",
uploadsPath: env.UPLOADS_PATH || "uploads",
}; };

View File

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

View File

@@ -0,0 +1,3 @@
import { z } from "zod";
export const categoryIdSchema = z.union([z.literal("root"), z.int().positive()]);

View File

@@ -0,0 +1,3 @@
import { z } from "zod";
export const directoryIdSchema = z.union([z.literal("root"), z.int().positive()]);

View File

@@ -0,0 +1,36 @@
import mime from "mime";
import { z } from "zod";
import { directoryIdSchema } from "./directory";
export const fileThumbnailUploadRequest = z.object({
dekVersion: z.iso.datetime(),
contentIv: z.base64().nonempty(),
});
export type FileThumbnailUploadRequest = z.input<typeof fileThumbnailUploadRequest>;
export const fileUploadRequest = z.object({
parent: directoryIdSchema,
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.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.int().positive(),
});
export type FileUploadResponse = z.output<typeof fileUploadResponse>;

View File

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

View File

@@ -1,74 +1,126 @@
import { error } from "@sveltejs/kit"; import { error } from "@sveltejs/kit";
import { createReadStream } from "fs"; import { createHash } from "crypto";
import { stat } from "fs/promises"; import { createReadStream, createWriteStream } from "fs";
import { mkdir, stat } from "fs/promises";
import { dirname } from "path";
import { Readable } from "stream"; import { Readable } from "stream";
import { FileRepo, MediaRepo } from "$lib/server/db"; import { pipeline } from "stream/promises";
import { v4 as uuidv4 } from "uuid";
import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
import env from "$lib/server/loadenv";
import { safeUnlink } from "$lib/server/modules/filesystem";
const createEncContentStream = async ( export const getFileStream = async (userId: number, fileId: number) => {
path: string,
iv?: Buffer,
range?: { start?: number; end?: number },
) => {
const { size: fileSize } = await stat(path);
const ivSize = iv?.byteLength ?? 0;
const totalSize = fileSize + ivSize;
const start = range?.start ?? 0;
const end = range?.end ?? totalSize - 1;
if (start > end || start < 0 || end >= totalSize) {
error(416, "Invalid range");
}
return {
encContentStream: Readable.toWeb(
Readable.from(
(async function* () {
if (start < ivSize) {
yield iv!.subarray(start, Math.min(end + 1, ivSize));
}
if (end >= ivSize) {
yield* createReadStream(path, {
start: Math.max(0, start - ivSize),
end: end - ivSize,
});
}
})(),
),
),
range: { start, end, total: totalSize },
};
};
export const getFileStream = async (
userId: number,
fileId: number,
range?: { start?: number; end?: number },
) => {
const file = await FileRepo.getFile(userId, fileId); const file = await FileRepo.getFile(userId, fileId);
if (!file) { if (!file) {
error(404, "Invalid file id"); error(404, "Invalid file id");
} }
return createEncContentStream( const { size } = await stat(file.path);
file.path, return {
file.encContentIv ? Buffer.from(file.encContentIv, "base64") : undefined, encContentStream: Readable.toWeb(createReadStream(file.path)),
range, encContentSize: size,
); };
}; };
export const getFileThumbnailStream = async ( export const getFileThumbnailStream = async (userId: number, fileId: number) => {
userId: number,
fileId: number,
range?: { start?: number; end?: number },
) => {
const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId); const thumbnail = await MediaRepo.getFileThumbnail(userId, fileId);
if (!thumbnail) { if (!thumbnail) {
error(404, "File or its thumbnail not found"); error(404, "File or its thumbnail not found");
} }
return createEncContentStream( const { size } = await stat(thumbnail.path);
thumbnail.path, return {
thumbnail.encContentIv ? Buffer.from(thumbnail.encContentIv, "base64") : undefined, encContentStream: Readable.toWeb(createReadStream(thumbnail.path)),
range, encContentSize: size,
); };
};
export const uploadFileThumbnail = async (
userId: number,
fileId: number,
dekVersion: Date,
encContentIv: string,
encContentStream: Readable,
) => {
const path = `${env.thumbnailsPath}/${userId}/${uuidv4()}`;
await mkdir(dirname(path), { recursive: true });
try {
await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 }));
const oldPath = await MediaRepo.updateFileThumbnail(
userId,
fileId,
dekVersion,
path,
encContentIv,
);
safeUnlink(oldPath); // Intended
} catch (e) {
await safeUnlink(path);
if (e instanceof IntegrityError) {
if (e.message === "File not found") {
error(404, "File not found");
} else if (e.message === "Invalid DEK version") {
error(400, "Mismatched DEK version");
}
}
throw e;
}
};
export const uploadFile = async (
params: Omit<FileRepo.NewFile, "path" | "encContentHash">,
encContentStream: Readable,
encContentHash: Promise<string>,
) => {
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
if (params.dekVersion <= oneDayAgo || params.dekVersion >= oneMinuteLater) {
error(400, "Invalid DEK version");
}
const path = `${env.libraryPath}/${params.userId}/${uuidv4()}`;
await mkdir(dirname(path), { recursive: true });
try {
const hashStream = createHash("sha256");
const [, hash] = await Promise.all([
pipeline(
encContentStream,
async function* (source) {
for await (const chunk of source) {
hashStream.update(chunk);
yield chunk;
}
},
createWriteStream(path, { flags: "wx", mode: 0o600 }),
),
encContentHash,
]);
if (hashStream.digest("base64") !== hash) {
throw new Error("Invalid checksum");
}
const { id: fileId } = await FileRepo.registerFile({
...params,
path,
encContentHash: hash,
});
return { fileId };
} catch (e) {
await safeUnlink(path);
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
error(400, "Invalid MEK version");
} else if (
e instanceof Error &&
(e.message === "Invalid request body" || e.message === "Invalid checksum")
) {
error(400, "Invalid request body");
}
throw e;
}
}; };

View File

@@ -1,88 +0,0 @@
import { error } from "@sveltejs/kit";
import { createHash } from "crypto";
import { createWriteStream } from "fs";
import { Readable } from "stream";
import { ENCRYPTION_OVERHEAD, ENCRYPTED_CHUNK_SIZE } from "$lib/constants";
import { UploadRepo } from "$lib/server/db";
import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
const chunkLocks = new Set<string>();
const isChunkUploaded = (bitmap: Buffer, chunkIndex: number) => {
chunkIndex -= 1;
const byte = bitmap[Math.floor(chunkIndex / 8)];
return !!byte && (byte & (1 << (chunkIndex % 8))) !== 0; // Postgres sucks
};
export const uploadChunk = async (
userId: number,
sessionId: string,
chunkIndex: number,
encChunkStream: Readable,
encChunkHash: string,
) => {
const lockKey = `${sessionId}/${chunkIndex}`;
if (chunkLocks.has(lockKey)) {
error(409, "Chunk upload already in progress");
} else {
chunkLocks.add(lockKey);
}
let filePath;
try {
const session = await UploadRepo.getUploadSession(sessionId, userId);
if (!session) {
error(404, "Invalid upload id");
} else if (chunkIndex > session.totalChunks) {
error(400, "Invalid chunk index");
} else if (isChunkUploaded(session.bitmap, chunkIndex)) {
error(409, "Chunk already uploaded");
}
const isLastChunk = chunkIndex === session.totalChunks;
filePath = `${session.path}/${chunkIndex}`;
const hashStream = createHash("sha256");
const writeStream = createWriteStream(filePath, { flags: "wx", mode: 0o600 });
let writtenBytes = 0;
for await (const chunk of encChunkStream) {
hashStream.update(chunk);
writeStream.write(chunk);
writtenBytes += chunk.length;
}
await new Promise<void>((resolve, reject) => {
writeStream.end((e: any) => (e ? reject(e) : resolve()));
});
if (hashStream.digest("base64") !== encChunkHash) {
throw new Error("Invalid checksum");
} else if (
(!isLastChunk && writtenBytes !== ENCRYPTED_CHUNK_SIZE) ||
(isLastChunk && (writtenBytes <= ENCRYPTION_OVERHEAD || writtenBytes > ENCRYPTED_CHUNK_SIZE))
) {
throw new Error("Invalid chunk size");
}
await UploadRepo.markChunkAsUploaded(sessionId, chunkIndex);
} catch (e) {
await safeUnlink(filePath);
if (
e instanceof Error &&
(e.message === "Invalid checksum" || e.message === "Invalid chunk size")
) {
error(400, "Invalid request body");
}
throw e;
} finally {
chunkLocks.delete(lockKey);
}
};
export const cleanupExpiredUploadSessions = async () => {
const paths = await UploadRepo.cleanupExpiredUploadSessions();
await Promise.all(paths.map(safeRecursiveRm));
};

View File

@@ -1,39 +0,0 @@
import { DECRYPTED_FILE_URL_PREFIX } from "$lib/constants";
import type { FileMetadata, ServiceWorkerMessage, ServiceWorkerResponse } from "./types";
const PREPARE_TIMEOUT_MS = 5000;
const getServiceWorker = async () => {
const registration = await navigator.serviceWorker.ready;
const sw = registration.active;
if (!sw) {
throw new Error("Service worker not activated");
}
return sw;
};
export const prepareFileDecryption = async (id: number, metadata: FileMetadata) => {
const sw = await getServiceWorker();
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(
() => reject(new Error("Service worker timeout")),
PREPARE_TIMEOUT_MS,
);
const handler = (event: MessageEvent<ServiceWorkerResponse>) => {
if (event.data.type === "decryption-ready" && event.data.fileId === id) {
clearTimeout(timeout);
navigator.serviceWorker.removeEventListener("message", handler);
resolve();
}
};
navigator.serviceWorker.addEventListener("message", handler);
sw.postMessage({
type: "decryption-prepare",
fileId: id,
...metadata,
} satisfies ServiceWorkerMessage);
});
};
export const getDecryptedFileUrl = (id: number) => `${DECRYPTED_FILE_URL_PREFIX}${id}`;

View File

@@ -1,2 +0,0 @@
export * from "./client";
export * from "./types";

View File

@@ -1,19 +0,0 @@
export interface FileMetadata {
isLegacy: boolean;
dataKey: CryptoKey;
encContentSize: number;
contentType: string;
}
export interface DecryptionPrepareMessage extends FileMetadata {
type: "decryption-prepare";
fileId: number;
}
export interface DecryptionReadyMessage {
type: "decryption-ready";
fileId: number;
}
export type ServiceWorkerMessage = DecryptionPrepareMessage;
export type ServiceWorkerResponse = DecryptionReadyMessage;

View File

@@ -1,5 +1,6 @@
import { TRPCClientError } from "@trpc/client";
import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto"; import { encodeToBase64, decryptChallenge, signMessageRSA } from "$lib/modules/crypto";
import { trpc, isTRPCClientError } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestSessionUpgrade = async ( export const requestSessionUpgrade = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -15,7 +16,7 @@ export const requestSessionUpgrade = async (
sigPubKey: verifyKeyBase64, sigPubKey: verifyKeyBase64,
})); }));
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "FORBIDDEN") { if (e instanceof TRPCClientError && e.data?.code === "FORBIDDEN") {
return [false, "Unregistered client"] as const; return [false, "Unregistered client"] as const;
} }
return [false] as const; return [false] as const;
@@ -30,7 +31,7 @@ export const requestSessionUpgrade = async (
force, force,
}); });
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && e.data?.code === "CONFLICT") { if (e instanceof TRPCClientError && e.data?.code === "CONFLICT") {
return [false, "Already logged in"] as const; return [false, "Already logged in"] as const;
} }
return [false] as const; return [false] as const;

View File

@@ -1,47 +1,70 @@
import { getAllFileInfos } from "$lib/indexedDB/filesystem"; import { getAllFileInfos } from "$lib/indexedDB/filesystem";
import { decryptData } from "$lib/modules/crypto";
import { import {
getFileCache, getFileCache,
storeFileCache, storeFileCache,
deleteFileCache, deleteFileCache,
downloadFile, getFileThumbnailCache,
storeFileThumbnailCache,
deleteFileThumbnailCache, deleteFileThumbnailCache,
downloadFile,
} from "$lib/modules/file"; } from "$lib/modules/file";
import { uploadBlob } from "$lib/modules/upload"; import { getThumbnailUrl } from "$lib/modules/thumbnail";
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
import { trpc } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestFileDownload = async ( export const requestFileDownload = async (
fileId: number, fileId: number,
fileEncryptedIv: string,
dataKey: CryptoKey, dataKey: CryptoKey,
isLegacy: boolean,
) => { ) => {
const cache = await getFileCache(fileId); const cache = await getFileCache(fileId);
if (cache) return cache; if (cache) return cache;
const fileBuffer = await downloadFile(fileId, dataKey, isLegacy); const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
storeFileCache(fileId, fileBuffer); // Intended storeFileCache(fileId, fileBuffer); // Intended
return fileBuffer; return fileBuffer;
}; };
export const requestFileThumbnailUpload = async ( export const requestFileThumbnailUpload = async (
fileId: number, fileId: number,
thumbnail: Blob,
dataKey: CryptoKey,
dataKeyVersion: Date, dataKeyVersion: Date,
thumbnailEncrypted: { ciphertext: ArrayBuffer; iv: string },
) => { ) => {
const form = new FormData();
form.set(
"metadata",
JSON.stringify({
dekVersion: dataKeyVersion.toISOString(),
contentIv: thumbnailEncrypted.iv,
} satisfies FileThumbnailUploadRequest),
);
form.set("content", new Blob([thumbnailEncrypted.ciphertext]));
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
};
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId);
if (cache || !dataKey) return cache;
let thumbnailInfo;
try { try {
const { uploadId } = await trpc().upload.startFileThumbnailUpload.mutate({ thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId });
file: fileId,
dekVersion: dataKeyVersion,
});
await uploadBlob(uploadId, thumbnail, dataKey);
await trpc().upload.completeFileThumbnailUpload.mutate({ uploadId });
return true;
} catch { } catch {
// TODO: Error Handling // TODO: Error Handling
return false; return null;
} }
const { contentIv: thumbnailEncryptedIv } = thumbnailInfo;
const res = await fetch(`/api/file/${fileId}/thumbnail/download`);
if (!res.ok) return null;
const thumbnailEncrypted = await res.arrayBuffer();
const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended
return getThumbnailUrl(thumbnailBuffer);
}; };
export const requestDeletedFilesCleanup = async () => { export const requestDeletedFilesCleanup = async () => {

View File

@@ -1,3 +1,4 @@
import { TRPCClientError } from "@trpc/client";
import { storeMasterKeys } from "$lib/indexedDB"; import { storeMasterKeys } from "$lib/indexedDB";
import { import {
encodeToBase64, encodeToBase64,
@@ -10,7 +11,7 @@ import {
} from "$lib/modules/crypto"; } from "$lib/modules/crypto";
import { requestSessionUpgrade } from "$lib/services/auth"; import { requestSessionUpgrade } from "$lib/services/auth";
import { masterKeyStore, type ClientKeys } from "$lib/stores"; import { masterKeyStore, type ClientKeys } from "$lib/stores";
import { trpc, isTRPCClientError } from "$trpc/client"; import { trpc } from "$trpc/client";
export const requestClientRegistration = async ( export const requestClientRegistration = async (
encryptKeyBase64: string, encryptKeyBase64: string,
@@ -111,7 +112,10 @@ export const requestInitialMasterKeyAndHmacSecretRegistration = async (
mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey), mekSig: await signMasterKeyWrapped(masterKeyWrapped, 1, signKey),
}); });
} catch (e) { } catch (e) {
if (isTRPCClientError(e) && (e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")) { if (
e instanceof TRPCClientError &&
(e.data?.code === "FORBIDDEN" || e.data?.code === "CONFLICT")
) {
return true; return true;
} }
// TODO: Error Handling // TODO: Error Handling

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