mirror of
https://github.com/kmc7468/arkvault.git
synced 2025-12-12 21:08:46 +00:00
Merge pull request #11 from kmc7468/add-file-thumbnail
파일에 대한 썸네일 기능 구현
This commit is contained in:
@@ -10,6 +10,7 @@ node_modules
|
||||
/build
|
||||
/data
|
||||
/library
|
||||
/thumbnails
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -11,3 +11,4 @@ SESSION_EXPIRES=
|
||||
USER_CLIENT_CHALLENGE_EXPIRES=
|
||||
SESSION_UPGRADE_CHALLENGE_EXPIRES=
|
||||
LIBRARY_PATH=
|
||||
THUMBNAILS_PATH=
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,6 +9,7 @@ node_modules
|
||||
/build
|
||||
/data
|
||||
/library
|
||||
/thumbnails
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -7,6 +7,7 @@ services:
|
||||
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
|
||||
volumes:
|
||||
- ./data/library:/app/data/library
|
||||
- ./data/thumbnails:/app/data/thumbnails
|
||||
environment:
|
||||
# ArkVault
|
||||
- DATABASE_HOST=database
|
||||
@@ -17,6 +18,7 @@ services:
|
||||
- USER_CLIENT_CHALLENGE_EXPIRES
|
||||
- SESSION_UPGRADE_CHALLENGE_EXPIRES
|
||||
- LIBRARY_PATH=/app/data/library
|
||||
- THUMBNAILS_PATH=/app/data/thumbnails
|
||||
# SvelteKit
|
||||
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
|
||||
- XFF_DEPTH=${TRUST_PROXY:-}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"@sveltejs/kit": "^2.22.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.4",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node-schedule": "^2.1.7",
|
||||
"@types/pg": "^8.15.4",
|
||||
"autoprefixer": "^10.4.21",
|
||||
@@ -37,6 +37,7 @@
|
||||
"globals": "^16.3.0",
|
||||
"heic2any": "^0.0.4",
|
||||
"kysely-ctl": "^0.13.1",
|
||||
"lru-cache": "^11.1.0",
|
||||
"mime": "^4.0.7",
|
||||
"p-limit": "^6.2.0",
|
||||
"prettier": "^3.6.2",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -52,8 +52,8 @@ importers:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7
|
||||
'@types/ms':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
specifier: ^0.7.34
|
||||
version: 0.7.34
|
||||
'@types/node-schedule':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.7
|
||||
@@ -96,6 +96,9 @@ importers:
|
||||
kysely-ctl:
|
||||
specifier: ^0.13.1
|
||||
version: 0.13.1(kysely@0.28.2)
|
||||
lru-cache:
|
||||
specifier: ^11.1.0
|
||||
version: 11.1.0
|
||||
mime:
|
||||
specifier: ^4.0.7
|
||||
version: 4.0.7
|
||||
@@ -592,8 +595,8 @@ packages:
|
||||
'@types/json-schema@7.0.15':
|
||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
'@types/ms@0.7.34':
|
||||
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
|
||||
|
||||
'@types/node-schedule@2.1.7':
|
||||
resolution: {integrity: sha512-G7Z3R9H7r3TowoH6D2pkzUHPhcJrDF4Jz1JOQ80AX0K2DWTHoN9VC94XzFAPNMdbW9TBzMZ3LjpFi7RYdbxtXA==}
|
||||
@@ -1290,6 +1293,10 @@ packages:
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
lru-cache@11.1.0:
|
||||
resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
luxon@3.6.1:
|
||||
resolution: {integrity: sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -2364,7 +2371,7 @@ snapshots:
|
||||
|
||||
'@types/json-schema@7.0.15': {}
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
'@types/ms@0.7.34': {}
|
||||
|
||||
'@types/node-schedule@2.1.7':
|
||||
dependencies:
|
||||
@@ -3099,6 +3106,8 @@ snapshots:
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@11.1.0: {}
|
||||
|
||||
luxon@3.6.1: {}
|
||||
|
||||
magic-string@0.30.17:
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
import type { Snippet } from "svelte";
|
||||
import type { ClassValue } from "svelte/elements";
|
||||
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { children, class: className }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex flex-grow flex-col justify-between px-4">
|
||||
<div class={["flex flex-grow flex-col justify-between px-4", className]}>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -10,19 +10,38 @@
|
||||
name: string;
|
||||
subtext?: string;
|
||||
textClass?: ClassValue;
|
||||
thumbnail?: string;
|
||||
type: "directory" | "file";
|
||||
}
|
||||
|
||||
let { class: className, name, subtext, textClass: textClassName, type }: Props = $props();
|
||||
let {
|
||||
class: className,
|
||||
name,
|
||||
subtext,
|
||||
textClass: textClassName,
|
||||
thumbnail,
|
||||
type,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#snippet iconSnippet()}
|
||||
<div class="flex h-10 w-10 items-center justify-center text-xl">
|
||||
{#if thumbnail}
|
||||
<img src={thumbnail} alt={name} loading="lazy" class="aspect-square rounded object-cover" />
|
||||
{:else if type === "directory"}
|
||||
<IconFolder />
|
||||
{:else}
|
||||
<IconDraft class="text-blue-400" />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet subtextSnippet()}
|
||||
{subtext}
|
||||
{/snippet}
|
||||
|
||||
<IconLabel
|
||||
icon={type === "directory" ? IconFolder : IconDraft}
|
||||
iconClass={type === "file" ? "text-blue-400" : undefined}
|
||||
{iconSnippet}
|
||||
subtext={subtext ? subtextSnippet : undefined}
|
||||
class={className}
|
||||
textClass={textClassName}
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
interface Props {
|
||||
children: Snippet;
|
||||
class?: ClassValue;
|
||||
icon: Component<SvelteHTMLElements["svg"]>;
|
||||
icon?: Component<SvelteHTMLElements["svg"]>;
|
||||
iconClass?: ClassValue;
|
||||
iconSnippet?: Snippet;
|
||||
subtext?: Snippet;
|
||||
textClass?: ClassValue;
|
||||
}
|
||||
@@ -16,15 +17,22 @@
|
||||
class: className,
|
||||
icon: Icon,
|
||||
iconClass: iconClassName,
|
||||
iconSnippet,
|
||||
subtext,
|
||||
textClass: textClassName,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={["flex items-center gap-x-4", className]}>
|
||||
<div class={["flex-shrink-0 text-lg", iconClassName]}>
|
||||
<Icon />
|
||||
</div>
|
||||
{#if iconSnippet}
|
||||
<div class={["flex-shrink-0", iconClassName]}>
|
||||
{@render iconSnippet()}
|
||||
</div>
|
||||
{:else if Icon}
|
||||
<div class={["flex-shrink-0 text-lg", iconClassName]}>
|
||||
<Icon />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-grow flex-col overflow-x-hidden text-left">
|
||||
<p class={["truncate font-medium", textClassName]}>
|
||||
{@render children()}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ActionEntryButton } from "$lib/components/atoms";
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import type { SelectedFile } from "./service";
|
||||
import { requestFileThumbnailDownload, type SelectedFile } from "./service";
|
||||
|
||||
import IconClose from "~icons/material-symbols/close";
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
let { info, onclick, onRemoveClick }: Props = $props();
|
||||
|
||||
let thumbnail: string | undefined = $state();
|
||||
|
||||
const openFile = () => {
|
||||
const { id, dataKey, dataKeyVersion, name } = $info as FileInfo;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
@@ -28,6 +30,21 @@
|
||||
|
||||
onRemoveClick!({ id, dataKey, dataKeyVersion, name });
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if ($info?.dataKey) {
|
||||
requestFileThumbnailDownload($info.id, $info.dataKey)
|
||||
.then((thumbnailUrl) => {
|
||||
thumbnail = thumbnailUrl ?? undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: Error Handling
|
||||
thumbnail = undefined;
|
||||
});
|
||||
} else {
|
||||
thumbnail = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $info}
|
||||
@@ -37,6 +54,6 @@
|
||||
actionButtonIcon={onRemoveClick && IconClose}
|
||||
onActionButtonClick={removeFile}
|
||||
>
|
||||
<DirectoryEntryLabel type="file" name={$info.name} />
|
||||
<DirectoryEntryLabel type="file" {thumbnail} name={$info.name} />
|
||||
</ActionEntryButton>
|
||||
{/if}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export { requestFileThumbnailDownload } from "$lib/services/file";
|
||||
|
||||
export interface SelectedFile {
|
||||
id: number;
|
||||
dataKey: CryptoKey;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { LRUCache } from "lru-cache";
|
||||
import {
|
||||
getFileCacheIndex as getFileCacheIndexFromIndexedDB,
|
||||
storeFileCacheIndex,
|
||||
deleteFileCacheIndex,
|
||||
type FileCacheIndex,
|
||||
} from "$lib/indexedDB";
|
||||
import { readFile, writeFile, deleteFile } from "$lib/modules/opfs";
|
||||
import { readFile, writeFile, deleteFile, deleteDirectory } from "$lib/modules/opfs";
|
||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||
|
||||
const fileCacheIndex = new Map<number, FileCacheIndex>();
|
||||
const loadedThumbnails = new LRUCache<number, string>({ max: 100 });
|
||||
|
||||
export const prepareFileCache = async () => {
|
||||
for (const cache of await getFileCacheIndexFromIndexedDB()) {
|
||||
@@ -48,3 +51,32 @@ export const deleteFileCache = async (fileId: number) => {
|
||||
await deleteFile(`/cache/${fileId}`);
|
||||
await deleteFileCacheIndex(fileId);
|
||||
};
|
||||
|
||||
export const getFileThumbnailCache = async (fileId: number) => {
|
||||
const thumbnail = loadedThumbnails.get(fileId);
|
||||
if (thumbnail) {
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
const thumbnailBuffer = await readFile(`/thumbnail/file/${fileId}`);
|
||||
if (!thumbnailBuffer) return null;
|
||||
|
||||
const thumbnailUrl = getThumbnailUrl(thumbnailBuffer);
|
||||
loadedThumbnails.set(fileId, thumbnailUrl);
|
||||
return thumbnailUrl;
|
||||
};
|
||||
|
||||
export const storeFileThumbnailCache = async (fileId: number, thumbnailBuffer: ArrayBuffer) => {
|
||||
await writeFile(`/thumbnail/file/${fileId}`, thumbnailBuffer);
|
||||
loadedThumbnails.set(fileId, getThumbnailUrl(thumbnailBuffer));
|
||||
};
|
||||
|
||||
export const deleteFileThumbnailCache = async (fileId: number) => {
|
||||
loadedThumbnails.delete(fileId);
|
||||
await deleteFile(`/thumbnail/file/${fileId}`);
|
||||
};
|
||||
|
||||
export const deleteAllFileThumbnailCaches = async () => {
|
||||
loadedThumbnails.clear();
|
||||
await deleteDirectory("/thumbnail/file");
|
||||
};
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
digestMessage,
|
||||
signMessageHmac,
|
||||
} from "$lib/modules/crypto";
|
||||
import { generateThumbnail } from "$lib/modules/thumbnail";
|
||||
import type {
|
||||
DuplicateFileScanRequest,
|
||||
DuplicateFileScanResponse,
|
||||
FileThumbnailUploadRequest,
|
||||
FileUploadRequest,
|
||||
FileUploadResponse,
|
||||
} from "$lib/server/schemas";
|
||||
@@ -106,6 +108,10 @@ const encryptFile = limitFunction(
|
||||
createdAt && (await encryptString(createdAt.getTime().toString(), dataKey));
|
||||
const lastModifiedAtEncrypted = await encryptString(file.lastModified.toString(), dataKey);
|
||||
|
||||
const thumbnail = await generateThumbnail(fileBuffer, fileType);
|
||||
const thumbnailBuffer = await thumbnail?.arrayBuffer();
|
||||
const thumbnailEncrypted = thumbnailBuffer && (await encryptData(thumbnailBuffer, dataKey));
|
||||
|
||||
status.update((value) => {
|
||||
value.status = "upload-pending";
|
||||
return value;
|
||||
@@ -120,13 +126,14 @@ const encryptFile = limitFunction(
|
||||
nameEncrypted,
|
||||
createdAtEncrypted,
|
||||
lastModifiedAtEncrypted,
|
||||
thumbnail: thumbnailEncrypted && { plaintext: thumbnailBuffer, ...thumbnailEncrypted },
|
||||
};
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
const requestFileUpload = limitFunction(
|
||||
async (status: Writable<FileUploadStatus>, form: FormData) => {
|
||||
async (status: Writable<FileUploadStatus>, form: FormData, thumbnailForm: FormData | null) => {
|
||||
status.update((value) => {
|
||||
value.status = "uploading";
|
||||
return value;
|
||||
@@ -144,6 +151,15 @@ const requestFileUpload = limitFunction(
|
||||
});
|
||||
const { file }: FileUploadResponse = res.data;
|
||||
|
||||
if (thumbnailForm) {
|
||||
try {
|
||||
await axios.post(`/api/file/${file}/thumbnail/upload`, thumbnailForm);
|
||||
} catch (e) {
|
||||
// TODO
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
status.update((value) => {
|
||||
value.status = "uploaded";
|
||||
return value;
|
||||
@@ -160,7 +176,9 @@ export const uploadFile = async (
|
||||
hmacSecret: HmacSecret,
|
||||
masterKey: MasterKey,
|
||||
onDuplicate: () => Promise<boolean>,
|
||||
): Promise<{ fileId: number; fileBuffer: ArrayBuffer } | undefined> => {
|
||||
): Promise<
|
||||
{ fileId: number; fileBuffer: ArrayBuffer; thumbnailBuffer?: ArrayBuffer } | undefined
|
||||
> => {
|
||||
const status = writable<FileUploadStatus>({
|
||||
name: file.name,
|
||||
parentId,
|
||||
@@ -198,6 +216,7 @@ export const uploadFile = async (
|
||||
nameEncrypted,
|
||||
createdAtEncrypted,
|
||||
lastModifiedAtEncrypted,
|
||||
thumbnail,
|
||||
} = await encryptFile(status, file, fileBuffer, masterKey);
|
||||
|
||||
const form = new FormData();
|
||||
@@ -218,13 +237,26 @@ export const uploadFile = async (
|
||||
createdAtIv: createdAtEncrypted?.iv,
|
||||
lastModifiedAt: lastModifiedAtEncrypted.ciphertext,
|
||||
lastModifiedAtIv: lastModifiedAtEncrypted.iv,
|
||||
} as FileUploadRequest),
|
||||
} satisfies FileUploadRequest),
|
||||
);
|
||||
form.set("content", new Blob([fileEncrypted.ciphertext]));
|
||||
form.set("checksum", fileEncryptedHash);
|
||||
|
||||
const { fileId } = await requestFileUpload(status, form);
|
||||
return { fileId, fileBuffer };
|
||||
let thumbnailForm = null;
|
||||
if (thumbnail) {
|
||||
thumbnailForm = new FormData();
|
||||
thumbnailForm.set(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
dekVersion: dataKeyVersion.toISOString(),
|
||||
contentIv: thumbnail.iv,
|
||||
} satisfies FileThumbnailUploadRequest),
|
||||
);
|
||||
thumbnailForm.set("content", new Blob([thumbnail.ciphertext]));
|
||||
}
|
||||
|
||||
const { fileId } = await requestFileUpload(status, form, thumbnailForm);
|
||||
return { fileId, fileBuffer, thumbnailBuffer: thumbnail?.plaintext };
|
||||
} catch (e) {
|
||||
status.update((value) => {
|
||||
value.status = "error";
|
||||
|
||||
@@ -59,3 +59,39 @@ export const deleteFile = async (path: string) => {
|
||||
|
||||
await parentHandle.removeEntry(filename);
|
||||
};
|
||||
|
||||
const getDirectoryHandle = async (path: string) => {
|
||||
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;
|
||||
let parentHandle;
|
||||
for (const part of parts.slice(1)) {
|
||||
if (!part) continue;
|
||||
parentHandle = directoryHandle;
|
||||
directoryHandle = await directoryHandle.getDirectoryHandle(part);
|
||||
}
|
||||
return { directoryHandle, parentHandle };
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === "NotFoundError") {
|
||||
return {};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDirectory = async (path: string) => {
|
||||
const { directoryHandle, parentHandle } = await getDirectoryHandle(path);
|
||||
if (!parentHandle) return;
|
||||
|
||||
await parentHandle.removeEntry(directoryHandle.name, { recursive: true });
|
||||
};
|
||||
|
||||
111
src/lib/modules/thumbnail.ts
Normal file
111
src/lib/modules/thumbnail.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { encodeToBase64 } from "$lib/modules/crypto";
|
||||
|
||||
const scaleSize = (width: number, height: number, targetSize: number) => {
|
||||
if (width <= targetSize || height <= targetSize) {
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
const scale = targetSize / Math.min(width, height);
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale),
|
||||
};
|
||||
};
|
||||
|
||||
const generateImageThumbnail = (imageUrl: string) => {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const { width, height } = scaleSize(image.width, image.height, 250);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return reject(new Error("Failed to generate thumbnail"));
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error("Failed to generate thumbnail"));
|
||||
}
|
||||
}, "image/webp");
|
||||
};
|
||||
image.onerror = reject;
|
||||
|
||||
image.src = imageUrl;
|
||||
});
|
||||
};
|
||||
|
||||
const generateVideoThumbnail = (videoUrl: string, time = 0) => {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const video = document.createElement("video");
|
||||
video.onloadeddata = () => {
|
||||
video.currentTime = time;
|
||||
};
|
||||
video.onseeked = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const { width, height } = scaleSize(video.videoWidth, video.videoHeight, 250);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return reject(new Error("Failed to generate thumbnail"));
|
||||
}
|
||||
|
||||
context.drawImage(video, 0, 0, width, height);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error("Failed to generate thumbnail"));
|
||||
}
|
||||
}, "image/webp");
|
||||
};
|
||||
video.onerror = reject;
|
||||
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.src = videoUrl;
|
||||
});
|
||||
};
|
||||
|
||||
export const generateThumbnail = async (fileBuffer: ArrayBuffer, fileType: string) => {
|
||||
let url;
|
||||
try {
|
||||
if (fileType === "image/heic") {
|
||||
const { default: heic2any } = await import("heic2any");
|
||||
url = URL.createObjectURL(
|
||||
(await heic2any({
|
||||
blob: new Blob([fileBuffer], { type: fileType }),
|
||||
toType: "image/png",
|
||||
})) as Blob,
|
||||
);
|
||||
return await generateImageThumbnail(url);
|
||||
} else if (fileType.startsWith("image/")) {
|
||||
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
|
||||
return await generateImageThumbnail(url);
|
||||
} else if (fileType.startsWith("video/")) {
|
||||
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
|
||||
return await generateVideoThumbnail(url);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
if (url) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const getThumbnailUrl = (thumbnailBuffer: ArrayBuffer) => {
|
||||
return `data:image/webp;base64,${encodeToBase64(thumbnailBuffer)}`;
|
||||
};
|
||||
@@ -163,16 +163,24 @@ export const unregisterDirectory = async (userId: number, directoryId: number) =
|
||||
.setIsolationLevel("repeatable read") // TODO: Sufficient?
|
||||
.execute(async (trx) => {
|
||||
const unregisterFiles = async (parentId: number) => {
|
||||
return await trx
|
||||
const files = await trx
|
||||
.selectFrom("file")
|
||||
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
|
||||
.select(["file.id", "file.path", "thumbnail.path as thumbnailPath"])
|
||||
.where("file.parent_id", "=", parentId)
|
||||
.where("file.user_id", "=", userId)
|
||||
.forUpdate("file")
|
||||
.execute();
|
||||
await trx
|
||||
.deleteFrom("file")
|
||||
.where("parent_id", "=", parentId)
|
||||
.where("user_id", "=", userId)
|
||||
.returning(["id", "path"])
|
||||
.execute();
|
||||
return files;
|
||||
};
|
||||
const unregisterDirectoryRecursively = async (
|
||||
directoryId: number,
|
||||
): Promise<{ id: number; path: string }[]> => {
|
||||
): Promise<{ id: number; path: string; thumbnailPath: string | null }[]> => {
|
||||
const files = await unregisterFiles(directoryId);
|
||||
const subDirectories = await trx
|
||||
.selectFrom("directory")
|
||||
@@ -327,7 +335,8 @@ export const getAllFilesByCategory = async (
|
||||
.where("user_id", "=", userId)
|
||||
.where("file_id", "is not", null)
|
||||
.$narrowType<{ file_id: NotNull }>()
|
||||
.orderBy(["file_id", "depth"])
|
||||
.orderBy("file_id")
|
||||
.orderBy("depth")
|
||||
.execute();
|
||||
return files.map(({ file_id, depth }) => ({ id: file_id, isRecursive: depth > 0 }));
|
||||
};
|
||||
@@ -344,7 +353,7 @@ export const getAllFileIdsByContentHmac = async (
|
||||
.where("hmac_secret_key_version", "=", hskVersion)
|
||||
.where("content_hmac", "=", contentHmac)
|
||||
.execute();
|
||||
return files.map(({ id }) => ({ id }));
|
||||
return files.map(({ id }) => id);
|
||||
};
|
||||
|
||||
export const getFile = async (userId: number, fileId: number) => {
|
||||
@@ -416,16 +425,22 @@ export const setFileEncName = async (
|
||||
};
|
||||
|
||||
export const unregisterFile = async (userId: number, fileId: number) => {
|
||||
const file = await db
|
||||
.deleteFrom("file")
|
||||
.where("id", "=", fileId)
|
||||
.where("user_id", "=", userId)
|
||||
.returning("path")
|
||||
.executeTakeFirst();
|
||||
if (!file) {
|
||||
throw new IntegrityError("File not found");
|
||||
}
|
||||
return { path: file.path };
|
||||
return await db.transaction().execute(async (trx) => {
|
||||
const file = await trx
|
||||
.selectFrom("file")
|
||||
.leftJoin("thumbnail", "file.id", "thumbnail.file_id")
|
||||
.select(["file.path", "thumbnail.path as thumbnailPath"])
|
||||
.where("file.id", "=", fileId)
|
||||
.where("file.user_id", "=", userId)
|
||||
.forUpdate("file")
|
||||
.executeTakeFirst();
|
||||
if (!file) {
|
||||
throw new IntegrityError("File not found");
|
||||
}
|
||||
|
||||
await trx.deleteFrom("file").where("id", "=", fileId).execute();
|
||||
return file;
|
||||
});
|
||||
};
|
||||
|
||||
export const addFileToCategory = async (fileId: number, categoryId: number) => {
|
||||
|
||||
110
src/lib/server/db/media.ts
Normal file
110
src/lib/server/db/media.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { NotNull } from "kysely";
|
||||
import { IntegrityError } from "./error";
|
||||
import db from "./kysely";
|
||||
|
||||
interface Thumbnail {
|
||||
id: number;
|
||||
path: string;
|
||||
updatedAt: Date;
|
||||
encContentIv: string;
|
||||
}
|
||||
|
||||
interface FileThumbnail extends Thumbnail {
|
||||
fileId: number;
|
||||
}
|
||||
|
||||
export const updateFileThumbnail = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
dekVersion: Date,
|
||||
path: string,
|
||||
encContentIv: string,
|
||||
) => {
|
||||
return await db.transaction().execute(async (trx) => {
|
||||
const file = await trx
|
||||
.selectFrom("file")
|
||||
.select("data_encryption_key_version")
|
||||
.where("id", "=", fileId)
|
||||
.where("user_id", "=", userId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
if (!file) {
|
||||
throw new IntegrityError("File not found");
|
||||
} else if (file.data_encryption_key_version.getTime() !== dekVersion.getTime()) {
|
||||
throw new IntegrityError("Invalid DEK version");
|
||||
}
|
||||
|
||||
const thumbnail = await trx
|
||||
.selectFrom("thumbnail")
|
||||
.select("path as oldPath")
|
||||
.where("file_id", "=", fileId)
|
||||
.limit(1)
|
||||
.forUpdate()
|
||||
.executeTakeFirst();
|
||||
const now = new Date();
|
||||
|
||||
await trx
|
||||
.insertInto("thumbnail")
|
||||
.values({
|
||||
file_id: fileId,
|
||||
path,
|
||||
updated_at: now,
|
||||
encrypted_content_iv: encContentIv,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.column("file_id").doUpdateSet({
|
||||
path,
|
||||
updated_at: now,
|
||||
encrypted_content_iv: encContentIv,
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
return thumbnail?.oldPath ?? null;
|
||||
});
|
||||
};
|
||||
|
||||
export const getFileThumbnail = async (userId: number, fileId: number) => {
|
||||
const thumbnail = await db
|
||||
.selectFrom("thumbnail")
|
||||
.innerJoin("file", "thumbnail.file_id", "file.id")
|
||||
.selectAll("thumbnail")
|
||||
.where("file.id", "=", fileId)
|
||||
.where("file.user_id", "=", userId)
|
||||
.$narrowType<{ file_id: NotNull }>()
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
return thumbnail
|
||||
? ({
|
||||
id: thumbnail.id,
|
||||
fileId: thumbnail.file_id,
|
||||
path: thumbnail.path,
|
||||
encContentIv: thumbnail.encrypted_content_iv,
|
||||
updatedAt: thumbnail.updated_at,
|
||||
} satisfies FileThumbnail)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const getMissingFileThumbnails = async (userId: number, limit: number = 100) => {
|
||||
const files = await db
|
||||
.selectFrom("file")
|
||||
.select("id")
|
||||
.where("user_id", "=", userId)
|
||||
.where((eb) =>
|
||||
eb.or([eb("content_type", "like", "image/%"), eb("content_type", "like", "video/%")]),
|
||||
)
|
||||
.where((eb) =>
|
||||
eb.not(
|
||||
eb.exists(
|
||||
eb
|
||||
.selectFrom("thumbnail")
|
||||
.select("thumbnail.id")
|
||||
.whereRef("thumbnail.file_id", "=", "file.id")
|
||||
.limit(1),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(limit)
|
||||
.execute();
|
||||
return files.map(({ id }) => id);
|
||||
};
|
||||
31
src/lib/server/db/migrations/1738409340-AddThumbnail.ts
Normal file
31
src/lib/server/db/migrations/1738409340-AddThumbnail.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Kysely, sql } from "kysely";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const up = async (db: Kysely<any>) => {
|
||||
// media.ts
|
||||
await db.schema
|
||||
.createTable("thumbnail")
|
||||
.addColumn("id", "integer", (col) => col.primaryKey().generatedAlwaysAsIdentity())
|
||||
.addColumn("directory_id", "integer", (col) =>
|
||||
col.references("directory.id").onDelete("cascade").unique(),
|
||||
)
|
||||
.addColumn("file_id", "integer", (col) =>
|
||||
col.references("file.id").onDelete("cascade").unique(),
|
||||
)
|
||||
.addColumn("category_id", "integer", (col) =>
|
||||
col.references("category.id").onDelete("cascade").unique(),
|
||||
)
|
||||
.addColumn("path", "text", (col) => col.unique().notNull())
|
||||
.addColumn("updated_at", "timestamp(3)", (col) => col.notNull())
|
||||
.addColumn("encrypted_content_iv", "text", (col) => col.notNull())
|
||||
.addCheckConstraint(
|
||||
"thumbnail_ck01",
|
||||
sql`(file_id IS NOT NULL)::integer + (directory_id IS NOT NULL)::integer + (category_id IS NOT NULL)::integer = 1`,
|
||||
)
|
||||
.execute();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const down = async (db: Kysely<any>) => {
|
||||
await db.schema.dropTable("thumbnail").execute();
|
||||
};
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as Initial1737357000 from "./1737357000-Initial";
|
||||
import * as AddFileCategory1737422340 from "./1737422340-AddFileCategory";
|
||||
import * as AddThumbnail1738409340 from "./1738409340-AddThumbnail";
|
||||
|
||||
export default {
|
||||
"1737357000-Initial": Initial1737357000,
|
||||
"1737422340-AddFileCategory": AddFileCategory1737422340,
|
||||
"1738409340-AddThumbnail": AddThumbnail1738409340,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./category";
|
||||
export * from "./client";
|
||||
export * from "./file";
|
||||
export * from "./hsk";
|
||||
export * from "./media";
|
||||
export * from "./mek";
|
||||
export * from "./session";
|
||||
export * from "./user";
|
||||
|
||||
17
src/lib/server/db/schema/media.ts
Normal file
17
src/lib/server/db/schema/media.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Generated } from "kysely";
|
||||
|
||||
interface ThumbnailTable {
|
||||
id: Generated<number>;
|
||||
directory_id: number | null;
|
||||
file_id: number | null;
|
||||
category_id: number | null;
|
||||
path: string;
|
||||
updated_at: Date;
|
||||
encrypted_content_iv: string; // Base64
|
||||
}
|
||||
|
||||
declare module "./index" {
|
||||
interface Database {
|
||||
thumbnail: ThumbnailTable;
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,5 @@ export default {
|
||||
sessionUpgradeExp: ms(env.SESSION_UPGRADE_CHALLENGE_EXPIRES || "5m"),
|
||||
},
|
||||
libraryPath: env.LIBRARY_PATH || "library",
|
||||
thumbnailsPath: env.THUMBNAILS_PATH || "thumbnails",
|
||||
};
|
||||
|
||||
@@ -30,6 +30,18 @@ export const fileRenameRequest = z.object({
|
||||
});
|
||||
export type FileRenameRequest = z.infer<typeof fileRenameRequest>;
|
||||
|
||||
export const fileThumbnailInfoResponse = z.object({
|
||||
updatedAt: z.string().datetime(),
|
||||
contentIv: z.string().base64().nonempty(),
|
||||
});
|
||||
export type FileThumbnailInfoResponse = z.infer<typeof fileThumbnailInfoResponse>;
|
||||
|
||||
export const fileThumbnailUploadRequest = z.object({
|
||||
dekVersion: z.string().datetime(),
|
||||
contentIv: z.string().base64().nonempty(),
|
||||
});
|
||||
export type FileThumbnailUploadRequest = z.infer<typeof fileThumbnailUploadRequest>;
|
||||
|
||||
export const duplicateFileScanRequest = z.object({
|
||||
hskVersion: z.number().int().positive(),
|
||||
contentHmac: z.string().base64().nonempty(),
|
||||
@@ -41,6 +53,11 @@ export const duplicateFileScanResponse = z.object({
|
||||
});
|
||||
export type DuplicateFileScanResponse = z.infer<typeof duplicateFileScanResponse>;
|
||||
|
||||
export const missingThumbnailFileScanResponse = z.object({
|
||||
files: z.number().int().positive().array(),
|
||||
});
|
||||
export type MissingThumbnailFileScanResponse = z.infer<typeof missingThumbnailFileScanResponse>;
|
||||
|
||||
export const fileUploadRequest = z.object({
|
||||
parent: directoryIdSchema,
|
||||
mekVersion: z.number().int().positive(),
|
||||
|
||||
@@ -34,12 +34,19 @@ export const getDirectoryInformation = async (userId: number, directoryId: Direc
|
||||
};
|
||||
};
|
||||
|
||||
const safeUnlink = async (path: string | null) => {
|
||||
if (path) {
|
||||
await unlink(path).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDirectory = async (userId: number, directoryId: number) => {
|
||||
try {
|
||||
const files = await unregisterDirectory(userId, directoryId);
|
||||
return {
|
||||
files: files.map(({ id, path }) => {
|
||||
unlink(path); // Intended
|
||||
files: files.map(({ id, path, thumbnailPath }) => {
|
||||
safeUnlink(path); // Intended
|
||||
safeUnlink(thumbnailPath); // Intended
|
||||
return id;
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -16,6 +16,11 @@ import {
|
||||
getAllFileCategories,
|
||||
type NewFile,
|
||||
} from "$lib/server/db/file";
|
||||
import {
|
||||
updateFileThumbnail,
|
||||
getFileThumbnail,
|
||||
getMissingFileThumbnails,
|
||||
} from "$lib/server/db/media";
|
||||
import type { Ciphertext } from "$lib/server/db/schema";
|
||||
import env from "$lib/server/loadenv";
|
||||
|
||||
@@ -40,10 +45,17 @@ export const getFileInformation = async (userId: number, fileId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
const safeUnlink = async (path: string | null) => {
|
||||
if (path) {
|
||||
await unlink(path).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFile = async (userId: number, fileId: number) => {
|
||||
try {
|
||||
const { path } = await unregisterFile(userId, fileId);
|
||||
unlink(path); // Intended
|
||||
const { path, thumbnailPath } = await unregisterFile(userId, fileId);
|
||||
safeUnlink(path); // Intended
|
||||
safeUnlink(thumbnailPath); // Intended
|
||||
} catch (e) {
|
||||
if (e instanceof IntegrityError && e.message === "File not found") {
|
||||
error(404, "Invalid file id");
|
||||
@@ -85,17 +97,69 @@ export const renameFile = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getFileThumbnailInformation = async (userId: number, fileId: number) => {
|
||||
const thumbnail = await getFileThumbnail(userId, fileId);
|
||||
if (!thumbnail) {
|
||||
error(404, "File or its thumbnail not found");
|
||||
}
|
||||
|
||||
return { updatedAt: thumbnail.updatedAt, encContentIv: thumbnail.encContentIv };
|
||||
};
|
||||
|
||||
export const getFileThumbnailStream = async (userId: number, fileId: number) => {
|
||||
const thumbnail = await getFileThumbnail(userId, fileId);
|
||||
if (!thumbnail) {
|
||||
error(404, "File or its thumbnail not found");
|
||||
}
|
||||
|
||||
const { size } = await stat(thumbnail.path);
|
||||
return {
|
||||
encContentStream: Readable.toWeb(createReadStream(thumbnail.path)),
|
||||
encContentSize: size,
|
||||
};
|
||||
};
|
||||
|
||||
export const uploadFileThumbnail = async (
|
||||
userId: number,
|
||||
fileId: number,
|
||||
dekVersion: Date,
|
||||
encContentIv: string,
|
||||
encContentStream: Readable,
|
||||
) => {
|
||||
const path = `${env.thumbnailsPath}/${userId}/${uuidv4()}`;
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
|
||||
try {
|
||||
await pipeline(encContentStream, createWriteStream(path, { flags: "wx", mode: 0o600 }));
|
||||
|
||||
const oldPath = await updateFileThumbnail(userId, fileId, dekVersion, path, encContentIv);
|
||||
safeUnlink(oldPath); // Intended
|
||||
} catch (e) {
|
||||
await safeUnlink(path);
|
||||
|
||||
if (e instanceof IntegrityError) {
|
||||
if (e.message === "File not found") {
|
||||
error(404, "File not found");
|
||||
} else if (e.message === "Invalid DEK version") {
|
||||
error(400, "Mismatched DEK version");
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
export const scanDuplicateFiles = async (
|
||||
userId: number,
|
||||
hskVersion: number,
|
||||
contentHmac: string,
|
||||
) => {
|
||||
const fileIds = await getAllFileIdsByContentHmac(userId, hskVersion, contentHmac);
|
||||
return { files: fileIds.map(({ id }) => id) };
|
||||
return { files: fileIds };
|
||||
};
|
||||
|
||||
const safeUnlink = async (path: string) => {
|
||||
await unlink(path).catch(console.error);
|
||||
export const scanMissingFileThumbnails = async (userId: number) => {
|
||||
const fileIds = await getMissingFileThumbnails(userId);
|
||||
return { files: fileIds };
|
||||
};
|
||||
|
||||
export const uploadFile = async (
|
||||
|
||||
43
src/lib/services/file.ts
Normal file
43
src/lib/services/file.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { callGetApi } from "$lib/hooks";
|
||||
import { decryptData } from "$lib/modules/crypto";
|
||||
import {
|
||||
getFileCache,
|
||||
storeFileCache,
|
||||
getFileThumbnailCache,
|
||||
storeFileThumbnailCache,
|
||||
downloadFile,
|
||||
} from "$lib/modules/file";
|
||||
import { getThumbnailUrl } from "$lib/modules/thumbnail";
|
||||
import type { FileThumbnailInfoResponse } from "$lib/server/schemas";
|
||||
|
||||
export const requestFileDownload = async (
|
||||
fileId: number,
|
||||
fileEncryptedIv: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
const cache = await getFileCache(fileId);
|
||||
if (cache) return cache;
|
||||
|
||||
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
|
||||
storeFileCache(fileId, fileBuffer); // Intended
|
||||
return fileBuffer;
|
||||
};
|
||||
|
||||
export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
|
||||
const cache = await getFileThumbnailCache(fileId);
|
||||
if (cache) return cache;
|
||||
|
||||
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const { contentIv: thumbnailEncryptedIv }: FileThumbnailInfoResponse = await res.json();
|
||||
|
||||
res = await callGetApi(`/api/file/${fileId}/thumbnail/download`);
|
||||
if (!res.ok) return null;
|
||||
|
||||
const thumbnailEncrypted = await res.arrayBuffer();
|
||||
const thumbnailBuffer = await decryptData(thumbnailEncrypted, thumbnailEncryptedIv, dataKey);
|
||||
|
||||
storeFileThumbnailCache(fileId, thumbnailBuffer); // Intended
|
||||
return getThumbnailUrl(thumbnailBuffer);
|
||||
};
|
||||
@@ -1,21 +1,8 @@
|
||||
import { callPostApi } from "$lib/hooks";
|
||||
import { getFileCache, storeFileCache, downloadFile } from "$lib/modules/file";
|
||||
import type { CategoryFileAddRequest } from "$lib/server/schemas";
|
||||
|
||||
export { requestCategoryCreation, requestFileRemovalFromCategory } from "$lib/services/category";
|
||||
|
||||
export const requestFileDownload = async (
|
||||
fileId: number,
|
||||
fileEncryptedIv: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
const cache = await getFileCache(fileId);
|
||||
if (cache) return cache;
|
||||
|
||||
const fileBuffer = await downloadFile(fileId, fileEncryptedIv, dataKey);
|
||||
storeFileCache(fileId, fileBuffer); // Intended
|
||||
return fileBuffer;
|
||||
};
|
||||
export { requestFileDownload } from "$lib/services/file";
|
||||
|
||||
export const requestFileAdditionToCategory = async (fileId: number, categoryId: number) => {
|
||||
const res = await callPostApi<CategoryFileAddRequest>(`/api/category/${categoryId}/file/add`, {
|
||||
|
||||
@@ -4,12 +4,11 @@
|
||||
import { FullscreenDiv } from "$lib/components/atoms";
|
||||
import { TopBar } from "$lib/components/molecules";
|
||||
import type { FileCacheIndex } from "$lib/indexedDB";
|
||||
import { getFileCacheIndex } from "$lib/modules/file";
|
||||
import { getFileCacheIndex, deleteFileCache as doDeleteFileCache } 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;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { deleteFileCache as doDeleteFileCache } from "$lib/modules/file";
|
||||
|
||||
export const deleteFileCache = async (fileId: number) => {
|
||||
await doDeleteFileCache(fileId);
|
||||
};
|
||||
77
src/routes/(fullscreen)/settings/thumbnails/+page.svelte
Normal file
77
src/routes/(fullscreen)/settings/thumbnails/+page.svelte
Normal file
@@ -0,0 +1,77 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { goto } from "$app/navigation";
|
||||
import { BottomDiv, Button, FullscreenDiv } from "$lib/components/atoms";
|
||||
import { IconEntryButton, TopBar } from "$lib/components/molecules";
|
||||
import { deleteAllFileThumbnailCaches } from "$lib/modules/file";
|
||||
import { getFileInfo } from "$lib/modules/filesystem";
|
||||
import { masterKeyStore } from "$lib/stores";
|
||||
import File from "./File.svelte";
|
||||
import {
|
||||
persistentStates,
|
||||
getGenerationStatus,
|
||||
requestThumbnailGeneration,
|
||||
} from "./service.svelte";
|
||||
|
||||
import IconDelete from "~icons/material-symbols/delete";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const generateAllThumbnails = () => {
|
||||
persistentStates.files.forEach(({ info }) => {
|
||||
const fileInfo = get(info);
|
||||
if (fileInfo) {
|
||||
requestThumbnailGeneration(fileInfo);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
persistentStates.files = data.files.map((fileId) => ({
|
||||
id: fileId,
|
||||
info: getFileInfo(fileId, $masterKeyStore?.get(1)?.key!),
|
||||
status: getGenerationStatus(fileId),
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>썸네일 설정</title>
|
||||
</svelte:head>
|
||||
|
||||
<TopBar title="썸네일" />
|
||||
<FullscreenDiv class="bg-gray-100 !px-0">
|
||||
<div class="flex flex-grow flex-col space-y-4">
|
||||
<div class="bg-white p-4 !pt-0">
|
||||
<IconEntryButton icon={IconDelete} onclick={deleteAllFileThumbnailCaches} class="w-full">
|
||||
저장된 썸네일 모두 삭제하기
|
||||
</IconEntryButton>
|
||||
</div>
|
||||
{#if persistentStates.files.length > 0}
|
||||
<div class="flex-grow space-y-2 bg-white p-4">
|
||||
<p class="text-lg font-bold text-gray-800">썸네일이 누락된 파일</p>
|
||||
<div class="space-y-4">
|
||||
<p class="break-keep text-gray-800">
|
||||
{persistentStates.files.length}개 파일의 썸네일이 존재하지 않아요.
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
{#each persistentStates.files as { info, status }}
|
||||
<File
|
||||
{info}
|
||||
generationStatus={status}
|
||||
onclick={({ id }) => goto(`/file/${id}`)}
|
||||
onGenerateThumbnailClick={requestThumbnailGeneration}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if persistentStates.files.length > 0}
|
||||
<BottomDiv class="px-4">
|
||||
<Button onclick={generateAllThumbnails} class="w-full">모두 썸네일 생성하기</Button>
|
||||
</BottomDiv>
|
||||
{/if}
|
||||
</FullscreenDiv>
|
||||
14
src/routes/(fullscreen)/settings/thumbnails/+page.ts
Normal file
14
src/routes/(fullscreen)/settings/thumbnails/+page.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { callPostApi } from "$lib/hooks";
|
||||
import type { MissingThumbnailFileScanResponse } from "$lib/server/schemas";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch }) => {
|
||||
const res = await callPostApi("/api/file/scanMissingThumbnails", undefined, fetch);
|
||||
if (!res.ok) {
|
||||
error(500, "Internal server error");
|
||||
}
|
||||
|
||||
const { files }: MissingThumbnailFileScanResponse = await res.json();
|
||||
return { files };
|
||||
};
|
||||
45
src/routes/(fullscreen)/settings/thumbnails/File.svelte
Normal file
45
src/routes/(fullscreen)/settings/thumbnails/File.svelte
Normal file
@@ -0,0 +1,45 @@
|
||||
<script module lang="ts">
|
||||
const subtexts = {
|
||||
"generation-pending": "준비 중",
|
||||
generating: "생성하는 중",
|
||||
"upload-pending": "업로드를 기다리는 중",
|
||||
uploading: "업로드하는 중",
|
||||
error: "실패",
|
||||
} as const;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import { ActionEntryButton } from "$lib/components/atoms";
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { formatDateTime } from "$lib/modules/util";
|
||||
import type { GenerationStatus } from "./service.svelte";
|
||||
|
||||
import IconCamera from "~icons/material-symbols/camera";
|
||||
|
||||
interface Props {
|
||||
info: Writable<FileInfo | null>;
|
||||
onclick: (selectedFile: FileInfo) => void;
|
||||
onGenerateThumbnailClick: (selectedFile: FileInfo) => void;
|
||||
generationStatus?: Writable<GenerationStatus>;
|
||||
}
|
||||
|
||||
let { info, onclick, onGenerateThumbnailClick, generationStatus }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if $info}
|
||||
<ActionEntryButton
|
||||
class="h-14"
|
||||
onclick={() => onclick($info)}
|
||||
actionButtonIcon={!$generationStatus || $generationStatus === "error" ? IconCamera : undefined}
|
||||
onActionButtonClick={() => onGenerateThumbnailClick($info)}
|
||||
actionButtonClass="text-gray-800"
|
||||
>
|
||||
{@const subtext =
|
||||
$generationStatus && $generationStatus !== "uploaded"
|
||||
? subtexts[$generationStatus]
|
||||
: formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
|
||||
<DirectoryEntryLabel type="file" name={$info.name} {subtext} />
|
||||
</ActionEntryButton>
|
||||
{/if}
|
||||
114
src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts
Normal file
114
src/routes/(fullscreen)/settings/thumbnails/service.svelte.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { limitFunction } from "p-limit";
|
||||
import { get, writable, type Writable } from "svelte/store";
|
||||
import { encryptData } from "$lib/modules/crypto";
|
||||
import { storeFileThumbnailCache } from "$lib/modules/file";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { generateThumbnail as doGenerateThumbnail } from "$lib/modules/thumbnail";
|
||||
import type { FileThumbnailUploadRequest } from "$lib/server/schemas";
|
||||
import { requestFileDownload } from "$lib/services/file";
|
||||
|
||||
export type GenerationStatus =
|
||||
| "generation-pending"
|
||||
| "generating"
|
||||
| "upload-pending"
|
||||
| "uploading"
|
||||
| "uploaded"
|
||||
| "error";
|
||||
|
||||
interface File {
|
||||
id: number;
|
||||
info: Writable<FileInfo | null>;
|
||||
status?: Writable<GenerationStatus>;
|
||||
}
|
||||
|
||||
const workingFiles = new Map<number, Writable<GenerationStatus>>();
|
||||
|
||||
export const persistentStates = $state({
|
||||
files: [] as File[],
|
||||
});
|
||||
|
||||
export const getGenerationStatus = (fileId: number) => {
|
||||
return workingFiles.get(fileId);
|
||||
};
|
||||
|
||||
const generateThumbnail = limitFunction(
|
||||
async (
|
||||
status: Writable<GenerationStatus>,
|
||||
fileBuffer: ArrayBuffer,
|
||||
fileType: string,
|
||||
dataKey: CryptoKey,
|
||||
) => {
|
||||
status.set("generating");
|
||||
|
||||
const thumbnail = await doGenerateThumbnail(fileBuffer, fileType);
|
||||
if (!thumbnail) {
|
||||
status.set("error");
|
||||
return null;
|
||||
}
|
||||
|
||||
const thumbnailBuffer = await thumbnail.arrayBuffer();
|
||||
const thumbnailEncrypted = await encryptData(thumbnailBuffer, dataKey);
|
||||
status.set("upload-pending");
|
||||
return { plaintext: thumbnailBuffer, ...thumbnailEncrypted };
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
const requestThumbnailUpload = limitFunction(
|
||||
async (
|
||||
status: Writable<GenerationStatus>,
|
||||
fileId: number,
|
||||
dataKeyVersion: Date,
|
||||
thumbnail: { plaintext: ArrayBuffer; ciphertext: ArrayBuffer; iv: string },
|
||||
) => {
|
||||
status.set("uploading");
|
||||
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"metadata",
|
||||
JSON.stringify({
|
||||
dekVersion: dataKeyVersion.toISOString(),
|
||||
contentIv: thumbnail.iv,
|
||||
} satisfies FileThumbnailUploadRequest),
|
||||
);
|
||||
form.set("content", new Blob([thumbnail.ciphertext]));
|
||||
|
||||
const res = await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
|
||||
if (!res.ok) return false;
|
||||
|
||||
status.set("uploaded");
|
||||
workingFiles.delete(fileId);
|
||||
persistentStates.files = persistentStates.files.filter(({ id }) => id != fileId);
|
||||
|
||||
storeFileThumbnailCache(fileId, thumbnail.plaintext); // Intended
|
||||
return true;
|
||||
},
|
||||
{ concurrency: 4 },
|
||||
);
|
||||
|
||||
export const requestThumbnailGeneration = async (fileInfo: FileInfo) => {
|
||||
let status = workingFiles.get(fileInfo.id);
|
||||
if (status && get(status) !== "error") return;
|
||||
|
||||
status = writable("generation-pending");
|
||||
workingFiles.set(fileInfo.id, status);
|
||||
persistentStates.files = persistentStates.files.map((file) =>
|
||||
file.id === fileInfo.id ? { ...file, status } : file,
|
||||
);
|
||||
|
||||
try {
|
||||
const file = await requestFileDownload(fileInfo.id, fileInfo.contentIv!, fileInfo.dataKey!);
|
||||
const thumbnail = await generateThumbnail(
|
||||
status,
|
||||
file,
|
||||
fileInfo.contentType,
|
||||
fileInfo.dataKey!,
|
||||
);
|
||||
if (!thumbnail) return;
|
||||
if (!(await requestThumbnailUpload(status, fileInfo.id, fileInfo.dataKeyVersion!, thumbnail))) {
|
||||
status.set("error");
|
||||
}
|
||||
} catch {
|
||||
status.set("error");
|
||||
}
|
||||
};
|
||||
@@ -4,6 +4,7 @@
|
||||
import { DirectoryEntryLabel } from "$lib/components/molecules";
|
||||
import type { FileInfo } from "$lib/modules/filesystem";
|
||||
import { formatDateTime } from "$lib/modules/util";
|
||||
import { requestFileThumbnailDownload } from "./service";
|
||||
import type { SelectedEntry } from "../service.svelte";
|
||||
|
||||
import IconMoreVert from "~icons/material-symbols/more-vert";
|
||||
@@ -16,6 +17,8 @@
|
||||
|
||||
let { info, onclick, onOpenMenuClick }: Props = $props();
|
||||
|
||||
let thumbnail: string | undefined = $state();
|
||||
|
||||
const openFile = () => {
|
||||
const { id, dataKey, dataKeyVersion, name } = $info!;
|
||||
if (!dataKey || !dataKeyVersion) return; // TODO: Error handling
|
||||
@@ -29,6 +32,21 @@
|
||||
|
||||
onOpenMenuClick({ type: "file", id, dataKey, dataKeyVersion, name });
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if ($info?.dataKey) {
|
||||
requestFileThumbnailDownload($info.id, $info.dataKey)
|
||||
.then((thumbnailUrl) => {
|
||||
thumbnail = thumbnailUrl ?? undefined;
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: Error Handling
|
||||
thumbnail = undefined;
|
||||
});
|
||||
} else {
|
||||
thumbnail = undefined;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $info}
|
||||
@@ -40,6 +58,7 @@
|
||||
>
|
||||
<DirectoryEntryLabel
|
||||
type="file"
|
||||
{thumbnail}
|
||||
name={$info.name}
|
||||
subtext={formatDateTime($info.createdAt ?? $info.lastModifiedAt)}
|
||||
/>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
</script>
|
||||
|
||||
{#if isFileUploading($status.status)}
|
||||
<div class="flex h-14 items-center gap-x-4 p-2">
|
||||
<div class="flex-shrink-0 text-lg">
|
||||
<div class="flex h-14 gap-x-4 p-2">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center text-xl">
|
||||
<IconDraft class="text-gray-600" />
|
||||
</div>
|
||||
<div class="flex flex-grow flex-col overflow-hidden text-gray-800">
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { requestFileThumbnailDownload } from "$lib/services/file";
|
||||
@@ -2,7 +2,13 @@ import { getContext, setContext } from "svelte";
|
||||
import { callGetApi, callPostApi } from "$lib/hooks";
|
||||
import { storeHmacSecrets } from "$lib/indexedDB";
|
||||
import { generateDataKey, wrapDataKey, unwrapHmacSecret, encryptString } from "$lib/modules/crypto";
|
||||
import { storeFileCache, deleteFileCache, uploadFile } from "$lib/modules/file";
|
||||
import {
|
||||
storeFileCache,
|
||||
deleteFileCache,
|
||||
storeFileThumbnailCache,
|
||||
deleteFileThumbnailCache,
|
||||
uploadFile,
|
||||
} from "$lib/modules/file";
|
||||
import type {
|
||||
DirectoryRenameRequest,
|
||||
DirectoryCreateRequest,
|
||||
@@ -81,6 +87,10 @@ export const requestFileUpload = async (
|
||||
if (!res) return false;
|
||||
|
||||
storeFileCache(res.fileId, res.fileBuffer); // Intended
|
||||
if (res.thumbnailBuffer) {
|
||||
storeFileThumbnailCache(res.fileId, res.thumbnailBuffer); // Intended
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -110,10 +120,12 @@ export const requestEntryDeletion = async (entry: SelectedEntry) => {
|
||||
|
||||
if (entry.type === "directory") {
|
||||
const { deletedFiles }: DirectoryDeleteResponse = await res.json();
|
||||
await Promise.all(deletedFiles.map(deleteFileCache));
|
||||
await Promise.all(
|
||||
deletedFiles.flatMap((fileId) => [deleteFileCache(fileId), deleteFileThumbnailCache(fileId)]),
|
||||
);
|
||||
return true;
|
||||
} else {
|
||||
await deleteFileCache(entry.id);
|
||||
await Promise.all([deleteFileCache(entry.id), deleteFileThumbnailCache(entry.id)]);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { requestLogout } from "./service";
|
||||
|
||||
import IconStorage from "~icons/material-symbols/storage";
|
||||
import IconImage from "~icons/material-symbols/image";
|
||||
import IconPassword from "~icons/material-symbols/password";
|
||||
import IconLogout from "~icons/material-symbols/logout";
|
||||
|
||||
@@ -33,6 +34,13 @@
|
||||
>
|
||||
캐시
|
||||
</MenuEntryButton>
|
||||
<MenuEntryButton
|
||||
onclick={() => goto("/settings/thumbnails")}
|
||||
icon={IconImage}
|
||||
iconColor="text-blue-500"
|
||||
>
|
||||
썸네일
|
||||
</MenuEntryButton>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<p class="font-semibold">보안</p>
|
||||
|
||||
26
src/routes/api/file/[id]/thumbnail/+server.ts
Normal file
26
src/routes/api/file/[id]/thumbnail/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { error, json } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { fileThumbnailInfoResponse, type FileThumbnailInfoResponse } from "$lib/server/schemas";
|
||||
import { getFileThumbnailInformation } from "$lib/server/services/file";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
const { updatedAt, encContentIv } = await getFileThumbnailInformation(userId, id);
|
||||
return json(
|
||||
fileThumbnailInfoResponse.parse({
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
contentIv: encContentIv,
|
||||
} satisfies FileThumbnailInfoResponse),
|
||||
);
|
||||
};
|
||||
25
src/routes/api/file/[id]/thumbnail/download/+server.ts
Normal file
25
src/routes/api/file/[id]/thumbnail/download/+server.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { getFileThumbnailStream } from "$lib/server/services/file";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const GET: RequestHandler = async ({ locals, params }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
const { encContentStream, encContentSize } = await getFileThumbnailStream(userId, id);
|
||||
return new Response(encContentStream as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": encContentSize.toString(),
|
||||
},
|
||||
});
|
||||
};
|
||||
74
src/routes/api/file/[id]/thumbnail/upload/+server.ts
Normal file
74
src/routes/api/file/[id]/thumbnail/upload/+server.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import Busboy from "@fastify/busboy";
|
||||
import { error, text } from "@sveltejs/kit";
|
||||
import { Readable, Writable } from "stream";
|
||||
import { z } from "zod";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import { fileThumbnailUploadRequest, type FileThumbnailUploadRequest } from "$lib/server/schemas";
|
||||
import { uploadFileThumbnail } from "$lib/server/services/file";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = async ({ locals, params, request }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const zodRes = z
|
||||
.object({
|
||||
id: z.coerce.number().int().positive(),
|
||||
})
|
||||
.safeParse(params);
|
||||
if (!zodRes.success) error(400, "Invalid path parameters");
|
||||
const { id } = zodRes.data;
|
||||
|
||||
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: FileThumbnailUploadRequest | null = null;
|
||||
let content: Readable | null = null;
|
||||
bb.on(
|
||||
"field",
|
||||
handler(async (fieldname, val) => {
|
||||
if (fieldname === "metadata") {
|
||||
// Ignore subsequent metadata fields
|
||||
if (!metadata) {
|
||||
const zodRes = fileThumbnailUploadRequest.safeParse(JSON.parse(val));
|
||||
if (!zodRes.success) error(400, "Invalid request body");
|
||||
metadata = zodRes.data;
|
||||
}
|
||||
} 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 uploadFileThumbnail(
|
||||
userId,
|
||||
id,
|
||||
new Date(metadata.dekVersion),
|
||||
metadata.contentIv,
|
||||
content,
|
||||
);
|
||||
resolve(text("Thumbnail uploaded", { headers: { "Content-Type": "text/plain" } }));
|
||||
}),
|
||||
);
|
||||
bb.on("error", (e) => {
|
||||
content?.emit("error", e) ?? reject(e);
|
||||
});
|
||||
|
||||
request.body!.pipeTo(Writable.toWeb(bb)).catch(() => {}); // busboy will handle the error
|
||||
});
|
||||
};
|
||||
17
src/routes/api/file/scanMissingThumbnails/+server.ts
Normal file
17
src/routes/api/file/scanMissingThumbnails/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { json } from "@sveltejs/kit";
|
||||
import { authorize } from "$lib/server/modules/auth";
|
||||
import {
|
||||
missingThumbnailFileScanResponse,
|
||||
type MissingThumbnailFileScanResponse,
|
||||
} from "$lib/server/schemas/file";
|
||||
import { scanMissingFileThumbnails } from "$lib/server/services/file";
|
||||
import type { RequestHandler } from "./$types";
|
||||
|
||||
export const POST: RequestHandler = async ({ locals }) => {
|
||||
const { userId } = await authorize(locals, "activeClient");
|
||||
|
||||
const { files } = await scanMissingFileThumbnails(userId);
|
||||
return json(
|
||||
missingThumbnailFileScanResponse.parse({ files } satisfies MissingThumbnailFileScanResponse),
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user