mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 08:06:56 +00:00
이전 버전에서 업로드된 파일을 청크 업로드 방식으로 마이그레이션할 수 있는 기능 추가
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { createCaller } from "$trpc/router.server";
|
||||
import type { PageServerLoad } from "./$types";
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
const files = await createCaller(event).file.listLegacy();
|
||||
return { files };
|
||||
};
|
||||
79
src/routes/(fullscreen)/settings/migration/+page.svelte
Normal file
79
src/routes/(fullscreen)/settings/migration/+page.svelte
Normal file
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
|
||||
import { TopBar } from "$lib/components/molecules";
|
||||
import { bulkGetFileInfo, type MaybeFileInfo } from "$lib/modules/filesystem";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
import { sortEntries } from "$lib/utils";
|
||||
import File from "./File.svelte";
|
||||
import { getMigrationState, clearMigrationStates, requestFileMigration } from "./service.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let fileInfos: MaybeFileInfo[] = $state([]);
|
||||
let files = $derived(
|
||||
fileInfos
|
||||
.map((info) => ({
|
||||
info,
|
||||
state: getMigrationState(info.id),
|
||||
}))
|
||||
.filter((file) => file.state?.status !== "completed"),
|
||||
);
|
||||
|
||||
const migrateAllFiles = () => {
|
||||
files.forEach(({ info }) => {
|
||||
if (info.exists) {
|
||||
requestFileMigration(info);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
fileInfos = sortEntries(
|
||||
Array.from((await bulkGetFileInfo(data.files, $masterKeyStore?.get(1)?.key!)).values()),
|
||||
);
|
||||
});
|
||||
|
||||
$effect(() => clearMigrationStates);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>암호화 마이그레이션</title>
|
||||
</svelte:head>
|
||||
|
||||
<TopBar title="암호화 마이그레이션" />
|
||||
<FullscreenDiv>
|
||||
{#if files.length > 0}
|
||||
<div class="space-y-4 pb-4">
|
||||
<p class="break-keep text-gray-800">
|
||||
이전 버전의 ArkVault에서 업로드된 {files.length}개 파일을 다시 암호화할 수 있어요.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each files as { info, state } (info.id)}
|
||||
{#if info.exists}
|
||||
<File
|
||||
{info}
|
||||
{state}
|
||||
onclick={({ id }) => goto(`/file/${id}`)}
|
||||
onMigrateClick={requestFileMigration}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<BottomDiv>
|
||||
<Button onclick={migrateAllFiles} class="w-full">모두 다시 암호화하기</Button>
|
||||
</BottomDiv>
|
||||
{:else}
|
||||
<div class="flex flex-grow items-center justify-center">
|
||||
<p class="text-gray-500">
|
||||
{#if data.files.length === 0}
|
||||
마이그레이션할 파일이 없어요.
|
||||
{:else}
|
||||
파일 목록을 불러오고 있어요.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</FullscreenDiv>
|
||||
55
src/routes/(fullscreen)/settings/migration/File.svelte
Normal file
55
src/routes/(fullscreen)/settings/migration/File.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script module lang="ts">
|
||||
const subtexts = {
|
||||
queued: "대기 중",
|
||||
"download-pending": "다운로드를 기다리는 중",
|
||||
downloading: "다운로드하는 중",
|
||||
"encryption-pending": "암호화를 기다리는 중",
|
||||
encrypting: "암호화하는 중",
|
||||
"upload-pending": "업로드를 기다리는 중",
|
||||
completed: "완료",
|
||||
error: "실패",
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { ActionEntryButton } from "$lib/components/atoms";
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { formatDateTime, formatNetworkSpeed } from "$lib/utils";
|
||||
import type { MigrationState } from "./service.svelte";
|
||||
|
||||
import IconSync from "~icons/material-symbols/sync";
|
||||
|
||||
type FileInfoWithExists = FileInfo & { exists: true };
|
||||
|
||||
interface Props {
|
||||
info: FileInfoWithExists;
|
||||
onclick: (file: FileInfo) => void;
|
||||
onMigrateClick: (file: FileInfoWithExists) => void;
|
||||
state: MigrationState | undefined;
|
||||
}
|
||||
|
||||
let { info, onclick, onMigrateClick, state }: Props = $props();
|
||||
|
||||
let subtext = $derived.by(() => {
|
||||
if (!state) {
|
||||
return formatDateTime(info.createdAt ?? info.lastModifiedAt);
|
||||
}
|
||||
if (state.status === "uploading") {
|
||||
const progress = Math.floor((state.progress ?? 0) * 100);
|
||||
const speed = formatNetworkSpeed((state.rate ?? 0) * 8);
|
||||
return `전송됨 ${progress}% · ${speed}`;
|
||||
}
|
||||
return subtexts[state.status] ?? state.status;
|
||||
});
|
||||
</script>
|
||||
|
||||
<ActionEntryButton
|
||||
class="h-14"
|
||||
onclick={() => onclick(info)}
|
||||
actionButtonIcon={!state || state.status === "error" ? IconSync : undefined}
|
||||
onActionButtonClick={() => onMigrateClick(info)}
|
||||
actionButtonClass="text-gray-800"
|
||||
>
|
||||
<DirectoryEntryLabel type="file" name={info.name} {subtext} />
|
||||
</ActionEntryButton>
|
||||
165
src/routes/(fullscreen)/settings/migration/service.svelte.ts
Normal file
165
src/routes/(fullscreen)/settings/migration/service.svelte.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { limitFunction } from "p-limit";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { CHUNK_SIZE } from "$lib/constants";
|
||||
import { encodeToBase64, encryptChunk, digestMessage } from "$lib/modules/crypto";
|
||||
import { deleteFileCache } from "$lib/modules/file";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { Scheduler } from "$lib/modules/scheduler";
|
||||
import { requestFileDownload } from "$lib/services/file";
|
||||
import { trpc } from "$trpc/client";
|
||||
|
||||
export type MigrationStatus =
|
||||
| "queued"
|
||||
| "download-pending"
|
||||
| "downloading"
|
||||
| "encryption-pending"
|
||||
| "encrypting"
|
||||
| "upload-pending"
|
||||
| "uploading"
|
||||
| "completed"
|
||||
| "error";
|
||||
|
||||
export interface MigrationState {
|
||||
status: MigrationStatus;
|
||||
progress?: number;
|
||||
rate?: number;
|
||||
}
|
||||
|
||||
const scheduler = new Scheduler();
|
||||
const states = new SvelteMap<number, MigrationState>();
|
||||
|
||||
const createState = (status: MigrationStatus): MigrationState => {
|
||||
const state = $state({ status });
|
||||
return state;
|
||||
};
|
||||
|
||||
export const getMigrationState = (fileId: number) => {
|
||||
return states.get(fileId);
|
||||
};
|
||||
|
||||
export const clearMigrationStates = () => {
|
||||
for (const [id, state] of states) {
|
||||
if (state.status === "completed" || state.status === "error") {
|
||||
states.delete(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const encryptChunks = async (fileBuffer: ArrayBuffer, dataKey: CryptoKey) => {
|
||||
const chunksEncrypted: { chunkEncrypted: ArrayBuffer; chunkEncryptedHash: string }[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (offset < fileBuffer.byteLength) {
|
||||
const nextOffset = Math.min(offset + CHUNK_SIZE, fileBuffer.byteLength);
|
||||
const chunkEncrypted = await encryptChunk(fileBuffer.slice(offset, nextOffset), dataKey);
|
||||
chunksEncrypted.push({
|
||||
chunkEncrypted: chunkEncrypted,
|
||||
chunkEncryptedHash: encodeToBase64(await digestMessage(chunkEncrypted)),
|
||||
});
|
||||
offset = nextOffset;
|
||||
}
|
||||
|
||||
return chunksEncrypted;
|
||||
};
|
||||
|
||||
const uploadMigrationChunks = limitFunction(
|
||||
async (
|
||||
state: MigrationState,
|
||||
fileId: number,
|
||||
chunksEncrypted: { chunkEncrypted: ArrayBuffer; chunkEncryptedHash: string }[],
|
||||
) => {
|
||||
state.status = "uploading";
|
||||
|
||||
const { uploadId } = await trpc().upload.startMigrationUpload.mutate({
|
||||
file: fileId,
|
||||
chunks: chunksEncrypted.length,
|
||||
});
|
||||
|
||||
const totalBytes = chunksEncrypted.reduce((sum, c) => sum + c.chunkEncrypted.byteLength, 0);
|
||||
let uploadedBytes = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < chunksEncrypted.length; i++) {
|
||||
const { chunkEncrypted, chunkEncryptedHash } = chunksEncrypted[i]!;
|
||||
|
||||
const response = await fetch(`/api/upload/${uploadId}/chunks/${i}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Digest": `sha-256=:${chunkEncryptedHash}:`,
|
||||
},
|
||||
body: chunkEncrypted,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chunk upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
uploadedBytes += chunkEncrypted.byteLength;
|
||||
|
||||
const elapsed = (Date.now() - startTime) / 1000;
|
||||
const rate = uploadedBytes / elapsed;
|
||||
|
||||
state.progress = uploadedBytes / totalBytes;
|
||||
state.rate = rate;
|
||||
}
|
||||
|
||||
await trpc().upload.completeMigrationUpload.mutate({ uploadId });
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
|
||||
const encryptFile = limitFunction(
|
||||
async (state: MigrationState, fileBuffer: ArrayBuffer, dataKey: CryptoKey) => {
|
||||
state.status = "encrypting";
|
||||
const chunksEncrypted = await encryptChunks(fileBuffer, dataKey);
|
||||
state.status = "upload-pending";
|
||||
return chunksEncrypted;
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
export const requestFileMigration = async (fileInfo: FileInfo & { exists: true }) => {
|
||||
let state = states.get(fileInfo.id);
|
||||
if (state) {
|
||||
if (state.status !== "error") return;
|
||||
state.status = "queued";
|
||||
state.progress = undefined;
|
||||
state.rate = undefined;
|
||||
} else {
|
||||
state = createState("queued");
|
||||
states.set(fileInfo.id, state);
|
||||
}
|
||||
|
||||
try {
|
||||
const dataKey = fileInfo.dataKey?.key;
|
||||
if (!dataKey) {
|
||||
throw new Error("Data key not available");
|
||||
}
|
||||
|
||||
let fileBuffer: ArrayBuffer | undefined;
|
||||
|
||||
await scheduler.schedule(
|
||||
async () => {
|
||||
state.status = "download-pending";
|
||||
state.status = "downloading";
|
||||
fileBuffer = await requestFileDownload(fileInfo.id, dataKey, true);
|
||||
return fileBuffer.byteLength;
|
||||
},
|
||||
async () => {
|
||||
state.status = "encryption-pending";
|
||||
const chunksEncrypted = await encryptFile(state, fileBuffer!, dataKey);
|
||||
|
||||
await uploadMigrationChunks(state, fileInfo.id, chunksEncrypted);
|
||||
|
||||
// Clear file cache since the file format has changed
|
||||
await deleteFileCache(fileInfo.id);
|
||||
|
||||
state.status = "completed";
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user