7 Commits

13 changed files with 1126 additions and 920 deletions

View File

@@ -2,11 +2,7 @@
FROM node:22-alpine AS base
WORKDIR /app
RUN apk add --no-cache bash curl && \
curl -o /usr/local/bin/wait-for-it https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh && \
chmod +x /usr/local/bin/wait-for-it
RUN npm install -g pnpm@9
RUN npm install -g pnpm@10
COPY pnpm-lock.yaml .
# Build Stage
@@ -29,4 +25,4 @@ COPY --from=build /app/build ./build
EXPOSE 3000
ENV BODY_SIZE_LIMIT=Infinity
CMD ["bash", "-c", "wait-for-it ${DATABASE_HOST:-localhost}:${DATABASE_PORT:-5432} -- node ./build/index.js"]
CMD ["node", "./build/index.js"]

View File

@@ -3,7 +3,8 @@ services:
build: .
restart: unless-stopped
depends_on:
- database
database:
condition: service_healthy
user: ${CONTAINER_UID:-0}:${CONTAINER_GID:-0}
volumes:
- ./data/library:/app/data/library
@@ -35,3 +36,8 @@ services:
environment:
- POSTGRES_USER=arkvault
- POSTGRES_PASSWORD=${DATABASE_PASSWORD:?}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 5s
retries: 5

View File

@@ -1,7 +1,7 @@
{
"name": "arkvault",
"private": true,
"version": "0.5.0",
"version": "0.5.1",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -16,53 +16,53 @@
"db:migrate": "kysely migrate"
},
"devDependencies": {
"@eslint/compat": "^1.3.1",
"@iconify-json/material-symbols": "^1.2.29",
"@sveltejs/adapter-node": "^5.2.13",
"@sveltejs/kit": "^2.22.5",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@eslint/compat": "^1.4.1",
"@iconify-json/material-symbols": "^1.2.44",
"@sveltejs/adapter-node": "^5.4.0",
"@sveltejs/kit": "^2.48.4",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/file-saver": "^2.0.7",
"@types/ms": "^0.7.34",
"@types/ms": "^2.1.0",
"@types/node-schedule": "^2.1.8",
"@types/pg": "^8.15.4",
"@types/pg": "^8.15.6",
"autoprefixer": "^10.4.21",
"axios": "^1.10.0",
"dexie": "^4.0.11",
"eslint": "^9.30.1",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-svelte": "^3.10.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"exifreader": "^4.31.1",
"axios": "^1.13.1",
"dexie": "^4.2.1",
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"exifreader": "^4.32.0",
"file-saver": "^2.0.5",
"globals": "^16.3.0",
"globals": "^16.5.0",
"heic2any": "^0.0.4",
"kysely-ctl": "^0.13.1",
"lru-cache": "^11.1.0",
"mime": "^4.0.7",
"p-limit": "^6.2.0",
"kysely-ctl": "^0.19.0",
"lru-cache": "^11.2.2",
"mime": "^4.1.0",
"p-limit": "^7.2.0",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.6.14",
"svelte": "^5.35.6",
"svelte-check": "^4.2.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.36.0",
"unplugin-icons": "^22.1.0",
"vite": "^5.4.19"
"prettier-plugin-tailwindcss": "^0.7.1",
"svelte": "^5.43.2",
"svelte-check": "^4.3.3",
"tailwindcss": "^3.4.18",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.2",
"unplugin-icons": "^22.5.0",
"vite": "^7.1.12"
},
"dependencies": {
"@fastify/busboy": "^3.1.1",
"argon2": "^0.43.0",
"kysely": "^0.28.2",
"@fastify/busboy": "^3.2.0",
"argon2": "^0.44.0",
"kysely": "^0.28.8",
"ms": "^2.1.3",
"node-schedule": "^2.1.1",
"pg": "^8.16.3",
"uuid": "^11.1.0",
"uuid": "^13.0.0",
"zod": "^3.25.76"
},
"engines": {
"node": "^22.0.0",
"pnpm": "^9.0.0"
"pnpm": "^10.0.0"
}
}

