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));
+};