diff --git a/src/lib/modules/file/download.ts b/src/lib/modules/file/download.ts new file mode 100644 index 0000000..b0efb30 --- /dev/null +++ b/src/lib/modules/file/download.ts @@ -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, 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, + 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({ + 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; + } +}; diff --git a/src/lib/modules/file/index.ts b/src/lib/modules/file/index.ts index bb3d0e6..42a5613 100644 --- a/src/lib/modules/file/index.ts +++ b/src/lib/modules/file/index.ts @@ -1,2 +1,3 @@ export * from "./cache"; +export * from "./download"; export * from "./upload"; diff --git a/src/lib/modules/file/upload.ts b/src/lib/modules/file/upload.ts index b24f444..4a6b8a6 100644 --- a/src/lib/modules/file/upload.ts +++ b/src/lib/modules/file/upload.ts @@ -120,7 +120,7 @@ const encryptFile = limitFunction( { concurrency: 4 }, ); -const uploadFileInternal = limitFunction( +const requestFileUpload = limitFunction( async (status: Writable, form: FormData) => { status.update((value) => { value.status = "uploading"; @@ -209,7 +209,7 @@ export const uploadFile = async ( ); form.set("content", new Blob([fileEncrypted.ciphertext])); - await uploadFileInternal(status, form); + await requestFileUpload(status, form); return true; } catch (e) { status.update((value) => { diff --git a/src/lib/stores/file.ts b/src/lib/stores/file.ts index d3234d8..4dd2df4 100644 --- a/src/lib/stores/file.ts +++ b/src/lib/stores/file.ts @@ -15,4 +15,22 @@ export interface FileUploadStatus { estimated?: number; } +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 fileUploadStatusStore = writable[]>([]); + +export const fileDownloadStatusStore = writable[]>([]); diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 4d735ba..8d8e1eb 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -1,66 +1,84 @@ @@ -69,23 +87,24 @@
-
+ +
{#snippet viewerLoading(message: string)}

{message}

{/snippet} - {#if $info && contentType === "image"} - {#if contentUrl} - {$info.name} + {#if $info && viewerType === "image"} + {#if fileBlobUrl} + {$info.name} {:else} {@render viewerLoading("이미지를 불러오고 있어요.")} {/if} - {:else if contentType === "video"} - {#if contentUrl} + {:else if viewerType === "video"} + {#if fileBlobUrl} - + {:else} {@render viewerLoading("비디오를 불러오고 있어요.")} {/if} diff --git a/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte new file mode 100644 index 0000000..f1b4d89 --- /dev/null +++ b/src/routes/(fullscreen)/file/[id]/DownloadStatus.svelte @@ -0,0 +1,32 @@ + + +{#if $info && $info.status !== "decrypted" && $info.status !== "canceled" && $info.status !== "error"} +
+

+ {#if $info.status === "download-pending"} + 다운로드를 기다리는 중 + {:else if $info.status === "downloading"} + 다운로드하는 중 + {:else if $info.status === "decryption-pending"} + 복호화를 기다리는 중 + {:else if $info.status === "decrypting"} + 복호화하는 중 + {/if} +

+

+ {#if $info.status === "downloading"} + 전송됨 {formatDownloadProgress($info.progress)} · {formatDownloadRate($info.rate)} + {/if} +

+
+{/if} diff --git a/src/routes/(fullscreen)/file/[id]/service.ts b/src/routes/(fullscreen)/file/[id]/service.ts index 55a5806..32ee666 100644 --- a/src/routes/(fullscreen)/file/[id]/service.ts +++ b/src/routes/(fullscreen)/file/[id]/service.ts @@ -1,5 +1,5 @@ -import { getFileCache, storeFileCache } from "$lib/modules/file"; -import { decryptData } from "$lib/modules/crypto"; +import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file"; +import { formatFileSize } from "$lib/modules/util"; export const requestFileDownload = async ( fileId: number, @@ -9,28 +9,15 @@ export const requestFileDownload = async ( const cache = await getFileCache(fileId); if (cache) return cache; - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.responseType = "arraybuffer"; - - 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); - await storeFileCache(fileId, 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; +}; + +export const formatDownloadProgress = (progress?: number) => { + return `${Math.floor((progress ?? 0) * 100)}%`; +}; + +export const formatDownloadRate = (rate?: number) => { + return `${formatFileSize((rate ?? 0) / 8)}/s`; };