mirror of
https://github.com/kmc7468/arkvault.git
synced 2026-02-04 08:06:56 +00:00
파일 페이지에서의 네트워크 호출 최적화
This commit is contained in:
97
src/lib/modules/file/download.svelte.ts
Normal file
97
src/lib/modules/file/download.svelte.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import axios from "axios";
|
||||
import { limitFunction } from "p-limit";
|
||||
import { decryptData } from "$lib/modules/crypto";
|
||||
|
||||
export interface FileDownloadState {
|
||||
id: number;
|
||||
status:
|
||||
| "download-pending"
|
||||
| "downloading"
|
||||
| "decryption-pending"
|
||||
| "decrypting"
|
||||
| "decrypted"
|
||||
| "canceled"
|
||||
| "error";
|
||||
progress?: number;
|
||||
rate?: number;
|
||||
estimated?: number;
|
||||
result?: ArrayBuffer;
|
||||
}
|
||||
|
||||
export type LiveFileDownloadState = FileDownloadState & {
|
||||
status: "download-pending" | "downloading" | "decryption-pending" | "decrypting";
|
||||
};
|
||||
|
||||
let downloadingFiles: FileDownloadState[] = $state([]);
|
||||
|
||||
export const isFileDownloading = (
|
||||
status: FileDownloadState["status"],
|
||||
): status is LiveFileDownloadState["status"] =>
|
||||
["download-pending", "downloading", "decryption-pending", "decrypting"].includes(status);
|
||||
|
||||
export const getFileDownloadState = (fileId: number) => {
|
||||
return downloadingFiles.find((file) => file.id === fileId && isFileDownloading(file.status));
|
||||
};
|
||||
|
||||
export const getDownloadingFiles = () => {
|
||||
return downloadingFiles.filter((file): file is LiveFileDownloadState =>
|
||||
isFileDownloading(file.status),
|
||||
);
|
||||
};
|
||||
|
||||
export const clearDownloadedFiles = () => {
|
||||
downloadingFiles = downloadingFiles.filter((file) => isFileDownloading(file.status));
|
||||
};
|
||||
|
||||
const requestFileDownload = limitFunction(
|
||||
async (state: FileDownloadState, id: number) => {
|
||||
state.status = "download-pending";
|
||||
|
||||
const res = await axios.get(`/api/file/${id}/download`, {
|
||||
responseType: "arraybuffer",
|
||||
onDownloadProgress: ({ progress, rate, estimated }) => {
|
||||
state.progress = progress;
|
||||
state.rate = rate;
|
||||
state.estimated = estimated;
|
||||
},
|
||||
});
|
||||
const fileEncrypted: ArrayBuffer = res.data;
|
||||
|
||||
state.status = "decryption-pending";
|
||||
return fileEncrypted;
|
||||
},
|
||||
{ concurrency: 1 },
|
||||
);
|
||||
|
||||
const decryptFile = limitFunction(
|
||||
async (
|
||||
state: FileDownloadState,
|
||||
fileEncrypted: ArrayBuffer,
|
||||
fileEncryptedIv: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
state.status = "decrypting";
|
||||
|
||||
const fileBuffer = await decryptData(fileEncrypted, fileEncryptedIv, dataKey);
|
||||
|
||||
state.status = "decrypted";
|
||||
state.result = fileBuffer;
|
||||
return fileBuffer;
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
export const downloadFile = async (id: number, fileEncryptedIv: string, dataKey: CryptoKey) => {
|
||||
downloadingFiles.push({
|
||||
id,
|
||||
status: "download-pending",
|
||||
});
|
||||
const state = downloadingFiles.at(-1)!;
|
||||
|
||||
try {
|
||||
return await decryptFile(state, await requestFileDownload(state, id), fileEncryptedIv, dataKey);
|
||||
} catch (e) {
|
||||
state.status = "error";
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./cache";
|
||||
export * from "./download";
|
||||
export * from "./download.svelte";
|
||||
export * from "./upload.svelte";
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import {
|
||||
getFileInfo as getFileInfoFromIndexedDB,
|
||||
storeFileInfo,
|
||||
deleteFileInfo,
|
||||
} from "$lib/indexedDB";
|
||||
import { unwrapDataKey, decryptString } from "$lib/modules/crypto";
|
||||
import { trpc, isTRPCClientError } from "$trpc/client";
|
||||
|
||||
export interface FileInfo {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
dataKey?: CryptoKey;
|
||||
dataKeyVersion?: Date;
|
||||
contentType: string;
|
||||
contentIv?: string;
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
categoryIds: number[];
|
||||
}
|
||||
|
||||
const fileInfoStore = new Map<number, Writable<FileInfo | null>>();
|
||||
|
||||
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,
|
||||
) => {
|
||||
let metadata;
|
||||
try {
|
||||
metadata = await trpc().file.get.query({ id });
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
info.set(null);
|
||||
await deleteFileInfo(id);
|
||||
return;
|
||||
}
|
||||
throw new Error("Failed to fetch file information");
|
||||
}
|
||||
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,
|
||||
parentId: metadata.parent,
|
||||
dataKey,
|
||||
dataKeyVersion: new Date(metadata.dekVersion),
|
||||
contentType: metadata.contentType,
|
||||
contentIv: metadata.contentIv,
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
categoryIds: metadata.categories,
|
||||
});
|
||||
await storeFileInfo({
|
||||
id,
|
||||
parentId: metadata.parent,
|
||||
name,
|
||||
contentType: metadata.contentType,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
categoryIds: metadata.categories,
|
||||
});
|
||||
};
|
||||
|
||||
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); // Intended
|
||||
return info;
|
||||
};
|
||||
@@ -41,12 +41,12 @@ interface RootDirectoryInfo {
|
||||
export type DirectoryInfo = LocalDirectoryInfo | RootDirectoryInfo;
|
||||
export type SubDirectoryInfo = Omit<LocalDirectoryInfo, "parentId" | "subDirectories" | "files">;
|
||||
|
||||
interface FileInfo {
|
||||
export interface FileInfo {
|
||||
id: number;
|
||||
parentId: DirectoryId;
|
||||
dataKey?: DataKey;
|
||||
contentType: string;
|
||||
contentIv: string | undefined;
|
||||
contentIv?: string;
|
||||
name: string;
|
||||
createdAt?: Date;
|
||||
lastModifiedAt: Date;
|
||||
@@ -81,6 +81,7 @@ export type SubCategoryInfo = Omit<
|
||||
>;
|
||||
|
||||
const directoryInfoCache = new Map<DirectoryId, DirectoryInfo | Promise<DirectoryInfo>>();
|
||||
const fileInfoCache = new Map<number, FileInfo | Promise<FileInfo>>();
|
||||
const categoryInfoCache = new Map<CategoryId, CategoryInfo | Promise<CategoryInfo>>();
|
||||
|
||||
export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => {
|
||||
@@ -197,6 +198,100 @@ const decryptDate = async (ciphertext: string, iv: string, dataKey: CryptoKey) =
|
||||
return new Date(parseInt(await decryptString(ciphertext, iv, dataKey), 10));
|
||||
};
|
||||
|
||||
export const getFileInfo = async (id: number, masterKey: CryptoKey) => {
|
||||
const info = fileInfoCache.get(id);
|
||||
if (info instanceof Promise) {
|
||||
return info;
|
||||
}
|
||||
|
||||
const { promise, resolve } = Promise.withResolvers<FileInfo>();
|
||||
if (!info) {
|
||||
fileInfoCache.set(id, promise);
|
||||
}
|
||||
|
||||
monotonicResolve(
|
||||
[!info && fetchFileInfoFromIndexedDB(id), fetchFileInfoFromServer(id, masterKey)],
|
||||
(fileInfo) => {
|
||||
let info = fileInfoCache.get(id);
|
||||
if (info instanceof Promise) {
|
||||
const state = $state(fileInfo);
|
||||
fileInfoCache.set(id, state);
|
||||
resolve(state);
|
||||
} else {
|
||||
Object.assign(info!, fileInfo);
|
||||
resolve(info!);
|
||||
}
|
||||
},
|
||||
);
|
||||
return info ?? promise;
|
||||
};
|
||||
|
||||
const fetchFileInfoFromIndexedDB = async (id: number): Promise<FileInfo | undefined> => {
|
||||
const file = await getFileInfoFromIndexedDB(id);
|
||||
const categories = await Promise.all(
|
||||
file?.categoryIds.map(async (categoryId) => {
|
||||
const categoryInfo = await getCategoryInfoFromIndexedDB(categoryId);
|
||||
return categoryInfo ? { id: categoryId, name: categoryInfo.name } : undefined;
|
||||
}) ?? [],
|
||||
);
|
||||
|
||||
if (file) {
|
||||
return {
|
||||
id,
|
||||
parentId: file.parentId,
|
||||
contentType: file.contentType,
|
||||
name: file.name,
|
||||
createdAt: file.createdAt,
|
||||
lastModifiedAt: file.lastModifiedAt,
|
||||
categories: categories.filter((category) => !!category),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFileInfoFromServer = async (
|
||||
id: number,
|
||||
masterKey: CryptoKey,
|
||||
): Promise<FileInfo | undefined> => {
|
||||
try {
|
||||
const { categories: categoriesRaw, ...metadata } = await trpc().file.get.query({ id });
|
||||
const categories = await Promise.all(
|
||||
categoriesRaw.map(async (category) => {
|
||||
const { dataKey } = await unwrapDataKey(category.dek, masterKey);
|
||||
const name = await decryptString(category.name, category.nameIv, dataKey);
|
||||
return { id: category.id, name };
|
||||
}),
|
||||
);
|
||||
|
||||
const { dataKey } = await unwrapDataKey(metadata.dek, masterKey);
|
||||
const [name, createdAt, lastModifiedAt] = await Promise.all([
|
||||
decryptString(metadata.name, metadata.nameIv, dataKey),
|
||||
metadata.createdAt
|
||||
? decryptDate(metadata.createdAt, metadata.createdAtIv!, dataKey)
|
||||
: undefined,
|
||||
decryptDate(metadata.lastModifiedAt, metadata.lastModifiedAtIv, dataKey),
|
||||
]);
|
||||
|
||||
return {
|
||||
id,
|
||||
parentId: metadata.parent,
|
||||
dataKey: { key: dataKey, version: new Date(metadata.dekVersion) },
|
||||
contentType: metadata.contentType,
|
||||
contentIv: metadata.contentIv,
|
||||
name,
|
||||
createdAt,
|
||||
lastModifiedAt,
|
||||
categories,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isTRPCClientError(e) && e.data?.code === "NOT_FOUND") {
|
||||
fileInfoCache.delete(id);
|
||||
await deleteFileInfo(id);
|
||||
return;
|
||||
}
|
||||
throw new Error("Failed to fetch file information");
|
||||
}
|
||||
};
|
||||
|
||||
export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => {
|
||||
const info = categoryInfoCache.get(id);
|
||||
if (info instanceof Promise) {
|
||||
|
||||
Reference in New Issue
Block a user