mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-15 22:38:47 +00:00
Merge branch 'dev' into add-file-category
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { ClientInit } from "@sveltejs/kit";
|
||||
import { getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
|
||||
import { cleanupDanglingInfos, getClientKey, getMasterKeys, getHmacSecrets } from "$lib/indexedDB";
|
||||
import { prepareFileCache } from "$lib/modules/file";
|
||||
import { prepareOpfs } from "$lib/modules/opfs";
|
||||
import { clientKeyStore, masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||
|
||||
const prepareClientKeyStore = async () => {
|
||||
@@ -29,5 +31,13 @@ const prepareHmacSecretStore = async () => {
|
||||
};
|
||||
|
||||
export const init: ClientInit = async () => {
|
||||
await Promise.all([prepareClientKeyStore(), prepareMasterKeyStore(), prepareHmacSecretStore()]);
|
||||
await Promise.all([
|
||||
prepareFileCache(),
|
||||
prepareClientKeyStore(),
|
||||
prepareMasterKeyStore(),
|
||||
prepareHmacSecretStore(),
|
||||
prepareOpfs(),
|
||||
]);
|
||||
|
||||
cleanupDanglingInfos(); // Intended
|
||||
};
|
||||
|
||||
28
src/lib/indexedDB/cacheIndex.ts
Normal file
28
src/lib/indexedDB/cacheIndex.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Dexie, type EntityTable } from "dexie";
|
||||
|
||||
export interface FileCacheIndex {
|
||||
fileId: number;
|
||||
cachedAt: Date;
|
||||
lastRetrievedAt: Date;
|
||||
size: number;
|
||||
}
|
||||
|
||||
const cacheIndex = new Dexie("cacheIndex") as Dexie & {
|
||||
fileCache: EntityTable<FileCacheIndex, "fileId">;
|
||||
};
|
||||
|
||||
cacheIndex.version(1).stores({
|
||||
fileCache: "fileId",
|
||||
});
|
||||
|
||||
export const getFileCacheIndex = async () => {
|
||||
return await cacheIndex.fileCache.toArray();
|
||||
};
|
||||
|
||||
export const storeFileCacheIndex = async (fileCacheIndex: FileCacheIndex) => {
|
||||
await cacheIndex.fileCache.put(fileCacheIndex);
|
||||
};
|
||||
|
||||
export const deleteFileCacheIndex = async (fileId: number) => {
|
||||
await cacheIndex.fileCache.delete(fileId);
|
||||
};
|
||||
86
src/lib/indexedDB/filesystem.ts
Normal file
86
src/lib/indexedDB/filesystem.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Dexie, type EntityTable } from "dexie";
|
||||
|
||||
export type DirectoryId = "root" | number;
|
||||
|
||||
interface DirectoryInfo {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface FileInfo {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
name: string;
|
||||
contentType: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
}
|
||||
|
||||
const filesystem = new Dexie("filesystem") as Dexie & {
|
||||
directory: EntityTable<DirectoryInfo, "id">;
|
||||
file: EntityTable<FileInfo, "id">;
|
||||
};
|
||||
|
||||
filesystem.version(1).stores({
|
||||
directory: "id, parentId",
|
||||
file: "id, parentId",
|
||||
});
|
||||
|
||||
export const getDirectoryInfos = async (parentId: DirectoryId) => {
|
||||
return await filesystem.directory.where({ parentId }).toArray();
|
||||
};
|
||||
|
||||
export const getDirectoryInfo = async (id: number) => {
|
||||
return await filesystem.directory.get(id);
|
||||
};
|
||||
|
||||
export const storeDirectoryInfo = async (directoryInfo: DirectoryInfo) => {
|
||||
await filesystem.directory.put(directoryInfo);
|
||||
};
|
||||
|
||||
export const deleteDirectoryInfo = async (id: number) => {
|
||||
await filesystem.directory.delete(id);
|
||||
};
|
||||
|
||||
export const getFileInfos = async (parentId: DirectoryId) => {
|
||||
return await filesystem.file.where({ parentId }).toArray();
|
||||
};
|
||||
|
||||
export const getFileInfo = async (id: number) => {
|
||||
return await filesystem.file.get(id);
|
||||
};
|
||||
|
||||
export const storeFileInfo = async (fileInfo: FileInfo) => {
|
||||
await filesystem.file.put(fileInfo);
|
||||
};
|
||||
|
||||
export const deleteFileInfo = async (id: number) => {
|
||||
await filesystem.file.delete(id);
|
||||
};
|
||||
|
||||
export const cleanupDanglingInfos = async () => {
|
||||
const validDirectoryIds: number[] = [];
|
||||
const validFileIds: number[] = [];
|
||||
const queue: DirectoryId[] = ["root"];
|
||||
|
||||
while (true) {
|
||||
const directoryId = queue.shift();
|
||||
if (!directoryId) break;
|
||||
|
||||
const [subDirectories, files] = await Promise.all([
|
||||
filesystem.directory.where({ parentId: directoryId }).toArray(),
|
||||
filesystem.file.where({ parentId: directoryId }).toArray(),
|
||||
]);
|
||||
subDirectories.forEach(({ id }) => {
|
||||
validDirectoryIds.push(id);
|
||||
queue.push(id);
|
||||
});
|
||||
files.forEach(({ id }) => validFileIds.push(id));
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
filesystem.directory.where("id").noneOf(validDirectoryIds).delete(),
|
||||
filesystem.file.where("id").noneOf(validFileIds).delete(),
|
||||
]);
|
||||
};
|
||||
3
src/lib/indexedDB/index.ts
Normal file
3
src/lib/indexedDB/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./cacheIndex";
|
||||
export * from "./filesystem";
|
||||
export * from "./keyStore";
|
||||
@@ -1,98 +0,0 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { callGetApi } from "$lib/hooks";
|
||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
|
||||
import {
|
||||
directoryInfoStore,
|
||||
fileInfoStore,
|
||||
type DirectoryInfo,
|
||||
type FileInfo,
|
||||
} from "$lib/stores/file";
|
||||
|
||||
const fetchDirectoryInfo = async (
|
||||
directoryId: "root" | number,
|
||||
masterKey: CryptoKey,
|
||||
infoStore: Writable<DirectoryInfo | null>,
|
||||
) => {
|
||||
const res = await callGetApi(`/api/directory/${directoryId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch directory information");
|
||||
const { metadata, subDirectories, files }: DirectoryInfoResponse = await res.json();
|
||||
|
||||
let newInfo: DirectoryInfo;
|
||||
if (directoryId === "root") {
|
||||
newInfo = {
|
||||
id: "root",
|
||||
subDirectoryIds: subDirectories,
|
||||
fileIds: files,
|
||||
};
|
||||
} else {
|
||||
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
||||
newInfo = {
|
||||
id: directoryId,
|
||||
dataKey,
|
||||
dataKeyVersion: new Date(metadata!.dekVersion),
|
||||
name: await decryptString(metadata!.name, metadata!.nameIv, dataKey),
|
||||
subDirectoryIds: subDirectories,
|
||||
fileIds: files,
|
||||
};
|
||||
}
|
||||
|
||||
infoStore.update(() => newInfo);
|
||||
};
|
||||
|
||||
export const getDirectoryInfo = (directoryId: "root" | number, masterKey: CryptoKey) => {
|
||||
// TODO: MEK rotation
|
||||
|
||||
let info = directoryInfoStore.get(directoryId);
|
||||
if (!info) {
|
||||
info = writable(null);
|
||||
directoryInfoStore.set(directoryId, info);
|
||||
}
|
||||
|
||||
fetchDirectoryInfo(directoryId, masterKey, info);
|
||||
return info;
|
||||
};
|
||||
|
||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||
};
|
||||
|
||||
const fetchFileInfo = async (
|
||||
fileId: number,
|
||||
masterKey: CryptoKey,
|
||||
infoStore: Writable<FileInfo | null>,
|
||||
) => {
|
||||
const res = await callGetApi(`/api/file/${fileId}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch file information");
|
||||
const metadata: FileInfoResponse = await res.json();
|
||||
|
||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||
const newInfo: FileInfo = {
|
||||
id: fileId,
|
||||
dataKey,
|
||||
dataKeyVersion: new Date(metadata.dekVersion),
|
||||
contentType: metadata.contentType,
|
||||
contentIv: metadata.contentIv,
|
||||
name: await decryptString(metadata.name, metadata.nameIv, dataKey),
|
||||
createdAt:
|
||||
metadata.createdAt && metadata.createdAtIv
|
||||
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
|
||||
: undefined,
|
||||
lastModifiedAt: await decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
||||
};
|
||||
|
||||
infoStore.update(() => newInfo);
|
||||
};
|
||||
|
||||
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
|
||||
// TODO: MEK rotation
|
||||
|
||||
let info = fileInfoStore.get(fileId);
|
||||
if (!info) {
|
||||
info = writable(null);
|
||||
fileInfoStore.set(fileId, info);
|
||||
}
|
||||
|
||||
fetchFileInfo(fileId, masterKey, info);
|
||||
return info;
|
||||
};
|
||||
50
src/lib/modules/file/cache.ts
Normal file
50
src/lib/modules/file/cache.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
|
||||
storeFileCacheIndex,
|
||||
deleteFileCacheIndex,
|
||||
type FileCacheIndex,
|
||||
} from "$lib/indexedDB";
|
||||
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
|
||||
|
||||
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
||||
|
||||
export const prepareFileCache = async () => {
|
||||
for (const cache of await getFileCacheIndexFromIndexedDB()) {
|
||||
fileCacheIndex.set(cache.fileId, cache);
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileCacheIndex = () => {
|
||||
return Array.from(fileCacheIndex.values());
|
||||
};
|
||||
|
||||
export const getFileCache = async (fileId: number) => {
|
||||
const cacheIndex = fileCacheIndex.get(fileId);
|
||||
if (!cacheIndex) return null;
|
||||
|
||||
cacheIndex.lastRetrievedAt = new Date();
|
||||
storeFileCacheIndex(cacheIndex); // Intended
|
||||
return await readFile(`/cache/${fileId}`);
|
||||
};
|
||||
|
||||
export const storeFileCache = async (fileId: number, fileBuffer: ArrayBuffer) => {
|
||||
const now = new Date();
|
||||
await writeFile(`/cache/${fileId}`, fileBuffer);
|
||||
|
||||
const cacheIndex: FileCacheIndex = {
|
||||
fileId,
|
||||
cachedAt: now,
|
||||
lastRetrievedAt: now,
|
||||
size: fileBuffer.byteLength,
|
||||
};
|
||||
fileCacheIndex.set(fileId, cacheIndex);
|
||||
await storeFileCacheIndex(cacheIndex);
|
||||
};
|
||||
|
||||
export const deleteFileCache = async (fileId: number) => {
|
||||
if (!fileCacheIndex.has(fileId)) return;
|
||||
|
||||
fileCacheIndex.delete(fileId);
|
||||
await deleteFile(`/cache/${fileId}`);
|
||||
await deleteFileCacheIndex(fileId);
|
||||
};
|
||||
84
src/lib/modules/file/download.ts
Normal file
84
src/lib/modules/file/download.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import axios from "axios";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { decryptData } from "$lib/modules/crypto";
|
||||
import { fileDownloadStatusStore, type FileDownloadStatus } from "$lib/stores";
|
||||
|
||||
const requestFileDownload = limitFunction(
|
||||
async (status: Writable<FileDownloadStatus>, id: number) => {
|
||||
status.update((value) => {
|
||||
value.status = "downloading";
|
||||
return value;
|
||||
});
|
||||
|
||||
const res = await axios.get(`/api/file/${id}/download`, {
|
||||
responseType: "arraybuffer",
|
||||
onDownloadProgress: ({ progress, rate, estimated }) => {
|
||||
status.update((value) => {
|
||||
value.progress = progress;
|
||||
value.rate = rate;
|
||||
value.estimated = estimated;
|
||||
return value;
|
||||
});
|
||||
},
|
||||
});
|
||||
const fileEncrypted: ArrayBuffer = res.data;
|
||||
|
||||
status.update((value) => {
|
||||
value.status = "decryption-pending";
|
||||
return value;
|
||||
});
|
||||
return fileEncrypted;
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
|
||||
const decryptFile = limitFunction(
|
||||
async (
|
||||
status: Writable<FileDownloadStatus>,
|
||||
fileEncrypted: ArrayBuffer,
|
||||
fileEncryptedIv: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
status.update((value) => {
|
||||
value.status = "decrypting";
|
||||
return value;
|
||||
});
|
||||
|
||||
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
|
||||
|
||||
status.update((value) => {
|
||||
value.status = "decrypted";
|
||||
value.result = fileBuffer;
|
||||
return value;
|
||||
});
|
||||
return fileBuffer;
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
|
||||
const status = writable<FileDownloadStatus>({
|
||||
id,
|
||||
status: "download-pending",
|
||||
});
|
||||
fileDownloadStatusStore.update((value) => {
|
||||
value.push(status);
|
||||
return value;
|
||||
});
|
||||
|
||||
try {
|
||||
return await decryptFile(
|
||||
status,
|
||||
await requestFileDownload(status, id),
|
||||
fileEncryptedIv,
|
||||
dataKey,
|
||||
);
|
||||
} catch (e) {
|
||||
status.update((value) => {
|
||||
value.status = "error";
|
||||
return value;
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
3
src/lib/modules/file/index.ts
Normal file
3
src/lib/modules/file/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./cache";
|
||||
export * from "./download";
|
||||
export * from "./upload";
|
||||
231
src/lib/modules/file/upload.ts
Normal file
231
src/lib/modules/file/upload.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import axios from "axios";
|
||||
import ExifReader from "exifreader";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import {
|
||||
encodeToBase64,
|
||||
generateDataKey,
|
||||
wrapDataKey,
|
||||
encryptData,
|
||||
encryptString,
|
||||
digestMessage,
|
||||
signMessageHmac,
|
||||
} from "$lib/modules/crypto";
|
||||
import type {
|
||||
DuplicateFileScanRequest,
|
||||
DuplicateFileScanResponse,
|
||||
FileUploadRequest,
|
||||
} from "$lib/server/schemas";
|
||||
import {
|
||||
fileUploadStatusStore,
|
||||
type MasterKey,
|
||||
type HmacSecret,
|
||||
type FileUploadStatus,
|
||||
} from "$lib/stores";
|
||||
|
||||
const requestDuplicateFileScan = limitFunction(
|
||||
async (file: File, hmacSecret: HmacSecret, onDuplicate: () => Promise<boolean>) => {
|
||||
const fileBuffer = await file.arrayBuffer();
|
||||
const fileSigned = encodeToBase64(await signMessageHmac(fileBuffer, hmacSecret.secret));
|
||||
|
||||
const res = await axios.post("/api/file/scanDuplicates", {
|
||||
hskVersion: hmacSecret.version,
|
||||
contentHmac: fileSigned,
|
||||
} satisfies DuplicateFileScanRequest);
|
||||
const { files }: DuplicateFileScanResponse = res.data;
|
||||
|
||||
if (files.length === 0 || (await onDuplicate())) {
|
||||
return { fileBuffer, fileSigned };
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
|
||||
const getFileType = (file: File) => {
|
||||
if (file.type) return file.type;
|
||||
if (file.name.endsWith(".heic")) return "image/heic";
|
||||
throw new Error("Unknown file type");
|
||||
};
|
||||
|
||||
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||
const exif = ExifReader.load(fileBuffer);
|
||||
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
||||
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
||||
if (!dateTimeOriginal) return undefined;
|
||||
|
||||
const [date, time] = dateTimeOriginal.split(" ");
|
||||
if (!date || !time) return undefined;
|
||||
|
||||
const [year, month, day] = date.split(":").map(Number);
|
||||
const [hour, minute, second] = time.split(":").map(Number);
|
||||
if (!year || !month || !day || !hour || !minute || !second) return undefined;
|
||||
|
||||
if (!offsetTimeOriginal) {
|
||||
// No timezone information.. Assume local timezone
|
||||
return new Date(year, month - 1, day, hour, minute, second);
|
||||
}
|
||||
|
||||
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
||||
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
||||
|
||||
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
||||
return new Date(utcDate - offsetMs);
|
||||
};
|
||||
|
||||
const encryptFile = limitFunction(
|
||||
async (
|
||||
status: Writable<FileUploadStatus>,
|
||||
file: File,
|
||||
fileBuffer: ArrayBuffer,
|
||||
masterKey: MasterKey,
|
||||
) => {
|
||||
status.update((value) => {
|
||||
value.status = "encrypting";
|
||||
return value;
|
||||
});
|
||||
|
||||
const fileType = getFileType(file);
|
||||
|
||||
let createdAt;
|
||||
if (fileType.startsWith("image/")) {
|
||||
createdAt = extractExifDateTime(fileBuffer);
|
||||
}
|
||||
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
const dataKeyWrapped = await wrapDataKey(dataKey, masterKey.key);
|
||||
|
||||
const fileEncrypted = await encryptData(fileBuffer, dataKey);
|
||||
const fileEncryptedHash = encodeToBase64(await digestMessage(fileEncrypted.ciphertext));
|
||||
|
||||
const nameEncrypted = await encryptString(file.name, dataKey);
|
||||
const createdAtEncrypted =
|
||||
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
|
||||
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
||||
|
||||
status.update((value) => {
|
||||
value.status = "upload-pending";
|
||||
return value;
|
||||
});
|
||||
|
||||
return {
|
||||
dataKeyWrapped,
|
||||
dataKeyVersion,
|
||||
fileType,
|
||||
fileEncrypted,
|
||||
fileEncryptedHash,
|
||||
nameEncrypted,
|
||||
createdAtEncrypted,
|
||||
lastModifiedAtEncrypted,
|
||||
};
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
const requestFileUpload = limitFunction(
|
||||
async (status: Writable<FileUploadStatus>, form: FormData) => {
|
||||
status.update((value) => {
|
||||
value.status = "uploading";
|
||||
return value;
|
||||
});
|
||||
|
||||
await axios.post("/api/file/upload", form, {
|
||||
onUploadProgress: ({ progress, rate, estimated }) => {
|
||||
status.update((value) => {
|
||||
value.progress = progress;
|
||||
value.rate = rate;
|
||||
value.estimated = estimated;
|
||||
return value;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
status.update((value) => {
|
||||
value.status = "uploaded";
|
||||
return value;
|
||||
});
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
|
||||
export const uploadFile = async (
|
||||
file: File,
|
||||
parentId: "root" | number,
|
||||
hmacSecret: HmacSecret,
|
||||
masterKey: MasterKey,
|
||||
onDuplicate: () => Promise<boolean>,
|
||||
) => {
|
||||
const status = writable<FileUploadStatus>({
|
||||
name: file.name,
|
||||
parentId,
|
||||
status: "encryption-pending",
|
||||
});
|
||||
fileUploadStatusStore.update((value) => {
|
||||
value.push(status);
|
||||
return value;
|
||||
});
|
||||
|
||||
try {
|
||||
const { fileBuffer, fileSigned } = await requestDuplicateFileScan(
|
||||
file,
|
||||
hmacSecret,
|
||||
onDuplicate,
|
||||
);
|
||||
if (!fileBuffer || !fileSigned) {
|
||||
status.update((value) => {
|
||||
value.status = "canceled";
|
||||
return value;
|
||||
});
|
||||
fileUploadStatusStore.update((value) => {
|
||||
value = value.filter((v) => v !== status);
|
||||
return value;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const {
|
||||
dataKeyWrapped,
|
||||
dataKeyVersion,
|
||||
fileType,
|
||||
fileEncrypted,
|
||||
fileEncryptedHash,
|
||||
nameEncrypted,
|
||||
createdAtEncrypted,
|
||||
lastModifiedAtEncrypted,
|
||||
} = await encryptFile(status, file, fileBuffer, masterKey);
|
||||
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
parent: parentId,
|
||||
mekVersion: masterKey.version,
|
||||
dek: dataKeyWrapped,
|
||||
dekVersion: dataKeyVersion.toISOString(),
|
||||
hskVersion: hmacSecret.version,
|
||||
contentHmac: fileSigned,
|
||||
contentType: fileType,
|
||||
contentIv: fileEncrypted.iv,
|
||||
name: nameEncrypted.ciphertext,
|
||||
nameIv: nameEncrypted.iv,
|
||||
createdAt: createdAtEncrypted?.ciphertext,
|
||||
createdAtIv: createdAtEncrypted?.iv,
|
||||
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
||||
} as FileUploadRequest),
|
||||
);
|
||||
form.set("content", new Blob([fileEncrypted.ciphertext]));
|
||||
form.set("checksum", fileEncryptedHash);
|
||||
|
||||
await requestFileUpload(status, form);
|
||||
return true;
|
||||
} catch (e) {
|
||||
status.update((value) => {
|
||||
value.status = "error";
|
||||
return value;
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
208
src/lib/modules/filesystem.ts
Normal file
208
src/lib/modules/filesystem.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import { callGetApi } from "$lib/hooks";
|
||||
import {
|
||||
getDirectoryInfos as getDirectoryInfosFromIndexedDB,
|
||||
getDirectoryInfo as getDirectoryInfoFromIndexedDB,
|
||||
storeDirectoryInfo,
|
||||
deleteDirectoryInfo,
|
||||
getFileInfos as getFileInfosFromIndexedDB,
|
||||
getFileInfo as getFileInfoFromIndexedDB,
|
||||
storeFileInfo,
|
||||
deleteFileInfo,
|
||||
type DirectoryId,
|
||||
} from "$lib/indexedDB";
|
||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||
import type { DirectoryInfoResponse, FileInfoResponse } from "$lib/server/schemas";
|
||||
|
||||
export type DirectoryInfo =
|
||||
| {
|
||||
id: "root";
|
||||
dataKey?: undefined;
|
||||
dataKeyVersion?: undefined;
|
||||
name?: undefined;
|
||||
subDirectoryIds: number[];
|
||||
fileIds: number[];
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
dataKey?: CryptoKey;
|
||||
dataKeyVersion?: Date;
|
||||
name: string;
|
||||
subDirectoryIds: number[];
|
||||
fileIds: number[];
|
||||
};
|
||||
|
||||
export interface FileInfo {
|
||||
id: number;
|
||||
dataKey?: CryptoKey;
|
||||
dataKeyVersion?: Date;
|
||||
contentType: string;
|
||||
contentIv?: string;
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
}
|
||||
|
||||
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
|
||||
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
||||
|
||||
const fetchDirectoryInfoFromIndexedDB = async (
|
||||
id: DirectoryId,
|
||||
info: Writable<DirectoryInfo | null>,
|
||||
) => {
|
||||
if (get(info)) return;
|
||||
|
||||
const [directory, subDirectories, files] = await Promise.all([
|
||||
id !== "root" ? getDirectoryInfoFromIndexedDB(id) : undefined,
|
||||
getDirectoryInfosFromIndexedDB(id),
|
||||
getFileInfosFromIndexedDB(id),
|
||||
]);
|
||||
const subDirectoryIds = subDirectories.map(({ id }) => id);
|
||||
const fileIds = files.map(({ id }) => id);
|
||||
|
||||
if (id === "root") {
|
||||
info.set({ id, subDirectoryIds, fileIds });
|
||||
} else {
|
||||
if (!directory) return;
|
||||
info.set({ id, name: directory.name, subDirectoryIds, fileIds });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDirectoryInfoFromServer = async (
|
||||
id: DirectoryId,
|
||||
info: Writable<DirectoryInfo | null>,
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const res = await callGetApi(`/api/directory/${id}`);
|
||||
if (res.status === 404) {
|
||||
info.set(null);
|
||||
await deleteDirectoryInfo(id as number);
|
||||
return;
|
||||
} else if (!res.ok) {
|
||||
throw new Error("Failed to fetch directory information");
|
||||
}
|
||||
|
||||
const {
|
||||
metadata,
|
||||
subDirectories: subDirectoryIds,
|
||||
files: fileIds,
|
||||
}: DirectoryInfoResponse = await res.json();
|
||||
|
||||
if (id === "root") {
|
||||
info.set({ id, subDirectoryIds, fileIds });
|
||||
} else {
|
||||
const { dataKey } = await unwrapDataKey(metadata!.dek, masterKey);
|
||||
const name = await decryptString(metadata!.name, metadata!.nameIv, dataKey);
|
||||
|
||||
info.set({
|
||||
id,
|
||||
dataKey,
|
||||
dataKeyVersion: new Date(metadata!.dekVersion),
|
||||
name,
|
||||
subDirectoryIds,
|
||||
fileIds,
|
||||
});
|
||||
await storeDirectoryInfo({ id, parentId: metadata!.parent, name });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchDirectoryInfo = async (
|
||||
id: DirectoryId,
|
||||
info: Writable<DirectoryInfo | null>,
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
await fetchDirectoryInfoFromIndexedDB(id, info);
|
||||
await fetchDirectoryInfoFromServer(id, info, masterKey);
|
||||
};
|
||||
|
||||
export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => {
|
||||
// TODO: MEK rotation
|
||||
|
||||
let info = directoryInfoStore.get(id);
|
||||
if (!info) {
|
||||
info = writable(null);
|
||||
directoryInfoStore.set(id, info);
|
||||
}
|
||||
|
||||
fetchDirectoryInfo(id, info, masterKey);
|
||||
return info;
|
||||
};
|
||||
|
||||
const fetchFileInfoFromIndexedDB = async (id: number, info: Writable<FileInfo | null>) => {
|
||||
if (get(info)) return;
|
||||
|
||||
const file = await getFileInfoFromIndexedDB(id);
|
||||
if (!file) return;
|
||||
|
||||
info.set(file);
|
||||
};
|
||||
|
||||
const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) => {
|
||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||
};
|
||||
|
||||
const fetchFileInfoFromServer = async (
|
||||
id: number,
|
||||
info: Writable<FileInfo | null>,
|
||||
masterKey: CryptoKey,
|
||||
) => {
|
||||
const res = await callGetApi(`/api/file/${id}`);
|
||||
if (res.status === 404) {
|
||||
info.set(null);
|
||||
await deleteFileInfo(id);
|
||||
return;
|
||||
} else if (!res.ok) {
|
||||
throw new Error("Failed to fetch file information");
|
||||
}
|
||||
|
||||
const metadata: FileInfoResponse = await res.json();
|
||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||
|
||||
const name = await decryptString(metadata.name, metadata.nameIv, dataKey);
|
||||
const createdAt =
|
||||
metadata.createdAt && metadata.createdAtIv
|
||||
? await decryptDate(metadata.createdAt, metadata.createdAtIv, dataKey)
|
||||
: undefined;
|
||||
const lastModifiedAt = await decryptDate(
|
||||
metadata.lastModifiedAt,
|
||||
metadata.lastModifiedAtIv,
|
||||
dataKey,
|
||||
);
|
||||
|
||||
info.set({
|
||||
id,
|
||||
dataKey,
|
||||
dataKeyVersion: new Date(metadata.dekVersion),
|
||||
contentType: metadata.contentType,
|
||||
contentIv: metadata.contentIv,
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
});
|
||||
await storeFileInfo({
|
||||
id,
|
||||
parentId: metadata.parent,
|
||||
name,
|
||||
contentType: metadata.contentType,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
});
|
||||
};
|
||||
|
||||
const fetchFileInfo = async (id: number, info: Writable<FileInfo | null>, masterKey: CryptoKey) => {
|
||||
await fetchFileInfoFromIndexedDB(id, info);
|
||||
await fetchFileInfoFromServer(id, info, masterKey);
|
||||
};
|
||||
|
||||
export const getFileInfo = (fileId: number, masterKey: CryptoKey) => {
|
||||
// TODO: MEK rotation
|
||||
|
||||
let info = fileInfoStore.get(fileId);
|
||||
if (!info) {
|
||||
info = writable(null);
|
||||
fileInfoStore.set(fileId, info);
|
||||
}
|
||||
|
||||
fetchFileInfo(fileId, info, masterKey);
|
||||
return info;
|
||||
};
|
||||
61
src/lib/modules/opfs.ts
Normal file
61
src/lib/modules/opfs.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
let rootHandle: FileSystemDirectoryHandle | null = null;
|
||||
|
||||
export const prepareOpfs = async () => {
|
||||
rootHandle = await navigator.storage.getDirectory();
|
||||
};
|
||||
|
||||
const getFileHandle = async (path: string, create = true) => {
|
||||
if (!rootHandle) {
|
||||
throw new Error("OPFS not prepared");
|
||||
} else if (path[0] !== "/") {
|
||||
throw new Error("Path must be absolute");
|
||||
}
|
||||
|
||||
const parts = path.split("/");
|
||||
if (parts.length <= 1) {
|
||||
throw new Error("Invalid path");
|
||||
}
|
||||
|
||||
try {
|
||||
let directoryHandle = rootHandle;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
if (!part) continue;
|
||||
directoryHandle = await directoryHandle.getDirectoryHandle(part, { create });
|
||||
}
|
||||
|
||||
const filename = parts[parts.length - 1]!;
|
||||
const fileHandle = await directoryHandle.getFileHandle(filename, { create });
|
||||
return { parentHandle: directoryHandle, filename, fileHandle };
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "NotFoundError") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const readFile = async (path: string) => {
|
||||
const { fileHandle } = await getFileHandle(path, false);
|
||||
if (!fileHandle) return null;
|
||||
|
||||
const file = await fileHandle.getFile();
|
||||
return await file.arrayBuffer();
|
||||
};
|
||||
|
||||
export const writeFile = async (path: string, data: ArrayBuffer) => {
|
||||
const { fileHandle } = await getFileHandle(path);
|
||||
const writable = await fileHandle!.createWritable();
|
||||
|
||||
try {
|
||||
await writable.write(data);
|
||||
} finally {
|
||||
await writable.close();
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFile = async (path: string) => {
|
||||
const { parentHandle, filename } = await getFileHandle(path, false);
|
||||
if (!parentHandle) return;
|
||||
|
||||
await parentHandle.removeEntry(filename);
|
||||
};
|
||||
29
src/lib/modules/util.ts
Normal file
29
src/lib/modules/util.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
const pad2 = (num: number) => num.toString().padStart(2, "0");
|
||||
|
||||
export const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
return `${year}. ${month}. ${day}.`;
|
||||
};
|
||||
|
||||
export const formatDateTime = (date: Date) => {
|
||||
const dateFormatted = formatDate(date);
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
return `${dateFormatted} ${pad2(hours)}:${pad2(minutes)}`;
|
||||
};
|
||||
|
||||
export const formatFileSize = (size: number) => {
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KiB`;
|
||||
if (size < 1024 * 1024 * 1024) return `${(size / 1024 / 1024).toFixed(1)} MiB`;
|
||||
return `${(size / 1024 / 1024 / 1024).toFixed(1)} GiB`;
|
||||
};
|
||||
|
||||
export const formatNetworkSpeed = (speed: number) => {
|
||||
if (speed < 1000) return `${speed} bps`;
|
||||
if (speed < 1000 * 1000) return `${(speed / 1000).toFixed(1)} kbps`;
|
||||
if (speed < 1000 * 1000 * 1000) return `${(speed / 1000 / 1000).toFixed(1)} Mbps`;
|
||||
return `${(speed / 1000 / 1000 / 1000).toFixed(1)} Gbps`;
|
||||
};
|
||||
@@ -27,6 +27,7 @@ export interface NewFileParams {
|
||||
contentHmac: string | null;
|
||||
contentType: string;
|
||||
encContentIv: string;
|
||||
encContentHash: string;
|
||||
encName: string;
|
||||
encNameIv: string;
|
||||
encCreatedAt: string | null;
|
||||
@@ -130,14 +131,15 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
|
||||
return await db.transaction(
|
||||
async (tx) => {
|
||||
const unregisterFiles = async (parentId: number) => {
|
||||
const files = await tx
|
||||
return await tx
|
||||
.delete(file)
|
||||
.where(and(eq(file.userId, userId), eq(file.parentId, parentId)))
|
||||
.returning({ path: file.path });
|
||||
return files.map(({ path }) => path);
|
||||
.returning({ id: file.id, path: file.path });
|
||||
};
|
||||
const unregisterDirectoryRecursively = async (directoryId: number): Promise<string[]> => {
|
||||
const filePaths = await unregisterFiles(directoryId);
|
||||
const unregisterDirectoryRecursively = async (
|
||||
directoryId: number,
|
||||
): Promise<{ id: number; path: string }[]> => {
|
||||
const files = await unregisterFiles(directoryId);
|
||||
const subDirectories = await tx
|
||||
.select({ id: directory.id })
|
||||
.from(directory)
|
||||
@@ -150,7 +152,7 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
|
||||
if (deleteRes.changes === 0) {
|
||||
throw new IntegrityError("Directory not found");
|
||||
}
|
||||
return filePaths.concat(...subDirectoryFilePaths);
|
||||
return files.concat(...subDirectoryFilePaths);
|
||||
};
|
||||
return await unregisterDirectoryRecursively(directoryId);
|
||||
},
|
||||
@@ -198,11 +200,12 @@ export const registerFile = async (params: NewFileParams) => {
|
||||
userId: params.userId,
|
||||
mekVersion: params.mekVersion,
|
||||
hskVersion: params.hskVersion,
|
||||
contentHmac: params.contentHmac,
|
||||
contentType: params.contentType,
|
||||
encDek: params.encDek,
|
||||
dekVersion: params.dekVersion,
|
||||
contentHmac: params.contentHmac,
|
||||
contentType: params.contentType,
|
||||
encContentIv: params.encContentIv,
|
||||
encContentHash: params.encContentHash,
|
||||
encName: { ciphertext: params.encName, iv: params.encNameIv },
|
||||
encCreatedAt:
|
||||
params.encCreatedAt && params.encCreatedAtIv
|
||||
|
||||
@@ -61,6 +61,7 @@ export const file = sqliteTable(
|
||||
contentHmac: text("content_hmac"), // Base64
|
||||
contentType: text("content_type").notNull(),
|
||||
encContentIv: text("encrypted_content_iv").notNull(), // Base64
|
||||
encContentHash: text("encrypted_content_hash").notNull(), // Base64
|
||||
encName: ciphertext("encrypted_name").notNull(),
|
||||
encCreatedAt: ciphertext("encrypted_created_at"),
|
||||
encLastModifiedAt: ciphertext("encrypted_last_modified_at").notNull(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { sqliteTable, text, integer, primaryKey, foreignKey } from "drizzle-orm/sqlite-core";
|
||||
import { client } from "./client";
|
||||
import { mek } from "./mek";
|
||||
import { user } from "./user";
|
||||
|
||||
@@ -32,7 +33,7 @@ export const hskLog = sqliteTable(
|
||||
hskVersion: integer("hmac_secret_key_version").notNull(),
|
||||
timestamp: integer("timestamp", { mode: "timestamp_ms" }).notNull(),
|
||||
action: text("action", { enum: ["create"] }).notNull(),
|
||||
actionBy: integer("action_by").references(() => user.id),
|
||||
actionBy: integer("action_by").references(() => client.id),
|
||||
},
|
||||
(t) => ({
|
||||
ref: foreignKey({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
||||
export const directoryInfoResponse = z.object({
|
||||
metadata: z
|
||||
.object({
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
@@ -15,6 +16,11 @@ export const directoryInfoResponse = z.object({
|
||||
});
|
||||
export type DirectoryInfoResponse = z.infer<typeof directoryInfoResponse>;
|
||||
|
||||
export const directoryDeleteResponse = z.object({
|
||||
deletedFiles: z.number().int().positive().array(),
|
||||
});
|
||||
export type DirectoryDeleteResponse = z.infer<typeof directoryDeleteResponse>;
|
||||
|
||||
export const directoryRenameRequest = z.object({
|
||||
dekVersion: z.string().datetime(),
|
||||
name: z.string().base64().nonempty(),
|
||||
@@ -23,7 +29,7 @@ export const directoryRenameRequest = z.object({
|
||||
export type DirectoryRenameRequest = z.infer<typeof directoryRenameRequest>;
|
||||
|
||||
export const directoryCreateRequest = z.object({
|
||||
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import mime from "mime";
|
||||
import { z } from "zod";
|
||||
|
||||
export const fileInfoResponse = z.object({
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
@@ -38,7 +39,7 @@ export const duplicateFileScanResponse = z.object({
|
||||
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
|
||||
|
||||
export const fileUploadRequest = z.object({
|
||||
parentId: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
parent: z.union([z.enum(["root"]), z.number().int().positive()]),
|
||||
mekVersion: z.number().int().positive(),
|
||||
dek: z.string().base64().nonempty(),
|
||||
dekVersion: z.string().datetime(),
|
||||
|
||||
@@ -19,9 +19,9 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
|
||||
|
||||
const directories = await getAllDirectoriesByParent(userId, directoryId);
|
||||
const files = await getAllFilesByParent(userId, directoryId);
|
||||
|
||||
return {
|
||||
metadata: directory && {
|
||||
parentId: directory.parentId ?? ("root" as const),
|
||||
mekVersion: directory.mekVersion,
|
||||
encDek: directory.encDek,
|
||||
dekVersion: directory.dekVersion,
|
||||
@@ -34,8 +34,13 @@ export const getDirectoryInformation = async (userId: number, directoryId: "root
|
||||
|
||||
export const deleteDirectory = async (userId: number, directoryId: number) => {
|
||||
try {
|
||||
const filePaths = await unregisterDirectory(userId, directoryId);
|
||||
filePaths.map((path) => unlink(path)); // Intended
|
||||
const files = await unregisterDirectory(userId, directoryId);
|
||||
return {
|
||||
files: files.map(({ id, path }) => {
|
||||
unlink(path); // Intended
|
||||
return id;
|
||||
}),
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "Directory not found") {
|
||||
error(404, "Invalid directory id");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { createHash } from "crypto";
|
||||
import { createReadStream, createWriteStream } from "fs";
|
||||
import { mkdir, stat, unlink } from "fs/promises";
|
||||
import { dirname } from "path";
|
||||
import { Readable, Writable } from "stream";
|
||||
import { Readable } from "stream";
|
||||
import { pipeline } from "stream/promises";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { IntegrityError } from "$lib/server/db/error";
|
||||
import {
|
||||
@@ -22,6 +24,7 @@ export const getFileInformation = async (userId: number, fileId: number) => {
|
||||
}
|
||||
|
||||
return {
|
||||
parentId: file.parentId ?? ("root" as const),
|
||||
mekVersion: file.mekVersion,
|
||||
encDek: file.encDek,
|
||||
dekVersion: file.dekVersion,
|
||||
@@ -93,12 +96,13 @@ const safeUnlink = async (path: string) => {
|
||||
};
|
||||
|
||||
export const uploadFile = async (
|
||||
params: Omit<NewFileParams, "path">,
|
||||
encContentStream: ReadableStream<Uint8Array>,
|
||||
params: Omit<NewFileParams, "path" | "encContentHash">,
|
||||
encContentStream: Readable,
|
||||
encContentHash: Promise<string>,
|
||||
) => {
|
||||
const oneMinuteAgo = new Date(Date.now() - 60 * 1000);
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
const oneMinuteLater = new Date(Date.now() + 60 * 1000);
|
||||
if (params.dekVersion <= oneMinuteAgo || params.dekVersion >= oneMinuteLater) {
|
||||
if (params.dekVersion <= oneDayAgo || params.dekVersion >= oneMinuteLater) {
|
||||
error(400, "Invalid DEK version");
|
||||
}
|
||||
|
||||
@@ -106,20 +110,39 @@ export const uploadFile = async (
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
|
||||
try {
|
||||
await encContentStream.pipeTo(
|
||||
Writable.toWeb(createWriteStream(path, { flags: "wx", mode: 0o600 })),
|
||||
);
|
||||
const hashStream = createHash("sha256");
|
||||
const [_, hash] = await Promise.all([
|
||||
pipeline(
|
||||
encContentStream,
|
||||
async function* (source) {
|
||||
for await (const chunk of source) {
|
||||
hashStream.update(chunk);
|
||||
yield chunk;
|
||||
}
|
||||
},
|
||||
createWriteStream(path, { flags: "wx", mode: 0o600 }),
|
||||
),
|
||||
encContentHash,
|
||||
]);
|
||||
if (hashStream.digest("base64") != hash) {
|
||||
throw new Error("Invalid checksum");
|
||||
}
|
||||
|
||||
await registerFile({
|
||||
...params,
|
||||
path,
|
||||
encContentHash: hash,
|
||||
});
|
||||
} catch (e) {
|
||||
await safeUnlink(path);
|
||||
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "Inactive MEK version") {
|
||||
error(400, "Invalid MEK version");
|
||||
}
|
||||
if (e instanceof IntegrityError && e.message === "Inactive MEK version") {
|
||||
error(400, "Invalid MEK version");
|
||||
} else if (
|
||||
e instanceof Error &&
|
||||
(e.message === "Invalid request body" || e.message === "Invalid checksum")
|
||||
) {
|
||||
error(400, "Invalid request body");
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
@@ -1,34 +1,49 @@
|
||||
import type { Writable } from "svelte/store";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
|
||||
export type DirectoryInfo =
|
||||
| {
|
||||
id: "root";
|
||||
dataKey?: undefined;
|
||||
dataKeyVersion?: undefined;
|
||||
name?: undefined;
|
||||
subDirectoryIds: number[];
|
||||
fileIds: number[];
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
dataKey: CryptoKey;
|
||||
dataKeyVersion: Date;
|
||||
name: string;
|
||||
subDirectoryIds: number[];
|
||||
fileIds: number[];
|
||||
};
|
||||
|
||||
export interface FileInfo {
|
||||
id: number;
|
||||
dataKey: CryptoKey;
|
||||
dataKeyVersion: Date;
|
||||
contentType: string;
|
||||
contentIv: string;
|
||||
export interface FileUploadStatus {
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
parentId: "root" | number;
|
||||
status:
|
||||
| "encryption-pending"
|
||||
| "encrypting"
|
||||
| "upload-pending"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "canceled"
|
||||
| "error";
|
||||
progress?: number;
|
||||
rate?: number;
|
||||
estimated?: number;
|
||||
}
|
||||
|
||||
export const directoryInfoStore = new Map<"root" | number, Writable<DirectoryInfo | null>>();
|
||||
export interface FileDownloadStatus {
|
||||
id: number;
|
||||
status:
|
||||
| "download-pending"
|
||||
| "downloading"
|
||||
| "decryption-pending"
|
||||
| "decrypting"
|
||||
| "decrypted"
|
||||
| "canceled"
|
||||
| "error";
|
||||
progress?: number;
|
||||
rate?: number;
|
||||
estimated?: number;
|
||||
result?: ArrayBuffer;
|
||||
}
|
||||
|
||||
export const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
||||
export const fileUploadStatusStore = writable<Writable<FileUploadStatus>[]>([]);
|
||||
|
||||
export const fileDownloadStatusStore = writable<Writable<FileDownloadStatus>[]>([]);
|
||||
|
||||
export const isFileUploading = (
|
||||
status: FileUploadStatus["status"],
|
||||
): status is "encryption-pending" | "encrypting" | "upload-pending" | "uploading" => {
|
||||
return ["encryption-pending", "encrypting", "upload-pending", "uploading"].includes(status);
|
||||
};
|
||||
|
||||
export const isFileDownloading = (
|
||||
status: FileDownloadStatus["status"],
|
||||
): status is "download-pending" | "downloading" | "decryption-pending" | "decrypting" => {
|
||||
return ["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
|
||||
};
|
||||
|
||||
@@ -1,66 +1,78 @@
|
||||
<script lang="ts">
|
||||
import FileSaver from "file-saver";
|
||||
import heic2any from "heic2any";
|
||||
import { untrack } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import { TopBar } from "$lib/components";
|
||||
import { getFileInfo } from "$lib/modules/file";
|
||||
import { masterKeyStore, type FileInfo } from "$lib/stores";
|
||||
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||
import { fileDownloadStatusStore, isFileDownloading, masterKeyStore } from "$lib/stores";
|
||||
import DownloadStatus from "./DownloadStatus.svelte";
|
||||
import { requestFileDownload } from "./service";
|
||||
|
||||
type ContentType = "image" | "video";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let info: Writable<FileInfo | null> | undefined = $state();
|
||||
let isDownloaded = $state(false);
|
||||
|
||||
let content: Blob | undefined = $state();
|
||||
let contentUrl: string | undefined = $state();
|
||||
let contentType: ContentType | undefined = $state();
|
||||
const downloadStatus = $derived(
|
||||
$fileDownloadStatusStore.find((statusStore) => {
|
||||
const { id, status } = get(statusStore);
|
||||
return id === data.id && isFileDownloading(status);
|
||||
}),
|
||||
);
|
||||
|
||||
let isDownloadRequested = $state(false);
|
||||
let viewerType: "image" | "video" | undefined = $state();
|
||||
let fileBlobUrl: string | undefined = $state();
|
||||
|
||||
const updateViewer = async (info: FileInfo, buffer: ArrayBuffer) => {
|
||||
const contentType = info.contentType;
|
||||
if (contentType.startsWith("image")) {
|
||||
viewerType = "image";
|
||||
} else if (contentType.startsWith("video")) {
|
||||
viewerType = "video";
|
||||
}
|
||||
|
||||
const fileBlob = new Blob([buffer], { type: contentType });
|
||||
if (contentType === "image/heic") {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
fileBlobUrl = URL.createObjectURL(
|
||||
(await heic2any({ blob: fileBlob, toType: "image/jpeg" })) as Blob,
|
||||
);
|
||||
} else if (viewerType) {
|
||||
fileBlobUrl = URL.createObjectURL(fileBlob);
|
||||
}
|
||||
|
||||
return fileBlob;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
info = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||
isDownloaded = false;
|
||||
|
||||
content = undefined;
|
||||
contentType = undefined;
|
||||
isDownloadRequested = false;
|
||||
viewerType = undefined;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if ($info && !isDownloaded) {
|
||||
if ($info && $info.dataKey && $info.contentIv) {
|
||||
untrack(() => {
|
||||
isDownloaded = true;
|
||||
|
||||
if ($info.contentType.startsWith("image")) {
|
||||
contentType = "image";
|
||||
} else if ($info.contentType.startsWith("video")) {
|
||||
contentType = "video";
|
||||
if (!downloadStatus && !isDownloadRequested) {
|
||||
isDownloadRequested = true;
|
||||
requestFileDownload(data.id, $info.contentIv!, $info.dataKey!).then(async (buffer) => {
|
||||
const blob = await updateViewer($info, buffer);
|
||||
if (!viewerType) {
|
||||
FileSaver.saveAs(blob, $info.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
requestFileDownload(data.id, $info.contentIv, $info.dataKey).then(async (res) => {
|
||||
content = new Blob([res], { type: $info.contentType });
|
||||
if (content.type === "image/heic" || content.type === "image/heif") {
|
||||
contentUrl = URL.createObjectURL(
|
||||
(await heic2any({ blob: content, toType: "image/jpeg" })) as Blob,
|
||||
);
|
||||
} else if (contentType) {
|
||||
contentUrl = URL.createObjectURL(content);
|
||||
} else {
|
||||
FileSaver.saveAs(content, $info.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (contentUrl) {
|
||||
URL.revokeObjectURL(contentUrl);
|
||||
}
|
||||
};
|
||||
if ($info && $downloadStatus?.status === "decrypted") {
|
||||
untrack(() => !isDownloadRequested && updateViewer($info, $downloadStatus.result!));
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => () => fileBlobUrl && URL.revokeObjectURL(fileBlobUrl));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -69,23 +81,24 @@
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<TopBar title={$info?.name} />
|
||||
<div class="mb-4 flex w-full flex-grow flex-col items-center">
|
||||
<DownloadStatus status={downloadStatus} />
|
||||
<div class="flex w-full flex-grow flex-col items-center pb-4">
|
||||
{#snippet viewerLoading(message: string)}
|
||||
<div class="flex flex-grow items-center justify-center">
|
||||
<p class="text-gray-500">{message}</p>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if $info && contentType === "image"}
|
||||
{#if contentUrl}
|
||||
<img src={contentUrl} alt={$info.name} />
|
||||
{#if $info && viewerType === "image"}
|
||||
{#if fileBlobUrl}
|
||||
<img src={fileBlobUrl} alt={$info.name} />
|
||||
{:else}
|
||||
{@render viewerLoading("이미지를 불러오고 있어요.")}
|
||||
{/if}
|
||||
{:else if contentType === "video"}
|
||||
{#if contentUrl}
|
||||
{:else if viewerType === "video"}
|
||||
{#if fileBlobUrl}
|
||||
<!-- svelte-ignore a11y_media_has_caption -->
|
||||
<video src={contentUrl} controls></video>
|
||||
<video src={fileBlobUrl} controls></video>
|
||||
{:else}
|
||||
{@render viewerLoading("비디오를 불러오고 있어요.")}
|
||||
{/if}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const ssr = false; // Because of heic2any
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
const zodRes = z
|
||||
.object({
|
||||
|
||||
33
src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
Normal file
33
src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||
import { isFileDownloading, type FileDownloadStatus } from "$lib/stores";
|
||||
|
||||
interface Props {
|
||||
status?: Writable<FileDownloadStatus>;
|
||||
}
|
||||
|
||||
let { status }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if $status && isFileDownloading($status.status)}
|
||||
<div class="w-full rounded-xl bg-gray-100 p-3">
|
||||
<p class="font-medium">
|
||||
{#if $status.status === "download-pending"}
|
||||
다운로드를 기다리는 중
|
||||
{:else if $status.status === "downloading"}
|
||||
다운로드하는 중
|
||||
{:else if $status.status === "decryption-pending"}
|
||||
복호화를 기다리는 중
|
||||
{:else if $status.status === "decrypting"}
|
||||
복호화하는 중
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{#if $status.status === "downloading"}
|
||||
전송됨
|
||||
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,31 +1,14 @@
|
||||
import { decryptData } from "$lib/modules/crypto";
|
||||
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
|
||||
|
||||
export const requestFileDownload = (
|
||||
export const requestFileDownload = async (
|
||||
fileId: number,
|
||||
fileEncryptedIv: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.responseType = "arraybuffer";
|
||||
const cache = await getFileCache(fileId);
|
||||
if (cache) return cache;
|
||||
|
||||
xhr.addEventListener("load", async () => {
|
||||
if (xhr.status !== 200) {
|
||||
reject(new Error("Failed to download file"));
|
||||
return;
|
||||
}
|
||||
|
||||
const fileDecrypted = await decryptData(
|
||||
xhr.response as ArrayBuffer,
|
||||
fileEncryptedIv,
|
||||
dataKey,
|
||||
);
|
||||
resolve(fileDecrypted);
|
||||
});
|
||||
|
||||
// TODO: Progress, ...
|
||||
|
||||
xhr.open("GET", `/api/file/${fileId}/download`);
|
||||
xhr.send();
|
||||
});
|
||||
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
|
||||
storeFileCache(fileId, fileBuffer); // Intended
|
||||
return fileBuffer;
|
||||
};
|
||||
|
||||
29
src/routes/(fullscreen)/file/downloads/+page.svelte
Normal file
29
src/routes/(fullscreen)/file/downloads/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { TopBar } from "$lib/components";
|
||||
import { fileDownloadStatusStore, isFileDownloading } from "$lib/stores";
|
||||
import File from "./File.svelte";
|
||||
|
||||
const downloadingFiles = $derived(
|
||||
$fileDownloadStatusStore.filter((status) => isFileDownloading(get(status).status)),
|
||||
);
|
||||
|
||||
$effect(() => () => {
|
||||
$fileDownloadStatusStore = $fileDownloadStatusStore.filter((status) =>
|
||||
isFileDownloading(get(status).status),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>진행 중인 다운로드</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<TopBar />
|
||||
<div class="space-y-2 pb-4">
|
||||
{#each downloadingFiles as status}
|
||||
<File {status} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
67
src/routes/(fullscreen)/file/downloads/File.svelte
Normal file
67
src/routes/(fullscreen)/file/downloads/File.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||
import { masterKeyStore, type FileDownloadStatus } from "$lib/stores";
|
||||
|
||||
import IconCloud from "~icons/material-symbols/cloud";
|
||||
import IconCloudDownload from "~icons/material-symbols/cloud-download";
|
||||
import IconLock from "~icons/material-symbols/lock";
|
||||
import IconLockClock from "~icons/material-symbols/lock-clock";
|
||||
import IconCheckCircle from "~icons/material-symbols/check-circle";
|
||||
import IconError from "~icons/material-symbols/error";
|
||||
|
||||
interface Props {
|
||||
status: Writable<FileDownloadStatus>;
|
||||
}
|
||||
|
||||
let { status }: Props = $props();
|
||||
|
||||
let fileInfo: Writable<FileInfo | null> | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
fileInfo = getFileInfo(get(status).id, $masterKeyStore?.get(1)?.key!);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $fileInfo}
|
||||
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||
<div class="flex-shrink-0 text-lg text-gray-600">
|
||||
{#if $status.status === "download-pending"}
|
||||
<IconCloud />
|
||||
{:else if $status.status === "downloading"}
|
||||
<IconCloudDownload />
|
||||
{:else if $status.status === "decryption-pending"}
|
||||
<IconLock />
|
||||
{:else if $status.status === "decrypting"}
|
||||
<IconLockClock />
|
||||
{:else if $status.status === "decrypted"}
|
||||
<IconCheckCircle class="text-green-500" />
|
||||
{:else if $status.status === "error"}
|
||||
<IconError class="text-red-500" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<p title={$fileInfo.name} class="truncate font-medium">
|
||||
{$fileInfo.name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-800">
|
||||
{#if $status.status === "download-pending"}
|
||||
다운로드를 기다리는 중
|
||||
{:else if $status.status === "downloading"}
|
||||
전송됨
|
||||
{Math.floor(($status.progress ?? 0) * 100)}% ·
|
||||
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||
{:else if $status.status === "decryption-pending"}
|
||||
복호화를 기다리는 중
|
||||
{:else if $status.status === "decrypting"}
|
||||
복호화하는 중
|
||||
{:else if $status.status === "decrypted"}
|
||||
다운로드 완료
|
||||
{:else if $status.status === "error"}
|
||||
다운로드 실패
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
29
src/routes/(fullscreen)/file/uploads/+page.svelte
Normal file
29
src/routes/(fullscreen)/file/uploads/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { get } from "svelte/store";
|
||||
import { TopBar } from "$lib/components";
|
||||
import { fileUploadStatusStore, isFileUploading } from "$lib/stores";
|
||||
import File from "./File.svelte";
|
||||
|
||||
const uploadingFiles = $derived(
|
||||
$fileUploadStatusStore.filter((status) => isFileUploading(get(status).status)),
|
||||
);
|
||||
|
||||
$effect(() => () => {
|
||||
$fileUploadStatusStore = $fileUploadStatusStore.filter((status) =>
|
||||
isFileUploading(get(status).status),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>진행 중인 업로드</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<TopBar />
|
||||
<div class="space-y-2 pb-4">
|
||||
{#each uploadingFiles as status}
|
||||
<File {status} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
57
src/routes/(fullscreen)/file/uploads/File.svelte
Normal file
57
src/routes/(fullscreen)/file/uploads/File.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||
import type { FileUploadStatus } from "$lib/stores";
|
||||
|
||||
import IconPending from "~icons/material-symbols/pending";
|
||||
import IconLockClock from "~icons/material-symbols/lock-clock";
|
||||
import IconCloud from "~icons/material-symbols/cloud";
|
||||
import IconCloudUpload from "~icons/material-symbols/cloud-upload";
|
||||
import IconCloudDone from "~icons/material-symbols/cloud-done";
|
||||
import IconError from "~icons/material-symbols/error";
|
||||
|
||||
interface Props {
|
||||
status: Writable<FileUploadStatus>;
|
||||
}
|
||||
|
||||
let { status }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||
<div class="flex-shrink-0 text-lg text-gray-600">
|
||||
{#if $status.status === "encryption-pending"}
|
||||
<IconPending />
|
||||
{:else if $status.status === "encrypting"}
|
||||
<IconLockClock />
|
||||
{:else if $status.status === "upload-pending"}
|
||||
<IconCloud />
|
||||
{:else if $status.status === "uploading"}
|
||||
<IconCloudUpload />
|
||||
{:else if $status.status === "uploaded"}
|
||||
<IconCloudDone class="text-blue-500" />
|
||||
{:else if $status.status === "error"}
|
||||
<IconError class="text-red-500" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<p title={$status.name} class="truncate font-medium">
|
||||
{$status.name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-800">
|
||||
{#if $status.status === "encryption-pending"}
|
||||
준비 중
|
||||
{:else if $status.status === "encrypting"}
|
||||
암호화하는 중
|
||||
{:else if $status.status === "upload-pending"}
|
||||
업로드를 기다리는 중
|
||||
{:else if $status.status === "uploading"}
|
||||
전송됨
|
||||
{Math.floor(($status.progress ?? 0) * 100)}% · {formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||
{:else if $status.status === "uploaded"}
|
||||
업로드 완료
|
||||
{:else if $status.status === "error"}
|
||||
업로드 실패
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { saveAs } from "file-saver";
|
||||
import FileSaver from "file-saver";
|
||||
import { goto } from "$app/navigation";
|
||||
import { Button, TextButton } from "$lib/components/buttons";
|
||||
import { TitleDiv, BottomDiv } from "$lib/components/divs";
|
||||
@@ -31,7 +31,7 @@
|
||||
const clientKeysBlob = new Blob([JSON.stringify(clientKeysSerialized)], {
|
||||
type: "application/json",
|
||||
});
|
||||
saveAs(clientKeysBlob, "arkvault-clientkey.json");
|
||||
FileSaver.saveAs(clientKeysBlob, "arkvault-clientkey.json");
|
||||
|
||||
if (!isBeforeContinueBottomSheetOpen) {
|
||||
setTimeout(() => {
|
||||
|
||||
74
src/routes/(fullscreen)/settings/cache/+page.svelte
vendored
Normal file
74
src/routes/(fullscreen)/settings/cache/+page.svelte
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { TopBar } from "$lib/components";
|
||||
import type { FileCacheIndex } from "$lib/indexedDB";
|
||||
import { getFileCacheIndex } from "$lib/modules/file";
|
||||
import { getFileInfo, type FileInfo } from "$lib/modules/filesystem";
|
||||
import { formatFileSize } from "$lib/modules/util";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
import File from "./File.svelte";
|
||||
import { deleteFileCache as doDeleteFileCache } from "./service";
|
||||
|
||||
interface FileCache {
|
||||
index: FileCacheIndex;
|
||||
fileInfo: Writable<FileInfo | null>;
|
||||
}
|
||||
|
||||
let fileCache: FileCache[] | undefined = $state();
|
||||
let fileCacheTotalSize = $state(0);
|
||||
|
||||
const deleteFileCache = async (fileId: number) => {
|
||||
await doDeleteFileCache(fileId);
|
||||
fileCache = fileCache?.filter(({ index }) => index.fileId !== fileId);
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
fileCache = getFileCacheIndex()
|
||||
.map((index) => ({
|
||||
index,
|
||||
fileInfo: getFileInfo(index.fileId, $masterKeyStore?.get(1)?.key!),
|
||||
}))
|
||||
.sort((a, b) => a.index.lastRetrievedAt.getTime() - b.index.lastRetrievedAt.getTime());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (fileCache) {
|
||||
fileCacheTotalSize = fileCache.reduce((acc, { index }) => acc + index.size, 0);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>캐시 설정</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
<TopBar title="캐시" />
|
||||
{#if fileCache && fileCache.length > 0}
|
||||
<div class="space-y-4 pb-4">
|
||||
<div class="space-y-1 break-keep text-gray-800">
|
||||
<p>
|
||||
{fileCache.length}개 파일이 캐시되어 {formatFileSize(fileCacheTotalSize)}를 사용하고
|
||||
있어요.
|
||||
</p>
|
||||
<p>캐시를 삭제하더라도 원본 파일은 삭제되지 않아요.</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each fileCache as { index, fileInfo }}
|
||||
<File {index} info={fileInfo} onDeleteClick={deleteFileCache} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-grow items-center justify-center">
|
||||
<p class="text-gray-500">
|
||||
{#if fileCache}
|
||||
캐시된 파일이 없어요.
|
||||
{:else}
|
||||
캐시 목록을 불러오고 있어요.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
46
src/routes/(fullscreen)/settings/cache/File.svelte
vendored
Normal file
46
src/routes/(fullscreen)/settings/cache/File.svelte
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { FileCacheIndex } from "$lib/indexedDB";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { formatDate, formatFileSize } from "$lib/modules/util";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
import IconScanDelete from "~icons/material-symbols/scan-delete";
|
||||
import IconDelete from "~icons/material-symbols/delete";
|
||||
|
||||
interface Props {
|
||||
index: FileCacheIndex;
|
||||
info: Writable<FileInfo | null>;
|
||||
onDeleteClick: (fileId: number) => void;
|
||||
}
|
||||
|
||||
let { index, info, onDeleteClick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||
{#if $info}
|
||||
<div class="flex-shrink-0 rounded-full bg-blue-100 p-1 text-xl">
|
||||
<IconDraft class="text-blue-400" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-shrink-0 rounded-full bg-red-100 p-1 text-xl">
|
||||
<IconScanDelete class="text-red-400" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex-grow overflow-hidden">
|
||||
{#if $info}
|
||||
<p title={$info.name} class="truncate font-medium">{$info.name}</p>
|
||||
{:else}
|
||||
<p class="font-medium">삭제된 파일</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-800">
|
||||
읽음 {formatDate(index.lastRetrievedAt)} · {formatFileSize(index.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={() => setTimeout(() => onDeleteClick(index.fileId), 100)}
|
||||
class="flex-shrink-0 rounded-full p-1 active:bg-gray-100"
|
||||
>
|
||||
<IconDelete class="text-lg text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
5
src/routes/(fullscreen)/settings/cache/service.ts
vendored
Normal file
5
src/routes/(fullscreen)/settings/cache/service.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
|
||||
|
||||
export const deleteFileCache = async (fileId: number) => {
|
||||
await doDeleteFileCache(fileId);
|
||||
};
|
||||
@@ -4,19 +4,20 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import { TopBar } from "$lib/components";
|
||||
import { FloatingButton } from "$lib/components/buttons";
|
||||
import { getDirectoryInfo } from "$lib/modules/file";
|
||||
import { masterKeyStore, hmacSecretStore, type DirectoryInfo } from "$lib/stores";
|
||||
import { getDirectoryInfo, type DirectoryInfo } from "$lib/modules/filesystem";
|
||||
import { masterKeyStore, hmacSecretStore } from "$lib/stores";
|
||||
import CreateBottomSheet from "./CreateBottomSheet.svelte";
|
||||
import CreateDirectoryModal from "./CreateDirectoryModal.svelte";
|
||||
import DeleteDirectoryEntryModal from "./DeleteDirectoryEntryModal.svelte";
|
||||
import DirectoryEntries from "./DirectoryEntries";
|
||||
import DirectoryEntryMenuBottomSheet from "./DirectoryEntryMenuBottomSheet.svelte";
|
||||
import DownloadStatusCard from "./DownloadStatusCard.svelte";
|
||||
import DuplicateFileModal from "./DuplicateFileModal.svelte";
|
||||
import RenameDirectoryEntryModal from "./RenameDirectoryEntryModal.svelte";
|
||||
import UploadStatusCard from "./UploadStatusCard.svelte";
|
||||
import {
|
||||
requestHmacSecretDownload,
|
||||
requestDirectoryCreation,
|
||||
requestDuplicateFileScan,
|
||||
requestFileUpload,
|
||||
requestDirectoryEntryRename,
|
||||
requestDirectoryEntryDeletion,
|
||||
@@ -25,17 +26,12 @@
|
||||
|
||||
import IconAdd from "~icons/material-symbols/add";
|
||||
|
||||
interface LoadedFile {
|
||||
file: File;
|
||||
fileBuffer: ArrayBuffer;
|
||||
fileSigned: string;
|
||||
}
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let info: Writable<DirectoryInfo | null> | undefined = $state();
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
let loadedFile: LoadedFile | undefined = $state();
|
||||
let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state();
|
||||
let duplicatedFile: File | undefined = $state();
|
||||
let selectedEntry: SelectedDirectoryEntry | undefined = $state();
|
||||
|
||||
let isCreateBottomSheetOpen = $state(false);
|
||||
@@ -52,34 +48,33 @@
|
||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME
|
||||
};
|
||||
|
||||
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 uploadFile = () => {
|
||||
const files = fileInput?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const loadAndUploadFile = async () => {
|
||||
const file = fileInput?.files?.[0];
|
||||
if (!file) return;
|
||||
for (const file of files) {
|
||||
requestFileUpload(file, data.id, $hmacSecretStore?.get(1)!, $masterKeyStore?.get(1)!, () => {
|
||||
return new Promise((resolve) => {
|
||||
resolveForDuplicateFileModal = resolve;
|
||||
duplicatedFile = file;
|
||||
isDuplicateFileModalOpen = true;
|
||||
});
|
||||
})
|
||||
.then((res) => {
|
||||
if (!res) return;
|
||||
|
||||
// TODO: FIXME
|
||||
info = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
|
||||
window.alert(`'${file.name}' 파일이 업로드되었어요.`);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
// TODO: FIXME
|
||||
console.error(e);
|
||||
window.alert(`'${file.name}' 파일 업로드에 실패했어요.\n${e.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
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 () => {
|
||||
@@ -97,7 +92,7 @@
|
||||
<title>파일</title>
|
||||
</svelte:head>
|
||||
|
||||
<input bind:this={fileInput} onchange={loadAndUploadFile} type="file" class="hidden" />
|
||||
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />
|
||||
|
||||
<div class="flex min-h-full flex-col px-4">
|
||||
{#if data.id !== "root"}
|
||||
@@ -106,6 +101,10 @@
|
||||
{#if $info}
|
||||
{@const topMargin = data.id === "root" ? "mt-4" : ""}
|
||||
<div class="mb-4 flex flex-grow flex-col {topMargin}">
|
||||
<div class="flex gap-x-2">
|
||||
<UploadStatusCard onclick={() => goto("/file/uploads")} />
|
||||
<DownloadStatusCard onclick={() => goto("/file/downloads")} />
|
||||
</div>
|
||||
{#key $info}
|
||||
<DirectoryEntries
|
||||
info={$info}
|
||||
@@ -140,14 +139,18 @@
|
||||
<CreateDirectoryModal bind:isOpen={isCreateDirectoryModalOpen} onCreateClick={createDirectory} />
|
||||
<DuplicateFileModal
|
||||
bind:isOpen={isDuplicateFileModalOpen}
|
||||
file={duplicatedFile}
|
||||
onclose={() => {
|
||||
resolveForDuplicateFileModal?.(false);
|
||||
resolveForDuplicateFileModal = undefined;
|
||||
duplicatedFile = undefined;
|
||||
isDuplicateFileModalOpen = false;
|
||||
loadedFile = undefined;
|
||||
}}
|
||||
onDuplicateClick={() => {
|
||||
uploadFile(loadedFile!);
|
||||
resolveForDuplicateFileModal?.(true);
|
||||
resolveForDuplicateFileModal = undefined;
|
||||
duplicatedFile = undefined;
|
||||
isDuplicateFileModalOpen = false;
|
||||
loadedFile = undefined;
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { getDirectoryInfo, getFileInfo } from "$lib/modules/file";
|
||||
import { masterKeyStore, type DirectoryInfo, type FileInfo } from "$lib/stores";
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import {
|
||||
getDirectoryInfo,
|
||||
getFileInfo,
|
||||
type DirectoryInfo,
|
||||
type FileInfo,
|
||||
} from "$lib/modules/filesystem";
|
||||
import {
|
||||
fileUploadStatusStore,
|
||||
isFileUploading,
|
||||
masterKeyStore,
|
||||
type FileUploadStatus,
|
||||
} from "$lib/stores";
|
||||
import File from "./File.svelte";
|
||||
import SubDirectory from "./SubDirectory.svelte";
|
||||
import { SortBy, sortEntries } from "./service";
|
||||
import UploadingFile from "./UploadingFile.svelte";
|
||||
import type { SelectedDirectoryEntry } from "../service";
|
||||
|
||||
interface Props {
|
||||
@@ -17,37 +28,95 @@
|
||||
|
||||
let { info, onEntryClick, onEntryMenuClick, sortBy = SortBy.NAME_ASC }: Props = $props();
|
||||
|
||||
let subDirectoryInfos: Writable<DirectoryInfo | null>[] = $state([]);
|
||||
let fileInfos: Writable<FileInfo | null>[] = $state([]);
|
||||
interface DirectoryEntry {
|
||||
name?: string;
|
||||
info: Writable<DirectoryInfo | null>;
|
||||
}
|
||||
|
||||
type FileEntry =
|
||||
| {
|
||||
type: "file";
|
||||
name?: string;
|
||||
info: Writable<FileInfo | null>;
|
||||
}
|
||||
| {
|
||||
type: "uploading-file";
|
||||
name: string;
|
||||
info: Writable<FileUploadStatus>;
|
||||
};
|
||||
|
||||
let subDirectories: DirectoryEntry[] = $state([]);
|
||||
let files: FileEntry[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
// TODO: Fix duplicated requests
|
||||
|
||||
subDirectoryInfos = info.subDirectoryIds.map((id) =>
|
||||
getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!),
|
||||
);
|
||||
fileInfos = info.fileIds.map((id) => getFileInfo(id, $masterKeyStore?.get(1)?.key!));
|
||||
subDirectories = info.subDirectoryIds.map((id) => {
|
||||
const info = getDirectoryInfo(id, $masterKeyStore?.get(1)?.key!);
|
||||
return { name: get(info)?.name, info };
|
||||
});
|
||||
files = info.fileIds
|
||||
.map((id): FileEntry => {
|
||||
const info = getFileInfo(id, $masterKeyStore?.get(1)?.key!);
|
||||
return {
|
||||
type: "file",
|
||||
name: get(info)?.name,
|
||||
info,
|
||||
};
|
||||
})
|
||||
.concat(
|
||||
$fileUploadStatusStore
|
||||
.filter((statusStore) => {
|
||||
const { parentId, status } = get(statusStore);
|
||||
return parentId === info.id && isFileUploading(status);
|
||||
})
|
||||
.map((status) => ({
|
||||
type: "uploading-file",
|
||||
name: get(status).name,
|
||||
info: status,
|
||||
})),
|
||||
);
|
||||
|
||||
const sort = () => {
|
||||
sortEntries(subDirectoryInfos, sortBy);
|
||||
sortEntries(fileInfos, sortBy);
|
||||
sortEntries(subDirectories, sortBy);
|
||||
sortEntries(files, sortBy);
|
||||
};
|
||||
return untrack(() => {
|
||||
const unsubscribes = subDirectoryInfos
|
||||
.map((subDirectoryInfo) => subDirectoryInfo.subscribe(sort))
|
||||
.concat(fileInfos.map((fileInfo) => fileInfo.subscribe(sort)));
|
||||
sort();
|
||||
|
||||
const unsubscribes = subDirectories
|
||||
.map((subDirectory) =>
|
||||
subDirectory.info.subscribe((value) => {
|
||||
if (subDirectory.name === value?.name) return;
|
||||
subDirectory.name = value?.name;
|
||||
sort();
|
||||
}),
|
||||
)
|
||||
.concat(
|
||||
files.map((file) =>
|
||||
file.info.subscribe((value) => {
|
||||
if (file.name === value?.name) return;
|
||||
file.name = value?.name;
|
||||
sort();
|
||||
}),
|
||||
),
|
||||
);
|
||||
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if info.subDirectoryIds.length + info.fileIds.length > 0}
|
||||
<div class="pb-[4.5rem]">
|
||||
{#each subDirectoryInfos as subDirectory}
|
||||
<SubDirectory info={subDirectory} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||
{#if subDirectories.length + files.length > 0}
|
||||
<div class="space-y-1 pb-[4.5rem]">
|
||||
{#each subDirectories as { info }}
|
||||
<SubDirectory {info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||
{/each}
|
||||
{#each fileInfos as file}
|
||||
<File info={file} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||
{#each files as file}
|
||||
{#if file.type === "file"}
|
||||
<File info={file.info} onclick={onEntryClick} onOpenMenuClick={onEntryMenuClick} />
|
||||
{:else}
|
||||
<UploadingFile status={file.info} />
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { FileInfo } from "$lib/stores";
|
||||
import { formatDate } from "./service";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { formatDateTime } from "$lib/modules/util";
|
||||
import type { SelectedDirectoryEntry } from "../service";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
const openFile = () => {
|
||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onclick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
@@ -26,6 +28,8 @@
|
||||
e.stopPropagation();
|
||||
|
||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
@@ -40,11 +44,13 @@
|
||||
<div class="flex-shrink-0 text-lg">
|
||||
<IconDraft class="text-blue-400" />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-col overflow-hidden">
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<p title={$info.name} class="truncate font-medium">
|
||||
{$info.name}
|
||||
</p>
|
||||
<p class="text-xs text-gray-800">{formatDate($info.createdAt ?? $info.lastModifiedAt)}</p>
|
||||
<p class="text-xs text-gray-800">
|
||||
{formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
id="open-menu"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { DirectoryInfo } from "$lib/stores";
|
||||
import type { DirectoryInfo } from "$lib/modules/filesystem";
|
||||
import type { SelectedDirectoryEntry } from "../service";
|
||||
|
||||
import IconFolder from "~icons/material-symbols/folder";
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
const openDirectory = () => {
|
||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onclick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
@@ -27,6 +29,8 @@
|
||||
e.stopPropagation();
|
||||
|
||||
const { id, dataKey, dataKeyVersion, name } = $info as SubDirectoryInfo;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
|
||||
setTimeout(() => {
|
||||
onOpenMenuClick({ type: "directory", id, dataKey, dataKeyVersion, name });
|
||||
}, 100);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import { formatNetworkSpeed } from "$lib/modules/util";
|
||||
import { isFileUploading, type FileUploadStatus } from "$lib/stores";
|
||||
|
||||
import IconDraft from "~icons/material-symbols/draft";
|
||||
|
||||
interface Props {
|
||||
status: Writable<FileUploadStatus>;
|
||||
}
|
||||
|
||||
let { status }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if isFileUploading($status.status)}
|
||||
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||
<div class="flex-shrink-0 text-lg">
|
||||
<IconDraft class="text-gray-600" />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
|
||||
<p title={$status.name} class="truncate font-medium">
|
||||
{$status.name}
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
{#if $status.status === "encryption-pending"}
|
||||
준비 중
|
||||
{:else if $status.status === "encrypting"}
|
||||
암호화하는 중
|
||||
{:else if $status.status === "upload-pending"}
|
||||
업로드를 기다리는 중
|
||||
{:else if $status.status === "uploading"}
|
||||
전송됨 {Math.floor(($status.progress ?? 0) * 100)}% ·
|
||||
{formatNetworkSpeed(($status.rate ?? 0) * 8)}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,22 +1,23 @@
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import type { DirectoryInfo, FileInfo } from "$lib/stores";
|
||||
|
||||
export enum SortBy {
|
||||
NAME_ASC,
|
||||
NAME_DESC,
|
||||
}
|
||||
|
||||
type SortFunc = (a: DirectoryInfo | FileInfo | null, b: DirectoryInfo | FileInfo | null) => number;
|
||||
type SortFunc = (a?: string, b?: string) => number;
|
||||
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
const sortByNameAsc: SortFunc = (a, b) => {
|
||||
if (a && b) return a.name!.localeCompare(b.name!);
|
||||
if (a && b) return collator.compare(a, b);
|
||||
if (a) return -1;
|
||||
if (b) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sortByNameDesc: SortFunc = (a, b) => -sortByNameAsc(a, b);
|
||||
|
||||
export const sortEntries = <T extends DirectoryInfo | FileInfo>(
|
||||
entries: Writable<T | null>[],
|
||||
export const sortEntries = <T extends { name?: string }>(
|
||||
entries: T[],
|
||||
sortBy: SortBy = SortBy.NAME_ASC,
|
||||
) => {
|
||||
let sortFunc: SortFunc;
|
||||
@@ -26,17 +27,5 @@ export const sortEntries = <T extends DirectoryInfo | FileInfo>(
|
||||
sortFunc = sortByNameDesc;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => sortFunc(get(a), get(b)));
|
||||
};
|
||||
|
||||
const pad2 = (num: number) => num.toString().padStart(2, "0");
|
||||
|
||||
export const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
const hours = date.getHours();
|
||||
const minutes = date.getMinutes();
|
||||
|
||||
return `${year}. ${month}. ${day}. ${pad2(hours)}:${pad2(minutes)}`;
|
||||
entries.sort((a, b) => sortFunc(a.name, b.name));
|
||||
};
|
||||
|
||||
41
src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
Normal file
41
src/routes/(main)/directory/[[id]]/DownloadStatusCard.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import { fileDownloadStatusStore, isFileDownloading, type FileDownloadStatus } from "$lib/stores";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { onclick }: Props = $props();
|
||||
|
||||
let downloadingFiles: Writable<FileDownloadStatus>[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
downloadingFiles = $fileDownloadStatusStore.filter((status) =>
|
||||
isFileDownloading(get(status).status),
|
||||
);
|
||||
return untrack(() => {
|
||||
const unsubscribes = downloadingFiles.map((downloadingFile) =>
|
||||
downloadingFile.subscribe(({ status }) => {
|
||||
if (!isFileDownloading(status)) {
|
||||
downloadingFiles = downloadingFiles.filter((file) => file !== downloadingFile);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if downloadingFiles.length > 0}
|
||||
<button
|
||||
onclick={() => setTimeout(onclick, 100)}
|
||||
class="mb-4 max-w-[50%] flex-1 rounded-xl bg-green-100 p-3 active:bg-green-200"
|
||||
>
|
||||
<div class="text-left transition active:scale-95">
|
||||
<p class="text-xs text-gray-800">진행 중인 다운로드</p>
|
||||
<p class="font-medium text-green-800">{downloadingFiles.length}개</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -3,30 +3,28 @@
|
||||
import { Button } from "$lib/components/buttons";
|
||||
|
||||
interface Props {
|
||||
file: File | undefined;
|
||||
onclose: () => void;
|
||||
onDuplicateClick: () => void;
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
let { onclose, onDuplicateClick, isOpen = $bindable() }: Props = $props();
|
||||
let { file, 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>
|
||||
{#if file}
|
||||
{@const { name } = file}
|
||||
{@const nameShort = name.length > 20 ? `${name.slice(0, 20)}...` : name}
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2 break-keep">
|
||||
<p class="text-xl font-bold">'{nameShort}' 파일이 있어요.</p>
|
||||
<p>예전에 이미 업로드된 파일이에요. 그래도 업로드할까요?</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button color="gray" onclick={onclose}>아니요</Button>
|
||||
<Button onclick={onDuplicateClick}>업로드할게요</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
color="gray"
|
||||
onclick={() => {
|
||||
isOpen = false;
|
||||
}}
|
||||
>
|
||||
아니요
|
||||
</Button>
|
||||
<Button onclick={onDuplicateClick}>업로드할게요</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
|
||||
39
src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte
Normal file
39
src/routes/(main)/directory/[[id]]/UploadStatusCard.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { get, type Writable } from "svelte/store";
|
||||
import { fileUploadStatusStore, isFileUploading, type FileUploadStatus } from "$lib/stores";
|
||||
|
||||
interface Props {
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { onclick }: Props = $props();
|
||||
|
||||
let uploadingFiles: Writable<FileUploadStatus>[] = $state([]);
|
||||
|
||||
$effect(() => {
|
||||
uploadingFiles = $fileUploadStatusStore.filter((status) => isFileUploading(get(status).status));
|
||||
return untrack(() => {
|
||||
const unsubscribes = uploadingFiles.map((uploadingFile) =>
|
||||
uploadingFile.subscribe(({ status }) => {
|
||||
if (!isFileUploading(status)) {
|
||||
uploadingFiles = uploadingFiles.filter((file) => file !== uploadingFile);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return () => unsubscribes.forEach((unsubscribe) => unsubscribe());
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if uploadingFiles.length > 0}
|
||||
<button
|
||||
onclick={() => setTimeout(onclick, 100)}
|
||||
class="mb-4 max-w-[50%] flex-1 rounded-xl bg-blue-100 p-3 active:bg-blue-200"
|
||||
>
|
||||
<div class="text-left transition active:scale-95">
|
||||
<p class="text-xs text-gray-800">진행 중인 업로드</p>
|
||||
<p class="font-medium text-blue-800">{uploadingFiles.length}개</p>
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,23 +1,13 @@
|
||||
import ExifReader from "exifreader";
|
||||
import { callGetApi, callPostApi } from "$lib/hooks";
|
||||
import { storeHmacSecrets } from "$lib/indexedDB";
|
||||
import {
|
||||
encodeToBase64,
|
||||
generateDataKey,
|
||||
wrapDataKey,
|
||||
unwrapHmacSecret,
|
||||
encryptData,
|
||||
encryptString,
|
||||
signMessageHmac,
|
||||
} from "$lib/modules/crypto";
|
||||
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
|
||||
import { deleteFileCache, uploadFile } from "$lib/modules/file";
|
||||
import type {
|
||||
DirectoryRenameRequest,
|
||||
DirectoryCreateRequest,
|
||||
FileRenameRequest,
|
||||
FileUploadRequest,
|
||||
HmacSecretListResponse,
|
||||
DuplicateFileScanRequest,
|
||||
DuplicateFileScanResponse,
|
||||
DirectoryDeleteResponse,
|
||||
} from "$lib/server/schemas";
|
||||
import { hmacSecretStore, type MasterKey, type HmacSecret } from "$lib/stores";
|
||||
|
||||
@@ -57,7 +47,7 @@ export const requestDirectoryCreation = async (
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
const nameEncrypted = await encryptString(name, dataKey);
|
||||
await callPostApi<DirectoryCreateRequest>("/api/directory/create", {
|
||||
parentId,
|
||||
parent: parentId,
|
||||
mekVersion: masterKey.version,
|
||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||
dekVersion: dataKeyVersion.toISOString(),
|
||||
@@ -66,106 +56,14 @@ 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,
|
||||
};
|
||||
};
|
||||
|
||||
const extractExifDateTime = (fileBuffer: ArrayBuffer) => {
|
||||
const exif = ExifReader.load(fileBuffer);
|
||||
const dateTimeOriginal = exif["DateTimeOriginal"]?.description;
|
||||
const offsetTimeOriginal = exif["OffsetTimeOriginal"]?.description;
|
||||
if (!dateTimeOriginal) return undefined;
|
||||
|
||||
const [date, time] = dateTimeOriginal.split(" ");
|
||||
if (!date || !time) return undefined;
|
||||
|
||||
const [year, month, day] = date.split(":").map(Number);
|
||||
const [hour, minute, second] = time.split(":").map(Number);
|
||||
if (!year || !month || !day || !hour || !minute || !second) return undefined;
|
||||
|
||||
if (!offsetTimeOriginal) {
|
||||
// No timezone information -> Local timezone
|
||||
return new Date(year, month - 1, day, hour, minute, second);
|
||||
}
|
||||
|
||||
const offsetSign = offsetTimeOriginal[0] === "+" ? 1 : -1;
|
||||
const [offsetHour, offsetMinute] = offsetTimeOriginal.slice(1).split(":").map(Number);
|
||||
|
||||
const utcDate = Date.UTC(year, month - 1, day, hour, minute, second);
|
||||
const offsetMs = offsetSign * ((offsetHour ?? 0) * 60 + (offsetMinute ?? 0)) * 60 * 1000;
|
||||
return new Date(utcDate - offsetMs);
|
||||
};
|
||||
|
||||
export const requestFileUpload = async (
|
||||
file: File,
|
||||
fileBuffer: ArrayBuffer,
|
||||
fileSigned: string,
|
||||
parentId: "root" | number,
|
||||
masterKey: MasterKey,
|
||||
hmacSecret: HmacSecret,
|
||||
masterKey: MasterKey,
|
||||
onDuplicate: () => Promise<boolean>,
|
||||
) => {
|
||||
let createdAt = undefined;
|
||||
if (file.type.startsWith("image/")) {
|
||||
createdAt = extractExifDateTime(fileBuffer);
|
||||
}
|
||||
|
||||
const { dataKey, dataKeyVersion } = await generateDataKey();
|
||||
const fileEncrypted = await encryptData(fileBuffer, dataKey);
|
||||
const nameEncrypted = await encryptString(file.name, dataKey);
|
||||
const createdAtEncrypted =
|
||||
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
|
||||
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
||||
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
parentId,
|
||||
mekVersion: masterKey.version,
|
||||
dek: await wrapDataKey(dataKey, masterKey.key),
|
||||
dekVersion: dataKeyVersion.toISOString(),
|
||||
hskVersion: hmacSecret.version,
|
||||
contentHmac: fileSigned,
|
||||
contentType: file.type,
|
||||
contentIv: fileEncrypted.iv,
|
||||
name: nameEncrypted.ciphertext,
|
||||
nameIv: nameEncrypted.iv,
|
||||
createdAt: createdAtEncrypted?.ciphertext,
|
||||
createdAtIv: createdAtEncrypted?.iv,
|
||||
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
||||
} satisfies FileUploadRequest),
|
||||
);
|
||||
form.set("content", new Blob([fileEncrypted.ciphertext]));
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
// TODO: Progress, Scheduling, ...
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.addEventListener("load", () => {
|
||||
if (xhr.status === 200) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(xhr.responseText));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.open("POST", "/api/file/upload");
|
||||
xhr.send(form);
|
||||
});
|
||||
return await uploadFile(file, parentId, hmacSecret, masterKey, onDuplicate);
|
||||
};
|
||||
|
||||
export const requestDirectoryEntryRename = async (
|
||||
@@ -190,5 +88,15 @@ export const requestDirectoryEntryRename = async (
|
||||
};
|
||||
|
||||
export const requestDirectoryEntryDeletion = async (entry: SelectedDirectoryEntry) => {
|
||||
await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
|
||||
const res = await callPostApi(`/api/${entry.type}/${entry.id}/delete`);
|
||||
if (!res.ok) return false;
|
||||
|
||||
if (entry.type === "directory") {
|
||||
const { deletedFiles }: DirectoryDeleteResponse = await res.json();
|
||||
await Promise.all(deletedFiles.map(deleteFileCache));
|
||||
return true;
|
||||
} else {
|
||||
await deleteFileCache(entry.id);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { EntryButton } from "$lib/components/buttons";
|
||||
import { requestLogout } from "./service.js";
|
||||
import MenuEntryButton from "./MenuEntryButton.svelte";
|
||||
import { requestLogout } from "./service";
|
||||
|
||||
import IconStorage from "~icons/material-symbols/storage";
|
||||
import IconPassword from "~icons/material-symbols/password";
|
||||
import IconLogout from "~icons/material-symbols/logout";
|
||||
|
||||
@@ -23,23 +24,27 @@
|
||||
<p class="font-semibold">{data.nickname}</p>
|
||||
</div>
|
||||
<div class="space-y-4 px-4 pb-4">
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold">설정</p>
|
||||
<MenuEntryButton
|
||||
onclick={() => goto("/settings/cache")}
|
||||
icon={IconStorage}
|
||||
iconColor="text-green-500"
|
||||
>
|
||||
캐시
|
||||
</MenuEntryButton>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold">보안</p>
|
||||
<EntryButton onclick={() => goto("/auth/changePassword")}>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="rounded-lg bg-gray-200 p-1 text-blue-500">
|
||||
<IconPassword />
|
||||
</div>
|
||||
<p class="font-medium">비밀번호 바꾸기</p>
|
||||
</div>
|
||||
</EntryButton>
|
||||
<EntryButton onclick={logout}>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="rounded-lg bg-gray-200 p-1 text-red-500">
|
||||
<IconLogout />
|
||||
</div>
|
||||
<p class="font-medium">로그아웃</p>
|
||||
</div>
|
||||
</EntryButton>
|
||||
<MenuEntryButton
|
||||
onclick={() => goto("/auth/changePassword")}
|
||||
icon={IconPassword}
|
||||
iconColor="text-blue-500"
|
||||
>
|
||||
비밀번호 바꾸기
|
||||
</MenuEntryButton>
|
||||
<MenuEntryButton onclick={logout} icon={IconLogout} iconColor="text-red-500">
|
||||
로그아웃
|
||||
</MenuEntryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
25
src/routes/(main)/menu/MenuEntryButton.svelte
Normal file
25
src/routes/(main)/menu/MenuEntryButton.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { Component, Snippet } from "svelte";
|
||||
import type { SvelteHTMLElements } from "svelte/elements";
|
||||
import { EntryButton } from "$lib/components/buttons";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
icon: Component<SvelteHTMLElements["svg"]>;
|
||||
iconColor: string;
|
||||
onclick: () => void;
|
||||
}
|
||||
|
||||
let { children, icon: Icon, iconColor, onclick }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EntryButton {onclick}>
|
||||
<div class="flex items-center gap-x-4">
|
||||
<div class="rounded-lg bg-gray-200 p-1 {iconColor}">
|
||||
<Icon />
|
||||
</div>
|
||||
<p class="font-medium">
|
||||
{@render children?.()}
|
||||
</p>
|
||||
</div>
|
||||
</EntryButton>
|
||||
@@ -1,11 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { goto as svelteGoto } from "$app/navigation";
|
||||
import { clientKeyStore, masterKeyStore } from "$lib/stores";
|
||||
import {
|
||||
fileUploadStatusStore,
|
||||
fileDownloadStatusStore,
|
||||
isFileUploading,
|
||||
isFileDownloading,
|
||||
clientKeyStore,
|
||||
masterKeyStore,
|
||||
} from "$lib/stores";
|
||||
import "../app.css";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const protectFileUploadAndDownload = (e: BeforeUnloadEvent) => {
|
||||
if (
|
||||
$fileUploadStatusStore.some((status) => isFileUploading(get(status).status)) ||
|
||||
$fileDownloadStatusStore.some((status) => isFileDownloading(get(status).status))
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const goto = async (url: string) => {
|
||||
const whitelist = ["/auth/login", "/key", "/client/pending"];
|
||||
@@ -24,4 +41,6 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onbeforeunload={protectFileUploadAndDownload} />
|
||||
|
||||
{@render children()}
|
||||
|
||||
@@ -20,6 +20,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
return json(
|
||||
directoryInfoResponse.parse({
|
||||
metadata: metadata && {
|
||||
parent: metadata.parentId,
|
||||
mekVersion: metadata.mekVersion,
|
||||
dek: metadata.encDek,
|
||||
dekVersion: metadata.dekVersion.toISOString(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { directoryDeleteResponse, type DirectoryDeleteResponse } from "$lib/server/schemas";
|
||||
import { deleteDirectory } from "$lib/server/services/directory";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
@@ -15,6 +16,8 @@ export const POST: RequestHandler = async ({ locals, params }) => {
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
await deleteDirectory(userId, id);
|
||||
return text("Directory deleted", { headers: { "Content-Type": "text/plain" } });
|
||||
const { files } = await deleteDirectory(userId, id);
|
||||
return json(
|
||||
directoryDeleteResponse.parse({ deletedFiles: files } satisfies DirectoryDeleteResponse),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,11 +9,11 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
|
||||
const zodRes = directoryCreateRequest.safeParse(await request.json());
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const { parentId, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
|
||||
const { parent, mekVersion, dek, dekVersion, name, nameIv } = zodRes.data;
|
||||
|
||||
await createDirectory({
|
||||
userId,
|
||||
parentId,
|
||||
parentId: parent,
|
||||
mekVersion,
|
||||
encDek: dek,
|
||||
dekVersion: new Date(dekVersion),
|
||||
|
||||
@@ -17,6 +17,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
const { id } = zodRes.data;
|
||||
|
||||
const {
|
||||
parentId,
|
||||
mekVersion,
|
||||
encDek,
|
||||
dekVersion,
|
||||
@@ -28,6 +29,7 @@ export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
} = await getFileInformation(userId, id);
|
||||
return json(
|
||||
fileInfoResponse.parse({
|
||||
parent: parentId,
|
||||
mekVersion,
|
||||
dek: encDek,
|
||||
dekVersion: dekVersion.toISOString(),
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import Busboy from "@fastify/busboy";
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { Readable, Writable } from "stream";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { fileUploadRequest } from "$lib/server/schemas";
|
||||
import { uploadFile } from "$lib/server/services/file";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
type FileMetadata = Parameters<typeof uploadFile>[0];
|
||||
|
||||
const form = await request.formData();
|
||||
const metadata = form.get("metadata");
|
||||
const content = form.get("content");
|
||||
if (typeof metadata !== "string" || !(content instanceof File)) {
|
||||
error(400, "Invalid request body");
|
||||
}
|
||||
|
||||
const zodRes = fileUploadRequest.safeParse(JSON.parse(metadata));
|
||||
const parseFileMetadata = (userId: number, json: string) => {
|
||||
const zodRes = fileUploadRequest.safeParse(JSON.parse(json));
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
const {
|
||||
parentId,
|
||||
parent,
|
||||
mekVersion,
|
||||
dek,
|
||||
dekVersion,
|
||||
@@ -35,25 +30,77 @@ export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
if ((createdAt && !createdAtIv) || (!createdAt && createdAtIv))
|
||||
error(400, "Invalid request body");
|
||||
|
||||
await uploadFile(
|
||||
{
|
||||
userId,
|
||||
parentId,
|
||||
mekVersion,
|
||||
encDek: dek,
|
||||
dekVersion: new Date(dekVersion),
|
||||
hskVersion,
|
||||
contentHmac,
|
||||
contentType,
|
||||
encContentIv: contentIv,
|
||||
encName: name,
|
||||
encNameIv: nameIv,
|
||||
encCreatedAt: createdAt ?? null,
|
||||
encCreatedAtIv: createdAtIv ?? null,
|
||||
encLastModifiedAt: lastModifiedAt,
|
||||
encLastModifiedAtIv: lastModifiedAtIv,
|
||||
},
|
||||
content.stream(),
|
||||
);
|
||||
return text("File uploaded", { headers: { "Content-Type": "text/plain" } });
|
||||
return {
|
||||
userId,
|
||||
parentId: parent,
|
||||
mekVersion,
|
||||
encDek: dek,
|
||||
dekVersion: new Date(dekVersion),
|
||||
hskVersion,
|
||||
contentHmac,
|
||||
contentType,
|
||||
encContentIv: contentIv,
|
||||
encName: name,
|
||||
encNameIv: nameIv,
|
||||
encCreatedAt: createdAt ?? null,
|
||||
encCreatedAtIv: createdAtIv ?? null,
|
||||
encLastModifiedAt: lastModifiedAt,
|
||||
encLastModifiedAtIv: lastModifiedAtIv,
|
||||
} satisfies FileMetadata;
|
||||
};
|
||||
|
||||
export const POST: RequestHandler = async ({ locals, request }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const contentType = request.headers.get("Content-Type");
|
||||
if (!contentType?.startsWith("multipart/form-data") || !request.body) {
|
||||
error(400, "Invalid request body");
|
||||
}
|
||||
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
||||
const handler =
|
||||
<T extends unknown[]>(f: (...args: T) => Promise<void>) =>
|
||||
(...args: T) => {
|
||||
f(...args).catch(reject);
|
||||
};
|
||||
|
||||
let metadata: FileMetadata | null = null;
|
||||
let content: Readable | null = null;
|
||||
const checksum = new Promise<string>((resolveChecksum, rejectChecksum) => {
|
||||
bb.on(
|
||||
"field",
|
||||
handler(async (fieldname, val) => {
|
||||
if (fieldname === "metadata") {
|
||||
if (!metadata) {
|
||||
// Ignore subsequent metadata fields
|
||||
metadata = parseFileMetadata(userId, val);
|
||||
}
|
||||
} else if (fieldname === "checksum") {
|
||||
resolveChecksum(val); // Ignore subsequent checksum fields
|
||||
} else {
|
||||
error(400, "Invalid request body");
|
||||
}
|
||||
}),
|
||||
);
|
||||
bb.on(
|
||||
"file",
|
||||
handler(async (fieldname, file) => {
|
||||
if (fieldname !== "content") error(400, "Invalid request body");
|
||||
if (!metadata || content) error(400, "Invalid request body");
|
||||
content = file;
|
||||
|
||||
await uploadFile(metadata, content, checksum);
|
||||
resolve(text("File uploaded", { headers: { "Content-Type": "text/plain" } }));
|
||||
}),
|
||||
);
|
||||
bb.on("finish", () => rejectChecksum(new Error("Invalid request body")));
|
||||
bb.on("error", (e) => {
|
||||
content?.emit("error", e) ?? reject(e);
|
||||
rejectChecksum(e);
|
||||
});
|
||||
});
|
||||
|
||||
request.body!.pipeTo(Writable.toWeb(bb)).catch(() => {}); // busboy will handle the error
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user