프론트엔드에서의 파일 업로드 전 중복 검사 구현

This commit is contained in:
static
2025-01-13 00:46:35 +09:00
parent 59c8523e25
commit 5c7dc58f03
8 changed files with 161 additions and 12 deletions

View File

@@ -29,7 +29,7 @@
onclick?.(); onclick?.();
}, 100); }, 100);
}} }}
class="{bgColorStyle} {fontColorStyle} h-12 w-full rounded-xl font-medium" class="{bgColorStyle} {fontColorStyle} h-12 w-full min-w-fit rounded-xl font-medium"
> >
<div class="h-full w-full p-3 transition active:scale-95"> <div class="h-full w-full p-3 transition active:scale-95">
{@render children?.()} {@render children?.()}

View File

@@ -220,6 +220,23 @@ export const getAllFilesByParent = async (userId: number, parentId: DirectoryId)
); );
}; };
export const getAllFileIdsByContentHmac = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
return await db
.select({ id: file.id })
.from(file)
.where(
and(
eq(file.userId, userId),
eq(file.hskVersion, hskVersion),
eq(file.contentHmac, contentHmac),
),
);
};
export const getFile = async (userId: number, fileId: number) => { export const getFile = async (userId: number, fileId: number) => {
const res = await db const res = await db
.select() .select()

View File

@@ -22,6 +22,17 @@ export const fileRenameRequest = z.object({
}); });
export type FileRenameRequest = z.infer<typeof fileRenameRequest>; export type FileRenameRequest = z.infer<typeof fileRenameRequest>;
export const duplicateFileScanRequest = z.object({
hskVersion: z.number().int().positive(),
contentHmac: z.string().base64().nonempty(),
});
export type DuplicateFileScanRequest = z.infer<typeof duplicateFileScanRequest>;
export const duplicateFileScanResponse = z.object({
files: z.number().int().positive().array(),
});
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
export const fileUploadRequest = z.object({ export const fileUploadRequest = z.object({
parentId: z.union([z.enum(["root"]), z.number().int().positive()]), parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
mekVersion: z.number().int().positive(), mekVersion: z.number().int().positive(),

View File

@@ -7,6 +7,7 @@ import { v4 as uuidv4 } from "uuid";
import { IntegrityError } from "$lib/server/db/error"; import { IntegrityError } from "$lib/server/db/error";
import { import {
registerFile, registerFile,
getAllFileIdsByContentHmac,
getFile, getFile,
setFileEncName, setFileEncName,
unregisterFile, unregisterFile,
@@ -76,6 +77,15 @@ export const renameFile = async (
} }
}; };
export const scanDuplicateFiles = async (
userId: number,
hskVersion: number,
contentHmac: string,
) => {
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
return { files: fileIds.map(({ id }) => id) };
};
const safeUnlink = async (path: string) => { const safeUnlink = async (path: string) => {
await unlink(path).catch(console.error); await unlink(path).catch(console.error);
}; };

View File

@@ -11,10 +11,12 @@
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte"; import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntries from "./DirectoryEntries";
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte"; import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
import DuplicateFileModal from "./DuplicateFileModal.svelte";
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte"; import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
import { import {
requestHmacSecretDownload, requestHmacSecretDownload,
requestDirectoryCreation, requestDirectoryCreation,
requestDuplicateFileScan,
requestFileUpload, requestFileUpload,
requestDirectoryEntryRename, requestDirectoryEntryRename,
requestDirectoryEntryDeletion, requestDirectoryEntryDeletion,
@@ -23,14 +25,22 @@
import IconAdd from "~icons/material-symbols/add"; import IconAdd from "~icons/material-symbols/add";
interface LoadedFile {
file: File;
fileBuffer: ArrayBuffer;
fileSigned: string;
}
let { data } = $props(); let { data } = $props();
let info: Writable<DirectoryInfo | null> | undefined = $state(); let info: Writable<DirectoryInfo | null> | undefined = $state();
let fileInput: HTMLInputElement | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state();
let loadedFile: LoadedFile | undefined = $state();
let selectedEntry: SelectedDirectoryEntry | undefined = $state(); let selectedEntry: SelectedDirectoryEntry | undefined = $state();
let isCreateBottomSheetOpen = $state(false); let isCreateBottomSheetOpen = $state(false);
let isCreateDirectoryModalOpen = $state(false); let isCreateDirectoryModalOpen = $state(false);
let isDuplicateFileModalOpen = $state(false);
let isDirectoryEntryMenuBottomSheetOpen = $state(false); let isDirectoryEntryMenuBottomSheetOpen = $state(false);
let isRenameDirectoryEntryModalOpen = $state(false); let isRenameDirectoryEntryModalOpen = $state(false);
@@ -42,15 +52,34 @@
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
}; };
const uploadFile = () => { const uploadFile = (loadedFile: LoadedFile) => {
requestFileUpload(
loadedFile.file,
loadedFile.fileBuffer,
loadedFile.fileSigned,
data.id,
$masterKeyStore?.get(1)!,
$hmacSecretStore?.get(1)!,
).then(() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
});
};
const loadAndUploadFile = async () => {
const file = fileInput?.files?.[0]; const file = fileInput?.files?.[0];
if (!file) return; if (!file) return;
requestFileUpload(file, data.id, $masterKeyStore?.get(1)!, $hmacSecretStore?.get(1)!).then( fileInput!.value = "";
() => {
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME const scanRes = await requestDuplicateFileScan(file, $hmacSecretStore?.get(1)!);
}, if (scanRes === null) {
); throw new Error("Failed to scan duplicate files");
} else if (scanRes.isDuplicate) {
loadedFile = { ...scanRes, file };
isDuplicateFileModalOpen = true;
} else {
uploadFile({ ...scanRes, file });
}
}; };
onMount(async () => { onMount(async () => {
@@ -68,7 +97,7 @@
<title>파일</title> <title>파일</title>
</svelte:head> </svelte:head>
<input bind:this={fileInput} onchange={uploadFile} type="file" class="hidden" /> <input bind:this={fileInput} onchange={loadAndUploadFile} type="file" class="hidden" />
<div class="flex min-h-full flex-col px-4"> <div class="flex min-h-full flex-col px-4">
{#if data.id !== "root"} {#if data.id !== "root"}
@@ -109,6 +138,18 @@
}} }}
/> />
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} /> <CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
<DuplicateFileModal
bind:isOpen={isDuplicateFileModalOpen}
onclose={() => {
isDuplicateFileModalOpen = false;
loadedFile = undefined;
}}
onDuplicateClick={() => {
uploadFile(loadedFile!);
isDuplicateFileModalOpen = false;
loadedFile = undefined;
}}
/>
<DirectoryEntryMenuBottomSheet <DirectoryEntryMenuBottomSheet
bind:isOpen={isDirectoryEntryMenuBottomSheetOpen} bind:isOpen={isDirectoryEntryMenuBottomSheetOpen}

View File

@@ -0,0 +1,32 @@
<script lang="ts">
import { Modal } from "$lib/components";
import { Button } from "$lib/components/buttons";
interface Props {
onclose: () => void;
onDuplicateClick: () => void;
isOpen: boolean;
}
let { onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
</script>
<Modal bind:isOpen {onclose}>
<div class="space-y-4">
<div class="space-y-2 break-keep">
<p class="text-xl font-bold">이미 업로드된 파일이에요.</p>
<p>그래도 업로드할까요?</p>
</div>
<div class="flex gap-2">
<Button
color="gray"
onclick={() => {
isOpen = false;
}}
>
아니요
</Button>
<Button onclick={onDuplicateClick}>업로드할게요</Button>
</div>
</div>
</Modal>

View File

@@ -15,6 +15,8 @@ import type {
FileRenameRequest, FileRenameRequest,
FileUploadRequest, FileUploadRequest,
HmacSecretListResponse, HmacSecretListResponse,
DuplicateFileScanRequest,
DuplicateFileScanResponse,
} from "$lib/server/schemas"; } from "$lib/server/schemas";
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores"; import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
@@ -63,17 +65,33 @@ export const requestDirectoryCreation = async (
}); });
}; };
export const requestDuplicateFileScan = async (file: File, hmacSecret: HmacSecret) => {
const fileBuffer = await file.arrayBuffer();
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
const res = await callPostApi<DuplicateFileScanRequest>("/api/file/scanDuplicates", {
hskVersion: hmacSecret.version,
contentHmac: fileSigned,
});
if (!res.ok) return null;
const { files }: DuplicateFileScanResponse = await res.json();
return {
fileBuffer,
fileSigned,
isDuplicate: files.length > 0,
};
};
export const requestFileUpload = async ( export const requestFileUpload = async (
file: File, file: File,
fileBuffer: ArrayBuffer,
fileSigned: string,
parentId: "root" | number, parentId: "root" | number,
masterKey: MasterKey, masterKey: MasterKey,
hmacSecret: HmacSecret, hmacSecret: HmacSecret,
) => { ) => {
const { dataKey, dataKeyVersion } = await generateDataKey(); const { dataKey, dataKeyVersion } = await generateDataKey();
const nameEncrypted = await encryptString(file.name, dataKey); const nameEncrypted = await encryptString(file.name, dataKey);
const fileBuffer = await file.arrayBuffer();
const fileSigned = await signMessageHmac(fileBuffer, hmacSecret.secret);
const fileEncrypted = await encryptData(fileBuffer, dataKey); const fileEncrypted = await encryptData(fileBuffer, dataKey);
const form = new FormData(); const form = new FormData();
@@ -85,7 +103,7 @@ export const requestFileUpload = async (
dek: await wrapDataKey(dataKey, masterKey.key), dek: await wrapDataKey(dataKey, masterKey.key),
dekVersion: dataKeyVersion.toISOString(), dekVersion: dataKeyVersion.toISOString(),
hskVersion: hmacSecret.version, hskVersion: hmacSecret.version,
contentHmac: encodeToBase64(fileSigned), contentHmac: fileSigned,
contentType: file.type, contentType: file.type,
contentIv: fileEncrypted.iv, contentIv: fileEncrypted.iv,
name: nameEncrypted.ciphertext, name: nameEncrypted.ciphertext,

View File

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