mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-15 22:38:47 +00:00
프론트엔드에서의 파일 업로드 전 중복 검사 구현
This commit is contained in:
@@ -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?.()}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
32
src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte
Normal file
32
src/routes/(main)/directory/[[id]]/DuplicateFileModal.svelte
Normal 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>
|
||||||
@@ -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,
|
||||||
|
|||||||
20
src/routes/api/file/scanDuplicates/+server.ts
Normal file
20
src/routes/api/file/scanDuplicates/+server.ts
Normal 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));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user