1790
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@
};
$effect(() => {
if ($info?.dataKey) {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;

View File

@@ -25,6 +25,7 @@ interface CategoryInfo {
parentId: CategoryId;
name: string;
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
}
const filesystem = new Dexie("filesystem") as Dexie & {
@@ -33,11 +34,21 @@ const filesystem = new Dexie("filesystem") as Dexie & {
category: EntityTable<CategoryInfo, "id">;
};
filesystem.version(2).stores({
directory: "id, parentId",
file: "id, parentId",
category: "id, parentId",
});
filesystem
.version(3)
.stores({
directory: "id, parentId",
file: "id, parentId",
category: "id, parentId",
})
.upgrade(async (trx) => {
await trx
.table("category")
.toCollection()
.modify((category) => {
category.isFileRecursive = false;
});
});
export const getDirectoryInfos = async (parentId: DirectoryId) => {
return await filesystem.directory.where({ parentId }).toArray();
@@ -87,6 +98,10 @@ export const storeCategoryInfo = async (categoryInfo: CategoryInfo) => {
await filesystem.category.put(categoryInfo);
};
export const updateCategoryInfo = async (id: number, changes: { isFileRecursive?: boolean }) => {
await filesystem.category.update(id, changes);
};
export const deleteCategoryInfo = async (id: number) => {
await filesystem.category.delete(id);
};

View File

@@ -12,6 +12,7 @@ import {
getCategoryInfos as getCategoryInfosFromIndexedDB,
getCategoryInfo as getCategoryInfoFromIndexedDB,
storeCategoryInfo,
updateCategoryInfo as updateCategoryInfoInIndexedDB,
deleteCategoryInfo,
type DirectoryId,
type CategoryId,
@@ -62,6 +63,7 @@ export type CategoryInfo =
name?: undefined;
subCategoryIds: number[];
files?: undefined;
isFileRecursive?: undefined;
}
| {
id: number;
@@ -70,6 +72,7 @@ export type CategoryInfo =
name: string;
subCategoryIds: number[];
files: { id: number; isRecursive: boolean }[];
isFileRecursive: boolean;
};
const directoryInfoStore = new Map<DirectoryId, Writable<DirectoryInfo | null>>();
@@ -255,7 +258,13 @@ const fetchCategoryInfoFromIndexedDB = async (
info.set({ id, subCategoryIds });
} else {
if (!category) return;
info.set({ id, name: category.name, subCategoryIds, files: category.files });
info.set({
id,
name: category.name,
subCategoryIds,
files: category.files,
isFileRecursive: category.isFileRecursive,
});
}
};
@@ -288,20 +297,28 @@ const fetchCategoryInfoFromServer = async (
const { files }: CategoryFileListResponse = await res.json();
const filesMapped = files.map(({ file, isRecursive }) => ({ id: file, isRecursive }));
let isFileRecursive: boolean | undefined = undefined;
info.set({
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
info.update((value) => {
const newValue = {
isFileRecursive: false,
...value,
id,
dataKey,
dataKeyVersion: new Date(metadata!.dekVersion),
name,
subCategoryIds: subCategories,
files: filesMapped,
};
isFileRecursive = newValue.isFileRecursive;
return newValue;
});
await storeCategoryInfo({
id,
parentId: metadata!.parent,
name,
files: filesMapped,
isFileRecursive: isFileRecursive!,
});
}
};
@@ -327,3 +344,17 @@ export const getCategoryInfo = (categoryId: CategoryId, masterKey: CryptoKey) =>
fetchCategoryInfo(categoryId, info, masterKey); // Intended
return info;
};
export const updateCategoryInfo = async (
categoryId: number,
changes: { isFileRecursive?: boolean },
) => {
await updateCategoryInfoInIndexedDB(categoryId, changes);
categoryInfoStore.get(categoryId)?.update((value) => {
if (!value) return value;
if (changes.isFileRecursive !== undefined) {
value.isFileRecursive = changes.isFileRecursive;
}
return value;
});
};

View File

@@ -32,7 +32,7 @@ const capture = (
drawer(context, scaledWidth, scaledHeight);
canvas.toBlob((blob) => {
if (blob) {
if (blob && blob.type === "image/webp") {
resolve(blob);
} else {
reject(new Error("Failed to generate thumbnail"));
@@ -67,10 +67,15 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
return new Promise<Blob>((resolve, reject) => {
const video = document.createElement("video");
video.onloadedmetadata = () => {
video.currentTime = Math.min(time, video.duration);
video.requestVideoFrameCallback(() => {
if (video.videoWidth === 0 || video.videoHeight === 0) {
return reject();
}
const callbackId = video.requestVideoFrameCallback(() => {
captureVideoThumbnail(video).then(resolve).catch(reject);
video.cancelVideoFrameCallback(callbackId);
});
video.currentTime = Math.min(time, video.duration);
};
video.onerror = reject;
@@ -83,18 +88,26 @@ const generateVideoThumbnail = (videoUrl: string, time = 0) => {
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);
if (fileType.startsWith("image/")) {
const fileBlob = new Blob([fileBuffer], { type: fileType });
url = URL.createObjectURL(fileBlob);
try {
return await generateImageThumbnail(url);
} catch {
URL.revokeObjectURL(url);
url = undefined;
if (fileType === "image/heic") {
const { default: heic2any } = await import("heic2any");
url = URL.createObjectURL(
(await heic2any({ blob: fileBlob, toType: "image/png" })) as Blob,
);
return await generateImageThumbnail(url);
} else {
return null;
}
}
} else if (fileType.startsWith("video/")) {
url = URL.createObjectURL(new Blob([fileBuffer], { type: fileType }));
return await generateVideoThumbnail(url);

View File

@@ -48,9 +48,9 @@ export const requestFileThumbnailUpload = async (
return await fetch(`/api/file/${fileId}/thumbnail/upload`, { method: "POST", body: form });
};
export const requestFileThumbnailDownload = async (fileId: number, dataKey: CryptoKey) => {
export const requestFileThumbnailDownload = async (fileId: number, dataKey?: CryptoKey) => {
const cache = await getFileThumbnailCache(fileId);
if (cache) return cache;
if (cache || !dataKey) return cache;
let res = await callGetApi(`/api/file/${fileId}/thumbnail`);
if (!res.ok) return null;

View File

@@ -6,8 +6,14 @@
let oldPassword = $state("");
let newPassword = $state("");
let confirmPassword = $state("");
const changePassword = async () => {
if (newPassword !== confirmPassword) {
// TODO: Alert
return;
}
if (await requestPasswordChange(oldPassword, newPassword)) {
await goto("/menu");
}
@@ -30,6 +36,7 @@
<TextInput bind:value={oldPassword} placeholder="기존 비밀번호" type="password" />
<TextInput bind:value={newPassword} placeholder="새 비밀번호" type="password" />
<TextInput bind:value={confirmPassword} placeholder="새 비밀번호 확인" type="password" />
</TitledDiv>
<BottomDiv>
<Button onclick={changePassword} class="w-full">비밀번호 바꾸기</Button>

View File

@@ -43,22 +43,31 @@
let isDownloadRequested = $state(false);
let viewerType: "image" | "video" | undefined = $state();
let fileBlobUrl: string | undefined = $state();
let heicBlob: Blob | undefined = $state();
let videoElement: HTMLVideoElement | undefined = $state();
const updateViewer = async (buffer: ArrayBuffer, contentType: string) => {
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) {
if (viewerType) {
fileBlobUrl = URL.createObjectURL(fileBlob);
heicBlob = contentType === "image/heic" ? fileBlob : undefined;
}
return fileBlob;
};
const convertHeicToJpeg = async () => {
if (!heicBlob) return;
URL.revokeObjectURL(fileBlobUrl!);
fileBlobUrl = undefined;
const { default: heic2any } = await import("heic2any");
fileBlobUrl = URL.createObjectURL(
(await heic2any({ blob: heicBlob, toType: "image/jpeg" })) as Blob,
);
heicBlob = undefined;
};
const updateThumbnail = async (dataKey: CryptoKey, dataKeyVersion: Date) => {
const thumbnail = await captureVideoThumbnail(videoElement!);
await requestThumbnailUpload(data.id, thumbnail, dataKey, dataKeyVersion);
@@ -136,7 +145,7 @@
{#if viewerType === "image"}
{#if fileBlobUrl}
<img src={fileBlobUrl} alt={$info.name} />
<img src={fileBlobUrl} alt={$info.name} onerror={convertHeicToJpeg} />
{:else}
{@render viewerLoading("이미지를 불러오고 있어요.")}
{/if}

View File

@@ -3,7 +3,7 @@
import { goto } from "$app/navigation";
import { TopBar } from "$lib/components/molecules";
import { Category, CategoryCreateModal } from "$lib/components/organisms";
import { getCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { getCategoryInfo, updateCategoryInfo, type CategoryInfo } from "$lib/modules/filesystem";
import { masterKeyStore } from "$lib/stores";
import CategoryDeleteModal from "./CategoryDeleteModal.svelte";
import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte";
@@ -21,7 +21,7 @@
let info: Writable<CategoryInfo | null> | undefined = $state();
let isFileRecursive = $state(false);
let isFileRecursive: boolean | undefined = $state();
let isCategoryCreateModalOpen = $state(false);
let isCategoryMenuBottomSheetOpen = $state(false);
@@ -30,6 +30,19 @@
$effect(() => {
info = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!);
isFileRecursive = undefined;
});
$effect(() => {
if ($info && isFileRecursive === undefined) {
isFileRecursive = $info.isFileRecursive ?? false;
}
});
$effect(() => {
if (data.id !== "root" && $info?.isFileRecursive !== isFileRecursive) {
updateCategoryInfo(data.id as number, { isFileRecursive });
}
});
</script>
@@ -41,7 +54,7 @@
<TopBar title={$info?.name} />
{/if}
<div class="min-h-full bg-gray-100 pb-[5.5em]">
{#if $info}
{#if $info && isFileRecursive !== undefined}
<Category
bind:isFileRecursive
info={$info}

View File

@@ -34,7 +34,7 @@
};
$effect(() => {
if ($info?.dataKey) {
if ($info) {
requestFileThumbnailDownload($info.id, $info.dataKey)
.then((thumbnailUrl) => {
thumbnail = thumbnailUrl ?? undefined;