mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-03 23:56:53 +00:00
동시에 업로드할 수 있는 파일의 메모리 용량을 제한하여 메모리 부족으로 인해 발생하던 크래시 해결
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "arkvault",
|
"name": "arkvault",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
digestMessage,
|
digestMessage,
|
||||||
signMessageHmac,
|
signMessageHmac,
|
||||||
} from "$lib/modules/crypto";
|
} from "$lib/modules/crypto";
|
||||||
|
import { Scheduler } from "$lib/modules/scheduler";
|
||||||
import { generateThumbnail } from "$lib/modules/thumbnail";
|
import { generateThumbnail } from "$lib/modules/thumbnail";
|
||||||
import type {
|
import type {
|
||||||
FileThumbnailUploadRequest,
|
FileThumbnailUploadRequest,
|
||||||
@@ -23,6 +24,7 @@ export interface FileUploadState {
|
|||||||
name: string;
|
name: string;
|
||||||
parentId: DirectoryId;
|
parentId: DirectoryId;
|
||||||
status:
|
status:
|
||||||
|
| "queued"
|
||||||
| "encryption-pending"
|
| "encryption-pending"
|
||||||
| "encrypting"
|
| "encrypting"
|
||||||
| "upload-pending"
|
| "upload-pending"
|
||||||
@@ -36,13 +38,16 @@ export interface FileUploadState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type LiveFileUploadState = FileUploadState & {
|
export type LiveFileUploadState = FileUploadState & {
|
||||||
status: "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
|
status: "queued" | "encryption-pending" | "encrypting" | "upload-pending" | "uploading";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduler = new Scheduler<
|
||||||
|
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
|
||||||
|
>();
|
||||||
let uploadingFiles: FileUploadState[] = $state([]);
|
let uploadingFiles: FileUploadState[] = $state([]);
|
||||||
|
|
||||||
const isFileUploading = (status: FileUploadState["status"]) =>
|
const isFileUploading = (status: FileUploadState["status"]) =>
|
||||||
["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
|
["queued", "encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
|
||||||
|
|
||||||
export const getUploadingFiles = (parentId?: DirectoryId) => {
|
export const getUploadingFiles = (parentId?: DirectoryId) => {
|
||||||
return uploadingFiles.filter(
|
return uploadingFiles.filter(
|
||||||
@@ -183,80 +188,85 @@ export const uploadFile = async (
|
|||||||
hmacSecret: HmacSecret,
|
hmacSecret: HmacSecret,
|
||||||
masterKey: MasterKey,
|
masterKey: MasterKey,
|
||||||
onDuplicate: () => Promise<boolean>,
|
onDuplicate: () => Promise<boolean>,
|
||||||
): Promise<
|
) => {
|
||||||
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
|
|
||||||
> => {
|
|
||||||
uploadingFiles.push({
|
uploadingFiles.push({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
parentId,
|
parentId,
|
||||||
status: "encryption-pending",
|
status: "queued",
|
||||||
});
|
});
|
||||||
const state = uploadingFiles.at(-1)!;
|
const state = uploadingFiles.at(-1)!;
|
||||||
|
|
||||||
try {
|
return await scheduler.schedule(
|
||||||
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
|
async () => file.size,
|
||||||
file,
|
async () => {
|
||||||
hmacSecret,
|
state.status = "encryption-pending";
|
||||||
onDuplicate,
|
|
||||||
);
|
|
||||||
if (!fileBuffer || !fileSigned) {
|
|
||||||
state.status = "canceled";
|
|
||||||
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
try {
|
||||||
dataKeyWrapped,
|
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
|
||||||
dataKeyVersion,
|
file,
|
||||||
fileType,
|
hmacSecret,
|
||||||
fileEncrypted,
|
onDuplicate,
|
||||||
fileEncryptedHash,
|
);
|
||||||
nameEncrypted,
|
if (!fileBuffer || !fileSigned) {
|
||||||
createdAtEncrypted,
|
state.status = "canceled";
|
||||||
lastModifiedAtEncrypted,
|
uploadingFiles = uploadingFiles.filter((file) => file !== state);
|
||||||
thumbnail,
|
return undefined;
|
||||||
} = await encryptFile(state, file, fileBuffer, masterKey);
|
}
|
||||||
|
|
||||||
const form = new FormData();
|
const {
|
||||||
form.set(
|
dataKeyWrapped,
|
||||||
"metadata",
|
dataKeyVersion,
|
||||||
JSON.stringify({
|
fileType,
|
||||||
parent: parentId,
|
fileEncrypted,
|
||||||
mekVersion: masterKey.version,
|
fileEncryptedHash,
|
||||||
dek: dataKeyWrapped,
|
nameEncrypted,
|
||||||
dekVersion: dataKeyVersion.toISOString(),
|
createdAtEncrypted,
|
||||||
hskVersion: hmacSecret.version,
|
lastModifiedAtEncrypted,
|
||||||
contentHmac: fileSigned,
|
thumbnail,
|
||||||
contentType: fileType,
|
} = await encryptFile(state, file, fileBuffer, masterKey);
|
||||||
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;
|
const form = new FormData();
|
||||||
if (thumbnail) {
|
form.set(
|
||||||
thumbnailForm = new FormData();
|
"metadata",
|
||||||
thumbnailForm.set(
|
JSON.stringify({
|
||||||
"metadata",
|
parent: parentId,
|
||||||
JSON.stringify({
|
mekVersion: masterKey.version,
|
||||||
dekVersion: dataKeyVersion.toISOString(),
|
dek: dataKeyWrapped,
|
||||||
contentIv: thumbnail.iv,
|
dekVersion: dataKeyVersion.toISOString(),
|
||||||
} satisfies FileThumbnailUploadRequest),
|
hskVersion: hmacSecret.version,
|
||||||
);
|
contentHmac: fileSigned,
|
||||||
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
|
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);
|
||||||
|
|
||||||
const { fileId } = await requestFileUpload(state, form, thumbnailForm);
|
let thumbnailForm = null;
|
||||||
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
|
if (thumbnail) {
|
||||||
} catch (e) {
|
thumbnailForm = new FormData();
|
||||||
state.status = "error";
|
thumbnailForm.set(
|
||||||
throw e;
|
"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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
41
src/lib/modules/scheduler.ts
Normal file
41
src/lib/modules/scheduler.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export class Scheduler<T = void> {
|
||||||
|
private tasks = 0;
|
||||||
|
private memoryUsage = 0;
|
||||||
|
private queue: (() => void)[] = [];
|
||||||
|
|
||||||
|
constructor(public memoryLimit = 100 * 1024 * 1024 /* 100 MiB */) {}
|
||||||
|
|
||||||
|
private next() {
|
||||||
|
if (this.memoryUsage < this.memoryLimit) {
|
||||||
|
this.queue.shift()?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async schedule(estimateMemoryUsage: () => Promise<number>, task: () => Promise<T>) {
|
||||||
|
if (this.tasks++ > 0) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.queue.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
while (this.memoryUsage >= this.memoryLimit) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
this.queue.unshift(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let taskMemoryUsage = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
taskMemoryUsage = await estimateMemoryUsage();
|
||||||
|
this.memoryUsage += taskMemoryUsage;
|
||||||
|
this.next();
|
||||||
|
|
||||||
|
return await task();
|
||||||
|
} finally {
|
||||||
|
this.tasks--;
|
||||||
|
this.memoryUsage -= taskMemoryUsage;
|
||||||
|
this.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
|
|
||||||
<div class="flex h-14 items-center gap-x-4 p-2">
|
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||||
<div class="flex-shrink-0 text-lg text-gray-600">
|
<div class="flex-shrink-0 text-lg text-gray-600">
|
||||||
{#if state.status === "encryption-pending"}
|
{#if state.status === "queued" || state.status === "encryption-pending"}
|
||||||
<IconPending />
|
<IconPending />
|
||||||
{:else if state.status === "encrypting"}
|
{:else if state.status === "encrypting"}
|
||||||
<IconLockClock />
|
<IconLockClock />
|
||||||
@@ -37,7 +37,9 @@
|
|||||||
{state.name}
|
{state.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-gray-800">
|
<p class="text-xs text-gray-800">
|
||||||
{#if state.status === "encryption-pending"}
|
{#if state.status === "queued"}
|
||||||
|
대기 중
|
||||||
|
{:else if state.status === "encryption-pending"}
|
||||||
준비 중
|
준비 중
|
||||||
{:else if state.status === "encrypting"}
|
{:else if state.status === "encrypting"}
|
||||||
암호화하는 중
|
암호화하는 중
|
||||||
|
|||||||
@@ -4,17 +4,36 @@
|
|||||||
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
|
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
|
||||||
import { IconEntryButton, TopBar } from "$lib/components/molecules";
|
import { IconEntryButton, TopBar } from "$lib/components/molecules";
|
||||||
import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
|
import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
|
||||||
import { bulkGetFileInfo } from "$lib/modules/filesystem";
|
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
|
||||||
import { masterKeyStore } from "$lib/stores";
|
import { masterKeyStore } from "$lib/stores";
|
||||||
|
import { sortEntries } from "$lib/utils";
|
||||||
import File from "./File.svelte";
|
import File from "./File.svelte";
|
||||||
import { persistentStates, requestThumbnailGeneration } from "./service.svelte";
|
import {
|
||||||
|
getThumbnailGenerationStatus,
|
||||||
|
clearThumbnailGenerationStatuses,
|
||||||
|
requestThumbnailGeneration,
|
||||||
|
type GenerationStatus,
|
||||||
|
} from "./service.svelte";
|
||||||
|
|
||||||
import IconDelete from "~icons/material-symbols/delete";
|
import IconDelete from "~icons/material-symbols/delete";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
|
let fileInfos: MaybeFileInfo[] = $state([]);
|
||||||
|
let files = $derived(
|
||||||
|
fileInfos
|
||||||
|
.map((info) => ({
|
||||||
|
info,
|
||||||
|
status: getThumbnailGenerationStatus(info.id),
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(file): file is { info: MaybeFileInfo; status: Exclude<GenerationStatus, "uploaded"> } =>
|
||||||
|
file.status !== "uploaded",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const generateAllThumbnails = () => {
|
const generateAllThumbnails = () => {
|
||||||
persistentStates.files.forEach(({ info }) => {
|
files.forEach(({ info }) => {
|
||||||
if (info.exists) {
|
if (info.exists) {
|
||||||
requestThumbnailGeneration(info);
|
requestThumbnailGeneration(info);
|
||||||
}
|
}
|
||||||
@@ -22,13 +41,12 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const fileInfos = await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!);
|
fileInfos = sortEntries(
|
||||||
persistentStates.files = persistentStates.files.map(({ id, status }) => ({
|
Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()),
|
||||||
id,
|
);
|
||||||
info: fileInfos.get(id)!,
|
|
||||||
status,
|
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => clearThumbnailGenerationStatuses);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -43,19 +61,19 @@
|
|||||||
저장된 썸네일 모두 삭제하기
|
저장된 썸네일 모두 삭제하기
|
||||||
</IconEntryButton>
|
</IconEntryButton>
|
||||||
</div>
|
</div>
|
||||||
{#if persistentStates.files.length > 0}
|
{#if files.length > 0}
|
||||||
<div class="flex-grow space-y-2 bg-white p-4">
|
<div class="flex-grow space-y-2 bg-white p-4">
|
||||||
<p class="text-lg font-bold text-gray-800">썸네일이 누락된 파일</p>
|
<p class="text-lg font-bold text-gray-800">썸네일이 누락된 파일</p>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="break-keep text-gray-800">
|
<p class="break-keep text-gray-800">
|
||||||
{persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요.
|
{files.length}개 파일의 썸네일이 존재하지 않아요.
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{#each persistentStates.files as { info, status } (info.id)}
|
{#each files as { info, status } (info.id)}
|
||||||
{#if info.exists}
|
{#if info.exists}
|
||||||
<File
|
<File
|
||||||
{info}
|
{info}
|
||||||
generationStatus={status}
|
{status}
|
||||||
onclick={({ id }) => goto(`/file/${id}`)}
|
onclick={({ id }) => goto(`/file/${id}`)}
|
||||||
onGenerateThumbnailClick={requestThumbnailGeneration}
|
onGenerateThumbnailClick={requestThumbnailGeneration}
|
||||||
/>
|
/>
|
||||||
@@ -66,7 +84,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if persistentStates.files.length > 0}
|
{#if files.length > 0}
|
||||||
<BottomDiv class="px-4">
|
<BottomDiv class="px-4">
|
||||||
<Button onclick={generateAllThumbnails} class="w-full">모두 썸네일 생성하기</Button>
|
<Button onclick={generateAllThumbnails} class="w-full">모두 썸네일 생성하기</Button>
|
||||||
</BottomDiv>
|
</BottomDiv>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Writable } from "svelte/store";
|
|
||||||
import { ActionEntryButton } from "$lib/components/atoms";
|
import { ActionEntryButton } from "$lib/components/atoms";
|
||||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||||
import type { FileInfo } from "$lib/modules/filesystem";
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
@@ -23,22 +22,21 @@
|
|||||||
info: FileInfo;
|
info: FileInfo;
|
||||||
onclick: (file: FileInfo) => void;
|
onclick: (file: FileInfo) => void;
|
||||||
onGenerateThumbnailClick: (file: FileInfo) => void;
|
onGenerateThumbnailClick: (file: FileInfo) => void;
|
||||||
generationStatus?: Writable<GenerationStatus>;
|
status: Exclude<GenerationStatus, "uploaded"> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
|
let { info, onclick, onGenerateThumbnailClick, status }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ActionEntryButton
|
<ActionEntryButton
|
||||||
class="h-14"
|
class="h-14"
|
||||||
onclick={() => onclick(info)}
|
onclick={() => onclick(info)}
|
||||||
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined}
|
actionButtonIcon={!status || status === "error" ? IconCamera : undefined}
|
||||||
onActionButtonClick={() => onGenerateThumbnailClick(info)}
|
onActionButtonClick={() => onGenerateThumbnailClick(info)}
|
||||||
actionButtonClass="text-gray-800"
|
actionButtonClass="text-gray-800"
|
||||||
>
|
>
|
||||||
{@const subtext =
|
{@const subtext = status
|
||||||
$generationStatus && $generationStatus !== "uploaded"
|
? subtexts[status]
|
||||||
? subtexts[$generationStatus]
|
: formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
||||||
: formatDateTime(info.createdAt ?? info.lastModifiedAt)}
|
|
||||||
<DirectoryEntryLabel type="file" name={info.name} {subtext} />
|
<DirectoryEntryLabel type="file" name={info.name} {subtext} />
|
||||||
</ActionEntryButton>
|
</ActionEntryButton>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { limitFunction } from "p-limit";
|
import { limitFunction } from "p-limit";
|
||||||
import { get, writable, type Writable } from "svelte/store";
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
import { encryptData } from "$lib/modules/crypto";
|
import { encryptData } from "$lib/modules/crypto";
|
||||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||||
import type { FileInfo, MaybeFileInfo } from "$lib/modules/filesystem";
|
import type { FileInfo } from "$lib/modules/filesystem";
|
||||||
|
import { Scheduler } from "$lib/modules/scheduler";
|
||||||
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
|
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
|
||||||
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
|
import { requestFileDownload, requestFileThumbnailUpload } from "$lib/services/file";
|
||||||
|
|
||||||
@@ -15,41 +16,31 @@ export type GenerationStatus =
|
|||||||
| "uploaded"
|
| "uploaded"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
interface File {
|
const scheduler = new Scheduler();
|
||||||
id: number;
|
const statuses = new SvelteMap<number, GenerationStatus>();
|
||||||
info: MaybeFileInfo;
|
|
||||||
status?: Writable<GenerationStatus>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const workingFiles = new Map<number, Writable<GenerationStatus>>();
|
export const getThumbnailGenerationStatus = (fileId: number) => {
|
||||||
|
return statuses.get(fileId);
|
||||||
|
};
|
||||||
|
|
||||||
let queue: (() => void)[] = [];
|
export const clearThumbnailGenerationStatuses = () => {
|
||||||
let memoryUsage = 0;
|
for (const [id, status] of statuses) {
|
||||||
const memoryLimit = 100 * 1024 * 1024; // 100 MiB
|
if (status === "uploaded" || status === "error") {
|
||||||
|
statuses.delete(id);
|
||||||
export const persistentStates = $state({
|
}
|
||||||
files: [] as File[],
|
}
|
||||||
});
|
|
||||||
|
|
||||||
export const getGenerationStatus = (fileId: number) => {
|
|
||||||
return workingFiles.get(fileId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateThumbnail = limitFunction(
|
const generateThumbnail = limitFunction(
|
||||||
async (
|
async (fileId: number, fileBuffer: ArrayBuffer, fileType: string, dataKey: CryptoKey) => {
|
||||||
status: Writable<GenerationStatus>,
|
statuses.set(fileId, "generating");
|
||||||
fileBuffer: ArrayBuffer,
|
|
||||||
fileType: string,
|
|
||||||
dataKey: CryptoKey,
|
|
||||||
) => {
|
|
||||||
status.set("generating");
|
|
||||||
|
|
||||||
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
|
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
|
||||||
if (!thumbnail) return null;
|
if (!thumbnail) return null;
|
||||||
|
|
||||||
const thumbnailBuffer = await thumbnail.arrayBuffer();
|
const thumbnailBuffer = await thumbnail.arrayBuffer();
|
||||||
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
|
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
|
||||||
status.set("upload-pending");
|
statuses.set(fileId, "upload-pending");
|
||||||
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
|
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
|
||||||
},
|
},
|
||||||
{ concurrency: 4 },
|
{ concurrency: 4 },
|
||||||
@@ -57,106 +48,55 @@ const generateThumbnail = limitFunction(
|
|||||||
|
|
||||||
const requestThumbnailUpload = limitFunction(
|
const requestThumbnailUpload = limitFunction(
|
||||||
async (
|
async (
|
||||||
status: Writable<GenerationStatus>,
|
|
||||||
fileId: number,
|
fileId: number,
|
||||||
dataKeyVersion: Date,
|
dataKeyVersion: Date,
|
||||||
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string },
|
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string },
|
||||||
) => {
|
) => {
|
||||||
status.set("uploading");
|
statuses.set(fileId, "uploading");
|
||||||
|
|
||||||
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
|
const res = await requestFileThumbnailUpload(fileId, dataKeyVersion, thumbnail);
|
||||||
if (!res.ok) return false;
|
if (!res.ok) return false;
|
||||||
|
statuses.set(fileId, "uploaded");
|
||||||
status.set("uploaded");
|
|
||||||
workingFiles.delete(fileId);
|
|
||||||
persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId);
|
|
||||||
|
|
||||||
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
|
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
{ concurrency: 4 },
|
{ concurrency: 4 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const enqueue = async (
|
|
||||||
status: Writable<GenerationStatus> | undefined,
|
|
||||||
fileInfo: FileInfo,
|
|
||||||
priority = false,
|
|
||||||
) => {
|
|
||||||
if (status) {
|
|
||||||
status.set("queued");
|
|
||||||
} else {
|
|
||||||
status = writable("queued");
|
|
||||||
workingFiles.set(fileInfo.id, status);
|
|
||||||
persistentStates.files = persistentStates.files.map((file) =>
|
|
||||||
file.id === fileInfo.id ? { ...file, status } : file,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let resolver;
|
|
||||||
const promise = new Promise((resolve) => {
|
|
||||||
resolver = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (priority) {
|
|
||||||
queue = [resolver!, ...queue];
|
|
||||||
} else {
|
|
||||||
queue.push(resolver!);
|
|
||||||
}
|
|
||||||
|
|
||||||
await promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
|
export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
|
||||||
let status = workingFiles.get(fileInfo.id);
|
const status = statuses.get(fileInfo.id);
|
||||||
if (status && get(status) !== "error") return;
|
|
||||||
|
|
||||||
if (workingFiles.values().some((status) => get(status) !== "error")) {
|
|
||||||
await enqueue(status, fileInfo);
|
|
||||||
}
|
|
||||||
while (memoryUsage >= memoryLimit) {
|
|
||||||
await enqueue(status, fileInfo, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status) {
|
if (status) {
|
||||||
status.set("generation-pending");
|
if (status !== "error") return;
|
||||||
} else {
|
} else {
|
||||||
status = writable("generation-pending");
|
statuses.set(fileInfo.id, "queued");
|
||||||
workingFiles.set(fileInfo.id, status);
|
|
||||||
persistentStates.files = persistentStates.files.map((file) =>
|
|
||||||
file.id === fileInfo.id ? { ...file, status } : file,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileSize = 0;
|
|
||||||
try {
|
try {
|
||||||
const file = await requestFileDownload(
|
let file: ArrayBuffer | undefined;
|
||||||
fileInfo.id,
|
|
||||||
fileInfo.contentIv!,
|
|
||||||
fileInfo.dataKey?.key!,
|
|
||||||
);
|
|
||||||
fileSize = file.byteLength;
|
|
||||||
|
|
||||||
memoryUsage += fileSize;
|
await scheduler.schedule(
|
||||||
if (memoryUsage < memoryLimit) {
|
async () => {
|
||||||
queue.shift()?.();
|
statuses.set(fileInfo.id, "generation-pending");
|
||||||
}
|
file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey?.key!);
|
||||||
|
return file.byteLength;
|
||||||
const thumbnail = await generateThumbnail(
|
},
|
||||||
status,
|
async () => {
|
||||||
file,
|
const thumbnail = await generateThumbnail(
|
||||||
fileInfo.contentType,
|
fileInfo.id,
|
||||||
fileInfo.dataKey?.key!,
|
file!,
|
||||||
|
fileInfo.contentType,
|
||||||
|
fileInfo.dataKey?.key!,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!thumbnail ||
|
||||||
|
!(await requestThumbnailUpload(fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
|
||||||
|
) {
|
||||||
|
statuses.set(fileInfo.id, "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (
|
} catch (e) {
|
||||||
!thumbnail ||
|
statuses.set(fileInfo.id, "error");
|
||||||
!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKey?.version!, thumbnail))
|
throw e;
|
||||||
) {
|
|
||||||
status.set("error");
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
status.set("error");
|
|
||||||
} finally {
|
|
||||||
memoryUsage -= fileSize;
|
|
||||||
queue.shift()?.();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
{state.name}
|
{state.name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs">
|
<p class="text-xs">
|
||||||
{#if state.status === "encryption-pending"}
|
{#if state.status === "queued"}
|
||||||
|
대기 중
|
||||||
|
{:else if state.status === "encryption-pending"}
|
||||||
준비 중
|
준비 중
|
||||||
{:else if state.status === "encrypting"}
|
{:else if state.status === "encrypting"}
|
||||||
암호화하는 중
|
암호화하는 중
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { RequestHandler } from "./$types";
|
|||||||
const trpcHandler: RequestHandler = (event) =>
|
const trpcHandler: RequestHandler = (event) =>
|
||||||
fetchRequestHandler({
|
fetchRequestHandler({
|
||||||
endpoint: "/api/trpc",
|
endpoint: "/api/trpc",
|
||||||
|
allowMethodOverride: true,
|
||||||
req: event.request,
|
req: event.request,
|
||||||
router: appRouter,
|
router: appRouter,
|
||||||
createContext: () => createContext(event),
|
createContext: () => createContext(event),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const createClient = (fetch: typeof globalThis.fetch) =>
|
|||||||
httpBatchLink({
|
httpBatchLink({
|
||||||
url: "/api/trpc",
|
url: "/api/trpc",
|
||||||
maxURLLength: 4096,
|
maxURLLength: 4096,
|
||||||
|
methodOverride: "POST",
|
||||||
transformer: superjson,
|
transformer: superjson,
|
||||||
fetch,
|
fetch,
|
||||||
}),
|
}),
|
||||||
|
|||||||
Reference in New Issue
Block a user