mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 08:06:56 +00:00
Compare commits
2 Commits
cf0f8fe0b9
...
f10a0a2da3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f10a0a2da3 | ||
|
|
0eb1d29259 |
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { getFileThumbnail } from "$lib/modules/file";
|
||||
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||
import { requestFileThumbnailDownload } from "$lib/services/file";
|
||||
|
||||
interface Props {
|
||||
info: SummarizedFileInfo;
|
||||
@@ -10,25 +9,16 @@
|
||||
|
||||
let { info, onclick }: Props = $props();
|
||||
|
||||
let showThumbnail = $derived(
|
||||
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
|
||||
);
|
||||
let thumbnailPromise = $derived(
|
||||
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
|
||||
);
|
||||
let thumbnail = $derived(getFileThumbnail(info));
|
||||
</script>
|
||||
|
||||
<button
|
||||
onclick={onclick && (() => setTimeout(() => onclick(info), 100))}
|
||||
class="aspect-square overflow-hidden rounded transition active:scale-95 active:brightness-90"
|
||||
>
|
||||
{#await thumbnailPromise}
|
||||
{#if $thumbnail}
|
||||
<img src={$thumbnail} alt={info.name} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div class="h-full w-full bg-gray-100"></div>
|
||||
{:then thumbnail}
|
||||
{#if thumbnail}
|
||||
<img src={thumbnail} alt={info.name} class="h-full w-full object-cover" />
|
||||
{:else}
|
||||
<div class="h-full w-full bg-gray-100"></div>
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { ActionEntryButton } from "$lib/components/atoms";
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import { getFileThumbnail } from "$lib/modules/file";
|
||||
import type { CategoryFileInfo } from "$lib/modules/filesystem";
|
||||
import { requestFileThumbnailDownload } from "$lib/services/file";
|
||||
import type { SelectedFile } from "./service";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
@@ -16,12 +15,7 @@
|
||||
|
||||
let { info, onclick, onRemoveClick }: Props = $props();
|
||||
|
||||
let showThumbnail = $derived(
|
||||
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
|
||||
);
|
||||
let thumbnailPromise = $derived(
|
||||
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
|
||||
);
|
||||
let thumbnail = $derived(getFileThumbnail(info));
|
||||
</script>
|
||||
|
||||
<ActionEntryButton
|
||||
@@ -30,9 +24,5 @@
|
||||
actionButtonIcon={onRemoveClick && IconClose}
|
||||
onActionButtonClick={() => onRemoveClick?.(info)}
|
||||
>
|
||||
{#await thumbnailPromise}
|
||||
<DirectoryEntryLabel type="file" name={info.name} />
|
||||
{:then thumbnail}
|
||||
<DirectoryEntryLabel type="file" thumbnail={thumbnail ?? undefined} name={info.name} />
|
||||
{/await}
|
||||
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} />
|
||||
</ActionEntryButton>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import {
|
||||
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
|
||||
storeFileCacheIndex,
|
||||
deleteFileCacheIndex,
|
||||
type FileCacheIndex,
|
||||
} from "$lib/indexedDB";
|
||||
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
|
||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
|
||||
|
||||
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
||||
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
|
||||
|
||||
export const prepareFileCache = async () => {
|
||||
for (const cache of await getFileCacheIndexFromIndexedDB()) {
|
||||
@@ -51,30 +48,3 @@ export const deleteFileCache = async (fileId: number) => {
|
||||
await deleteFile(`/cache/${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");
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./cache";
|
||||
export * from "./download.svelte";
|
||||
export * from "./thumbnail";
|
||||
export * from "./upload.svelte";
|
||||
|
||||
90
src/lib/modules/file/thumbnail.ts
Normal file
90
src/lib/modules/file/thumbnail.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { browser } from "$app/environment";
|
||||
import { decryptData } 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";
|
||||
import { isTRPCClientError, trpc } from "$trpc/client";
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const [thumbnailEncrypted, { contentIv: thumbnailEncryptedIv }] = await Promise.all([
|
||||
fetch(`/api/file/${fileId}/thumbnail/download`),
|
||||
trpc().file.thumbnail.query({ id: fileId }),
|
||||
]);
|
||||
const thumbnailBuffer = await decryptData(
|
||||
await thumbnailEncrypted.arrayBuffer(),
|
||||
thumbnailEncryptedIv,
|
||||
dataKey,
|
||||
);
|
||||
|
||||
void writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
||||
return getThumbnailUrl(thumbnailBuffer);
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
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`);
|
||||
};
|
||||
@@ -196,77 +196,74 @@ export const uploadFile = async (
|
||||
});
|
||||
const state = uploadingFiles.at(-1)!;
|
||||
|
||||
return await scheduler.schedule(
|
||||
async () => file.size,
|
||||
async () => {
|
||||
state.status = "encryption-pending";
|
||||
return await scheduler.schedule(file.size, async () => {
|
||||
state.status = "encryption-pending";
|
||||
|
||||
try {
|
||||
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
|
||||
file,
|
||||
hmacSecret,
|
||||
onDuplicate,
|
||||
);
|
||||
if (!fileBuffer || !fileSigned) {
|
||||
state.status = "canceled";
|
||||
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
|
||||
file,
|
||||
hmacSecret,
|
||||
onDuplicate,
|
||||
);
|
||||
if (!fileBuffer || !fileSigned) {
|
||||
state.status = "canceled";
|
||||
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const {
|
||||
dataKeyWrapped,
|
||||
dataKeyVersion,
|
||||
fileType,
|
||||
fileEncrypted,
|
||||
fileEncryptedHash,
|
||||
nameEncrypted,
|
||||
createdAtEncrypted,
|
||||
lastModifiedAtEncrypted,
|
||||
thumbnail,
|
||||
} = await encryptFile(state, file, fileBuffer, masterKey);
|
||||
const {
|
||||
dataKeyWrapped,
|
||||
dataKeyVersion,
|
||||
fileType,
|
||||
fileEncrypted,
|
||||
fileEncryptedHash,
|
||||
nameEncrypted,
|
||||
createdAtEncrypted,
|
||||
lastModifiedAtEncrypted,
|
||||
thumbnail,
|
||||
} = await encryptFile(state, file, fileBuffer, masterKey);
|
||||
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
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({
|
||||
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),
|
||||
contentIv: thumbnail.iv,
|
||||
} satisfies FileThumbnailUploadRequest),
|
||||
);
|
||||
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(state, form, thumbnailForm);
|
||||
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { fileId } = await requestFileUpload(state, form, thumbnailForm);
|
||||
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
export class Scheduler<T = void> {
|
||||
private tasks = 0;
|
||||
private isEstimating = false;
|
||||
private memoryUsage = 0;
|
||||
private queue: (() => void)[] = [];
|
||||
|
||||
constructor(public memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {}
|
||||
constructor(public readonly memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {}
|
||||
|
||||
private next() {
|
||||
if (this.memoryUsage < this.memoryLimit) {
|
||||
this.queue.shift()?.();
|
||||
if (!this.isEstimating && this.memoryUsage < this.memoryLimit) {
|
||||
const resolve = this.queue.shift();
|
||||
if (resolve) {
|
||||
this.isEstimating = true;
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async schedule(estimateMemoryUsage: () => Promise<number>, task: () => Promise<T>) {
|
||||
if (this.tasks++ > 0) {
|
||||
async schedule(
|
||||
estimateMemoryUsage: number | (() => number | Promise<number>),
|
||||
task: () => Promise<T>,
|
||||
) {
|
||||
if (this.isEstimating || this.memoryUsage >= this.memoryLimit) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.queue.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
while (this.memoryUsage >= this.memoryLimit) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.queue.unshift(resolve);
|
||||
});
|
||||
} else {
|
||||
this.isEstimating = true;
|
||||
}
|
||||
|
||||
let taskMemoryUsage = 0;
|
||||
|
||||
try {
|
||||
taskMemoryUsage = await estimateMemoryUsage();
|
||||
taskMemoryUsage =
|
||||
typeof estimateMemoryUsage === "number" ? estimateMemoryUsage : await estimateMemoryUsage();
|
||||
this.memoryUsage += taskMemoryUsage;
|
||||
} finally {
|
||||
this.isEstimating = false;
|
||||
this.next();
|
||||
}
|
||||
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
this.tasks--;
|
||||
this.memoryUsage -= taskMemoryUsage;
|
||||
this.next();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import { getAllFileInfos } from "$lib/indexedDB/filesystem";
|
||||
import { decryptData } from "$lib/modules/crypto";
|
||||
import {
|
||||
getFileCache,
|
||||
storeFileCache,
|
||||
deleteFileCache,
|
||||
getFileThumbnailCache,
|
||||
storeFileThumbnailCache,
|
||||
deleteFileThumbnailCache,
|
||||
downloadFile,
|
||||
deleteFileThumbnailCache,
|
||||
} from "$lib/modules/file";
|
||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
|
||||
import { trpc } from "$trpc/client";
|
||||
|
||||
@@ -44,29 +40,6 @@ export const requestFileThumbnailUpload = async (
|
||||
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 {
|
||||
thumbnailInfo = await trpc().file.thumbnail.query({ id: fileId });
|
||||
} catch {
|
||||
// TODO: Error Handling
|
||||
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 () => {
|
||||
let liveFiles;
|
||||
try {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { ActionEntryButton } from "$lib/components/atoms";
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import { getFileThumbnail } from "$lib/modules/file";
|
||||
import type { SummarizedFileInfo } from "$lib/modules/filesystem";
|
||||
import { requestFileThumbnailDownload } from "$lib/services/file";
|
||||
import { formatDateTime } from "$lib/utils";
|
||||
import type { SelectedEntry } from "../service.svelte";
|
||||
|
||||
@@ -17,12 +16,7 @@
|
||||
|
||||
let { info, onclick, onOpenMenuClick }: Props = $props();
|
||||
|
||||
let showThumbnail = $derived(
|
||||
browser && (info.contentType.startsWith("image/") || info.contentType.startsWith("video/")),
|
||||
);
|
||||
let thumbnailPromise = $derived(
|
||||
showThumbnail ? requestFileThumbnailDownload(info.id, info.dataKey?.key) : null,
|
||||
);
|
||||
let thumbnail = $derived(getFileThumbnail(info));
|
||||
|
||||
const action = (callback: typeof onclick) => {
|
||||
callback({ type: "file", id: info.id, dataKey: info.dataKey, name: info.name });
|
||||
@@ -35,18 +29,10 @@
|
||||
actionButtonIcon={IconMoreVert}
|
||||
onActionButtonClick={() => action(onOpenMenuClick)}
|
||||
>
|
||||
{#await thumbnailPromise}
|
||||
<DirectoryEntryLabel
|
||||
type="file"
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
/>
|
||||
{:then thumbnail}
|
||||
<DirectoryEntryLabel
|
||||
type="file"
|
||||
thumbnail={thumbnail ?? undefined}
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
/>
|
||||
{/await}
|
||||
<DirectoryEntryLabel
|
||||
type="file"
|
||||
thumbnail={$thumbnail}
|
||||
name={info.name}
|
||||
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||
/>
|
||||
</ActionEntryButton>
|
||||
|
||||
Reference in New Issue
Block a user