diff --git a/src/lib/components/buttons/Button.svelte b/src/lib/components/buttons/Button.svelte index 692d4a1..65f80ab 100644 --- a/src/lib/components/buttons/Button.svelte +++ b/src/lib/components/buttons/Button.svelte @@ -29,7 +29,7 @@ onclick?.(); }, 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" >
{@render children?.()} diff --git a/src/lib/server/db/file.ts b/src/lib/server/db/file.ts index 1ce230c..db6b881 100644 --- a/src/lib/server/db/file.ts +++ b/src/lib/server/db/file.ts @@ -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) => { const res = await db .select() diff --git a/src/lib/server/schemas/file.ts b/src/lib/server/schemas/file.ts index 958c4d7..f73b299 100644 --- a/src/lib/server/schemas/file.ts +++ b/src/lib/server/schemas/file.ts @@ -22,6 +22,17 @@ export const fileRenameRequest = z.object({ }); export type FileRenameRequest = z.infer; +export const duplicateFileScanRequest = z.object({ + hskVersion: z.number().int().positive(), + contentHmac: z.string().base64().nonempty(), +}); +export type DuplicateFileScanRequest = z.infer; + +export const duplicateFileScanResponse = z.object({ + files: z.number().int().positive().array(), +}); +export type DuplicateFileScanResponse = z.infer; + export const fileUploadRequest = z.object({ parentId: z.union([z.enum(["root"]), z.number().int().positive()]), mekVersion: z.number().int().positive(), diff --git a/src/lib/server/services/file.ts b/src/lib/server/services/file.ts index b342b90..7599939 100644 --- a/src/lib/server/services/file.ts +++ b/src/lib/server/services/file.ts @@ -7,6 +7,7 @@ import { v4 as uuidv4 } from "uuid"; import { IntegrityError } from "$lib/server/db/error"; import { registerFile, + getAllFileIdsByContentHmac, getFile, setFileEncName, 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) => { await unlink(path).catch(console.error); }; diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index a868e8a..5361fd7 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -11,10 +11,12 @@ import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte"; import DirectoryEntries from "./DirectoryEntries"; import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte"; + import DuplicateFileModal from "./DuplicateFileModal.svelte"; import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte"; import { requestHmacSecretDownload, requestDirectoryCreation, + requestDuplicateFileScan, requestFileUpload, requestDirectoryEntryRename, requestDirectoryEntryDeletion, @@ -23,14 +25,22 @@ import IconAdd from "~icons/material-symbols/add"; + interface LoadedFile { + file: File; + fileBuffer: ArrayBuffer; + fileSigned: string; + } + let { data } = $props(); let info: Writable | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state(); + let loadedFile: LoadedFile | undefined = $state(); let selectedEntry: SelectedDirectoryEntry | undefined = $state(); let isCreateBottomSheetOpen = $state(false); let isCreateDirectoryModalOpen = $state(false); + let isDuplicateFileModalOpen = $state(false); let isDirectoryEntryMenuBottomSheetOpen = $state(false); let isRenameDirectoryEntryModalOpen = $state(false); @@ -42,15 +52,34 @@ 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]; if (!file) return; - requestFileUpload(file, data.id, $masterKeyStore?.get(1)!, $hmacSecretStore?.get(1)!).then( - () => { - info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - }, - ); + fileInput!.value = ""; + + 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 () => { @@ -68,7 +97,7 @@ 파일 - +
{#if data.id !== "root"} @@ -109,6 +138,18 @@ }} /> + { + isDuplicateFileModalOpen = false; + loadedFile = undefined; + }} + onDuplicateClick={() => { + uploadFile(loadedFile!); + isDuplicateFileModalOpen = false; + loadedFile = undefined; + }} +/> + 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(); + + + +
+
+

이미 업로드된 파일이에요.

+

그래도 업로드할까요?

+
+
+ + +
+
+
diff --git a/src/routes/(main)/directory/[[id]]/service.ts b/src/routes/(main)/directory/[[id]]/service.ts index b237ae5..32575fb 100644 --- a/src/routes/(main)/directory/[[id]]/service.ts +++ b/src/routes/(main)/directory/[[id]]/service.ts @@ -15,6 +15,8 @@ import type { FileRenameRequest, FileUploadRequest, HmacSecretListResponse, + DuplicateFileScanRequest, + DuplicateFileScanResponse, } from "$lib/server/schemas"; 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("/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 ( file: File, + fileBuffer: ArrayBuffer, + fileSigned: string, parentId: "root" | number, masterKey: MasterKey, hmacSecret: HmacSecret, ) => { const { dataKey, dataKeyVersion } = await generateDataKey(); 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 form = new FormData(); @@ -85,7 +103,7 @@ export const requestFileUpload = async ( dek: await wrapDataKey(dataKey, masterKey.key), dekVersion: dataKeyVersion.toISOString(), hskVersion: hmacSecret.version, - contentHmac: encodeToBase64(fileSigned), + contentHmac: fileSigned, contentType: file.type, contentIv: fileEncrypted.iv, name: nameEncrypted.ciphertext, diff --git a/src/routes/api/file/scanDuplicates/+server.ts b/src/routes/api/file/scanDuplicates/+server.ts new file mode 100644 index 0000000..fb41b43 --- /dev/null +++ b/src/routes/api/file/scanDuplicates/+server.ts @@ -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)); +};