5 Commits
v0.9.0 ... demo

23 changed files with 245 additions and 126 deletions

View File

@@ -13,6 +13,7 @@ node_modules
/library /library
/thumbnails /thumbnails
/uploads /uploads
/log
# OS # OS
.DS_Store .DS_Store

1
.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
/library /library
/thumbnails /thumbnails
/uploads /uploads
/log
# OS # OS
.DS_Store .DS_Store

View File

@@ -10,6 +10,7 @@ services:
- ./data/library:/app/data/library - ./data/library:/app/data/library
- ./data/thumbnails:/app/data/thumbnails - ./data/thumbnails:/app/data/thumbnails
- ./data/uploads:/app/data/uploads - ./data/uploads:/app/data/uploads
- ./data/log:/app/data/log
environment: environment:
# ArkVault # ArkVault
- DATABASE_HOST=database - DATABASE_HOST=database
@@ -22,6 +23,7 @@ services:
- LIBRARY_PATH=/app/data/library - LIBRARY_PATH=/app/data/library
- THUMBNAILS_PATH=/app/data/thumbnails - THUMBNAILS_PATH=/app/data/thumbnails
- UPLOADS_PATH=/app/data/uploads - UPLOADS_PATH=/app/data/uploads
- LOG_DIR=/app/data/log
# SvelteKit # SvelteKit
- ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For} - ADDRESS_HEADER=${TRUST_PROXY:+X-Forwarded-For}
- XFF_DEPTH=${TRUST_PROXY:-} - XFF_DEPTH=${TRUST_PROXY:-}

View File

@@ -4,3 +4,6 @@ export const ENCRYPTION_OVERHEAD = AES_GCM_IV_SIZE + AES_GCM_TAG_SIZE;
export const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB export const CHUNK_SIZE = 4 * 1024 * 1024; // 4 MiB
export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD; export const ENCRYPTED_CHUNK_SIZE = CHUNK_SIZE + ENCRYPTION_OVERHEAD;
export const MAX_FILE_SIZE = 512 * 1024 * 1024; // 512 MiB
export const MAX_CHUNKS = Math.ceil(MAX_FILE_SIZE / CHUNK_SIZE); // 128 chunks

View File

@@ -34,7 +34,7 @@ export class FilesystemCache<K, V extends object> {
} }
(state.value (state.value
? Promise.resolve(state.value) ? Promise.resolve($state.snapshot(state.value) as V)
: this.options.fetchFromIndexedDB(key).then((loadedInfo) => { : this.options.fetchFromIndexedDB(key).then((loadedInfo) => {
if (loadedInfo) { if (loadedInfo) {
state.value = loadedInfo; state.value = loadedInfo;

View File

@@ -74,28 +74,6 @@ export const getAllDirectoriesByParent = async (userId: number, parentId: Direct
return directories.map(toDirectory); return directories.map(toDirectory);
}; };
export const getAllRecursiveDirectoriesByParent = async (userId: number, parentId: DirectoryId) => {
const directories = await db
.withRecursive("directory_tree", (db) =>
db
.selectFrom("directory")
.selectAll()
.$if(parentId === "root", (qb) => qb.where("parent_id", "is", null))
.$if(parentId !== "root", (qb) => qb.where("parent_id", "=", parentId as number))
.where("user_id", "=", userId)
.unionAll((db) =>
db
.selectFrom("directory")
.innerJoin("directory_tree", "directory.parent_id", "directory_tree.id")
.selectAll("directory"),
),
)
.selectFrom("directory_tree")
.selectAll()
.execute();
return directories.map(toDirectory);
};
export const getAllFavoriteDirectories = async (userId: number) => { export const getAllFavoriteDirectories = async (userId: number) => {
const directories = await db const directories = await db
.selectFrom("directory") .selectFrom("directory")
@@ -117,6 +95,61 @@ export const getDirectory = async (userId: number, directoryId: number) => {
return directory ? toDirectory(directory) : null; return directory ? toDirectory(directory) : null;
}; };
export const searchDirectories = async (
userId: number,
filters: {
parentId: DirectoryId;
inFavorites: boolean;
},
) => {
const directories = await db
.withRecursive("directory_tree", (db) =>
db
.selectFrom("directory")
.select("id")
.where("user_id", "=", userId)
.$if(filters.parentId === "root", (qb) => qb.where((eb) => eb.lit(false))) // directory_tree will be empty if parentId is "root"
.$if(filters.parentId !== "root", (qb) => qb.where("id", "=", filters.parentId as number))
.unionAll(
db
.selectFrom("directory as d")
.innerJoin("directory_tree as dt", "d.parent_id", "dt.id")
.select("d.id"),
),
)
.withRecursive("favorite_directory_tree", (db) =>
db
.selectFrom("directory")
.select("id")
.where("user_id", "=", userId)
.$if(!filters.inFavorites, (qb) => qb.where((eb) => eb.lit(false))) // favorite_directory_tree will be empty if inFavorites is false
.$if(filters.inFavorites, (qb) => qb.where("is_favorite", "=", true))
.unionAll((db) =>
db
.selectFrom("directory as d")
.innerJoin("favorite_directory_tree as dt", "d.parent_id", "dt.id")
.select("d.id"),
),
)
.selectFrom("directory")
.selectAll()
.where("user_id", "=", userId)
.$if(filters.parentId !== "root", (qb) =>
qb.where((eb) =>
eb.exists(eb.selectFrom("directory_tree as dt").whereRef("dt.id", "=", "parent_id")),
),
)
.$if(filters.inFavorites, (qb) =>
qb.where((eb) =>
eb.exists(
eb.selectFrom("favorite_directory_tree as dt").whereRef("dt.id", "=", "directory.id"),
),
),
)
.execute();
return directories.map(toDirectory);
};
export const setDirectoryEncName = async ( export const setDirectoryEncName = async (
userId: number, userId: number,
directoryId: number, directoryId: number,

View File

@@ -248,6 +248,7 @@ export const searchFiles = async (
userId: number, userId: number,
filters: { filters: {
parentId: DirectoryId; parentId: DirectoryId;
inFavorites: boolean;
includeCategoryIds: number[]; includeCategoryIds: number[];
excludeCategoryIds: number[]; excludeCategoryIds: number[];
}, },
@@ -258,7 +259,7 @@ export const searchFiles = async (
.selectFrom("directory") .selectFrom("directory")
.select("id") .select("id")
.where("user_id", "=", userId) .where("user_id", "=", userId)
.where((eb) => eb.val(filters.parentId !== "root")) // directory_tree will be empty if parentId is "root" .$if(filters.parentId === "root", (qb) => qb.where((eb) => eb.lit(false))) // directory_tree will be empty if parentId is "root"
.$if(filters.parentId !== "root", (qb) => qb.where("id", "=", filters.parentId as number)) .$if(filters.parentId !== "root", (qb) => qb.where("id", "=", filters.parentId as number))
.unionAll( .unionAll(
db db
@@ -267,6 +268,20 @@ export const searchFiles = async (
.select("d.id"), .select("d.id"),
), ),
) )
.withRecursive("favorite_directory_tree", (db) =>
db
.selectFrom("directory")
.select("id")
.where("user_id", "=", userId)
.$if(!filters.inFavorites, (qb) => qb.where((eb) => eb.lit(false))) // favorite_directory_tree will be empty if inFavorites is false
.$if(filters.inFavorites, (qb) => qb.where("is_favorite", "=", true))
.unionAll((db) =>
db
.selectFrom("directory as d")
.innerJoin("favorite_directory_tree as dt", "d.parent_id", "dt.id")
.select("d.id"),
),
)
.withRecursive("include_category_tree", (db) => .withRecursive("include_category_tree", (db) =>
db db
.selectFrom("category") .selectFrom("category")
@@ -295,19 +310,31 @@ export const searchFiles = async (
) )
.selectFrom("file") .selectFrom("file")
.selectAll("file") .selectAll("file")
.$if(filters.parentId === "root", (qb) => qb.where("user_id", "=", userId)) // directory_tree isn't used if parentId is "root" .where("user_id", "=", userId)
.$if(filters.parentId !== "root", (qb) => .$if(filters.parentId !== "root", (qb) =>
qb.where("parent_id", "in", (eb) => eb.selectFrom("directory_tree").select("id")), qb.where((eb) =>
eb.exists(eb.selectFrom("directory_tree as dt").whereRef("dt.id", "=", "file.parent_id")),
),
) )
.where((eb) => .$if(filters.inFavorites, (qb) =>
eb.not( qb.where((eb) =>
eb.exists( eb.or([
eb eb("is_favorite", "=", true),
.selectFrom("file_category") eb.exists(
.whereRef("file_id", "=", "file.id") eb.selectFrom("favorite_directory_tree as dt").whereRef("dt.id", "=", "file.parent_id"),
.where("category_id", "in", (eb) => ),
eb.selectFrom("exclude_category_tree").select("id"), ]),
), ),
)
.$if(filters.excludeCategoryIds.length > 0, (qb) =>
qb.where((eb) =>
eb.not(
eb.exists(
eb
.selectFrom("file_category")
.innerJoin("exclude_category_tree", "category_id", "exclude_category_tree.id")
.whereRef("file_id", "=", "file.id"),
),
), ),
), ),
); );

View File

@@ -0,0 +1,37 @@
import { appendFileSync, existsSync, mkdirSync } from "fs";
import { env } from "$env/dynamic/private";
const LOG_DIR = env.LOG_DIR || "log";
const getLogFilePath = () => {
const date = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
return `${LOG_DIR}/arkvault-${date}.log`;
};
const ensureLogDir = () => {
if (!existsSync(LOG_DIR)) {
mkdirSync(LOG_DIR, { recursive: true });
}
};
const formatLogLine = (type: string, data: Record<string, unknown>) => {
const timestamp = new Date().toISOString();
return JSON.stringify({ timestamp, type, ...data });
};
export const demoLogger = {
log: (type: string, data: Record<string, unknown>) => {
const line = formatLogLine(type, data);
// Output to stdout
console.log(line);
// Output to file
try {
ensureLogDir();
appendFileSync(getLogFilePath(), line + "\n", { encoding: "utf-8" });
} catch (e) {
console.error("Failed to write to log file:", e);
}
},
};

View File

@@ -14,8 +14,8 @@
let { data } = $props(); let { data } = $props();
let email = $state(""); let email = $state("arkvault-demo@minchan.me");
let password = $state(""); let password = $state("arkvault-demo");
let isForceLoginModalOpen = $state(false); let isForceLoginModalOpen = $state(false);

View File

@@ -46,7 +46,7 @@
{#if isOpen && (directoryId || downloadUrl || fileBlob)} {#if isOpen && (directoryId || downloadUrl || fileBlob)}
<div <div
class="absolute right-2 top-full z-20 min-w-40 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl" class="absolute right-2 top-full z-20 min-w-44 space-y-1 rounded-lg bg-white px-1 py-2 shadow-2xl"
transition:fly={{ y: -8, duration: 200 }} transition:fly={{ y: -8, duration: 200 }}
> >
<p class="px-3 pt-2 text-sm font-semibold text-gray-600">더보기</p> <p class="px-3 pt-2 text-sm font-semibold text-gray-600">더보기</p>
@@ -57,7 +57,9 @@
onclick: () => void, onclick: () => void,
)} )}
<button {onclick} class="rounded-xl active:bg-gray-100"> <button {onclick} class="rounded-xl active:bg-gray-100">
<div class="flex items-center gap-x-3 px-3 py-2 text-gray-700 transition active:scale-95"> <div
class="flex items-center gap-x-3 px-3 py-2 text-lg text-gray-700 transition active:scale-95"
>
<Icon /> <Icon />
<p class="font-medium">{text}</p> <p class="font-medium">{text}</p>
</div> </div>

View File

@@ -37,8 +37,8 @@
includeImages: false, includeImages: false,
includeVideos: false, includeVideos: false,
includeDirectories: false, includeDirectories: false,
searchInFavorites: false,
searchInDirectory: false, searchInDirectory: false,
searchInFavorites: false,
categories: [], categories: [],
}); });
let hasCategoryFilter = $derived(filters.categories.length > 0); let hasCategoryFilter = $derived(filters.categories.length > 0);
@@ -47,7 +47,6 @@
filters.includeImages || filters.includeImages ||
filters.includeVideos || filters.includeVideos ||
filters.includeDirectories || filters.includeDirectories ||
filters.searchInFavorites ||
filters.name.trim().length > 0, filters.name.trim().length > 0,
); );
@@ -84,9 +83,7 @@
return sortEntries( return sortEntries(
[...directories, ...files].filter( [...directories, ...files].filter(
(entry) => (entry) => !nameFilter || searchString(entry.name, nameFilter),
(!nameFilter || searchString(entry.name, nameFilter)) &&
(!filters.searchInFavorites || entry.isFavorite),
), ),
); );
}); });
@@ -145,6 +142,7 @@
// Svelte sucks // Svelte sucks
hasAnyFilter; hasAnyFilter;
filters.searchInDirectory; filters.searchInDirectory;
filters.searchInFavorites;
filters.categories.length; filters.categories.length;
if (untrack(() => isRestoredFromSnapshot)) { if (untrack(() => isRestoredFromSnapshot)) {
@@ -156,6 +154,7 @@
requestSearch( requestSearch(
{ {
ancestorId: filters.searchInDirectory ? data.directoryId! : "root", ancestorId: filters.searchInDirectory ? data.directoryId! : "root",
inFavorites: filters.searchInFavorites,
categories: filters.categories, categories: filters.categories,
}, },
$masterKeyStore?.get(1)?.key!, $masterKeyStore?.get(1)?.key!,
@@ -216,7 +215,12 @@
</div> </div>
{#if hasAnyFilter} {#if hasAnyFilter}
<div class="flex flex-grow flex-col space-y-2 bg-white p-4"> <div class="flex flex-grow flex-col space-y-2 bg-white p-4">
<p class="text-lg font-bold text-gray-800">검색 결과</p> <p class="text-lg font-bold text-gray-800">
검색 결과
{#if result.length > 0}
{" "}({result.length}개)
{/if}
</p>
{#if result.length > 0} {#if result.length > 0}
<RowVirtualizer <RowVirtualizer
count={result.length} count={result.length}

View File

@@ -1,17 +1,14 @@
<script lang="ts"> <script lang="ts">
import type { ClassValue } from "svelte/elements";
import IconArrowBack from "~icons/material-symbols/arrow-back"; import IconArrowBack from "~icons/material-symbols/arrow-back";
interface Props { interface Props {
class?: ClassValue;
value: string; value: string;
} }
let { class: className, value = $bindable() }: Props = $props(); let { value = $bindable() }: Props = $props();
</script> </script>
<div class={["sticky top-0 z-10 flex items-center gap-x-2 px-2 py-3 backdrop-blur-2xl", className]}> <div class={"sticky top-0 z-10 flex items-center gap-x-2 px-2 py-3 backdrop-blur-2xl"}>
<button <button
onclick={() => history.back()} onclick={() => history.back()}
class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]" class="w-[2.3rem] flex-shrink-0 rounded-full p-1 active:bg-black active:bg-opacity-[0.04]"
@@ -22,6 +19,6 @@
bind:value bind:value
type="text" type="text"
placeholder="검색" placeholder="검색"
class="mr-1 h-[2.3rem] flex-grow rounded-lg bg-gray-100 px-3 py-1.5 placeholder-gray-600 focus:outline-none" class="mr-1 h-[2.3rem] min-w-0 flex-grow rounded-lg bg-gray-100 px-3 py-1.5 placeholder-gray-600 focus:outline-none"
/> />
</div> </div>

View File

@@ -10,6 +10,7 @@ import { trpc } from "$trpc/client";
export interface SearchFilter { export interface SearchFilter {
ancestorId: DirectoryId; ancestorId: DirectoryId;
inFavorites: boolean;
categories: { info: LocalCategoryInfo; type: "include" | "exclude" }[]; categories: { info: LocalCategoryInfo; type: "include" | "exclude" }[];
} }
@@ -21,6 +22,7 @@ export interface SearchResult {
export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey) => { export const requestSearch = async (filter: SearchFilter, masterKey: CryptoKey) => {
const { directories: directoriesRaw, files: filesRaw } = await trpc().search.search.query({ const { directories: directoriesRaw, files: filesRaw } = await trpc().search.search.query({
ancestor: filter.ancestorId, ancestor: filter.ancestorId,
inFavorites: filter.inFavorites,
includeCategories: filter.categories includeCategories: filter.categories
.filter(({ type }) => type === "include") .filter(({ type }) => type === "include")
.map(({ info }) => info.id), .map(({ info }) => info.id),

View File

@@ -3,6 +3,7 @@
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getFileThumbnail } from "$lib/modules/file"; import { getFileThumbnail } from "$lib/modules/file";
import type { CategoryFileInfo } from "$lib/modules/filesystem"; import type { CategoryFileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/utils";
import type { SelectedFile } from "./service.svelte"; import type { SelectedFile } from "./service.svelte";
import IconClose from "~icons/material-symbols/close"; import IconClose from "~icons/material-symbols/close";
@@ -19,7 +20,7 @@
</script> </script>
<ActionEntryButton <ActionEntryButton
class="h-12" class="h-14"
onclick={() => onclick(info)} onclick={() => onclick(info)}
actionButtonIcon={onRemoveClick && IconClose} actionButtonIcon={onRemoveClick && IconClose}
onActionButtonClick={() => onRemoveClick?.(info)} onActionButtonClick={() => onRemoveClick?.(info)}
@@ -28,6 +29,7 @@
type="file" type="file"
thumbnail={$thumbnail} thumbnail={$thumbnail}
name={info.name} name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite={info.isFavorite} isFavorite={info.isFavorite}
/> />
</ActionEntryButton> </ActionEntryButton>

View File

@@ -103,7 +103,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>파일</title> <title>파일</title>
</svelte:head> </svelte:head>
<input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" /> <input bind:this={fileInput} onchange={uploadFile} type="file" multiple class="hidden" />

View File

@@ -44,52 +44,50 @@
<title>즐겨찾기</title> <title>즐겨찾기</title>
</svelte:head> </svelte:head>
<TopBar title="즐겨찾기" showBackButton={false}> <div class="flex h-full flex-col">
<button <TopBar title="즐겨찾기" showBackButton={false}>
onclick={() => goto("/search?from=favorites")} <button
class="w-full rounded-full p-1 text-2xl active:bg-black active:bg-opacity-[0.04]" onclick={() => goto("/search?from=favorites")}
> class="w-full rounded-full p-1 text-2xl active:bg-black active:bg-opacity-[0.04]"
<IconSearch />
</button>
</TopBar>
<div class="flex h-full flex-col p-4 !pt-0">
{#if isLoading}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">
{#if data.favorites.files.length === 0 && data.favorites.directories.length === 0}
즐겨찾기한 항목이 없어요.
{:else}
즐겨찾기 목록을 불러오고 있어요.
{/if}
</p>
</div>
{:else if entries.length === 0}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">즐겨찾기한 항목이 없어요.</p>
</div>
{:else}
<RowVirtualizer
count={entries.length}
getItemKey={(index) => `${entries[index]!.type}-${entries[index]!.details.id}`}
estimateItemHeight={() => 56}
itemGap={4}
> >
{#snippet item(index)} <IconSearch />
{@const entry = entries[index]!} </button>
{#if entry.type === "directory"} </TopBar>
<Directory <div class="flex flex-grow flex-col px-4 pb-4">
info={entry.details} {#if entries.length > 0}
onclick={() => handleClick(entry)} <RowVirtualizer
onRemoveClick={() => handleRemove(entry)} count={entries.length}
/> getItemKey={(index) => `${entries[index]!.type}-${entries[index]!.details.id}`}
{:else} estimateItemHeight={() => 56}
<File itemGap={4}
info={entry.details} >
onclick={() => handleClick(entry)} {#snippet item(index)}
onRemoveClick={() => handleRemove(entry)} {@const entry = entries[index]!}
/> {#if entry.type === "directory"}
{/if} <Directory
{/snippet} info={entry.details}
</RowVirtualizer> onclick={() => handleClick(entry)}
{/if} onRemoveClick={() => handleRemove(entry)}
/>
{:else}
<File
info={entry.details}
onclick={() => handleClick(entry)}
onRemoveClick={() => handleRemove(entry)}
/>
{/if}
{/snippet}
</RowVirtualizer>
{:else}
<div class="flex flex-grow items-center justify-center">
<p class="text-gray-500">
{#if isLoading}
즐겨찾기 목록을 불러오고 있어요.
{:else}
즐겨찾기한 항목이 없어요.
{/if}
</p>
</div>
{/if}
</div>
</div> </div>

View File

@@ -3,6 +3,7 @@
import { DirectoryEntryLabel } from "$lib/components/molecules"; import { DirectoryEntryLabel } from "$lib/components/molecules";
import { getFileThumbnail } from "$lib/modules/file"; import { getFileThumbnail } from "$lib/modules/file";
import type { SummarizedFileInfo } from "$lib/modules/filesystem"; import type { SummarizedFileInfo } from "$lib/modules/filesystem";
import { formatDateTime } from "$lib/utils";
import IconClose from "~icons/material-symbols/close"; import IconClose from "~icons/material-symbols/close";
@@ -23,5 +24,11 @@
actionButtonIcon={IconClose} actionButtonIcon={IconClose}
onActionButtonClick={onRemoveClick} onActionButtonClick={onRemoveClick}
> >
<DirectoryEntryLabel type="file" thumbnail={$thumbnail} name={info.name} isFavorite /> <DirectoryEntryLabel
type="file"
thumbnail={$thumbnail}
name={info.name}
subtext={formatDateTime(info.createdAt ?? info.lastModifiedAt)}
isFavorite
/>
</ActionEntryButton> </ActionEntryButton>

View File

@@ -52,13 +52,6 @@
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<p class="font-semibold">보안</p> <p class="font-semibold">보안</p>
<MenuEntryButton
onclick={() => goto("/auth/changePassword")}
icon={IconPassword}
iconColor="text-blue-500"
>
비밀번호 바꾸기
</MenuEntryButton>
<MenuEntryButton onclick={logout} icon={IconLogout} iconColor="text-red-500"> <MenuEntryButton onclick={logout} icon={IconLogout} iconColor="text-red-500">
로그아웃 로그아웃
</MenuEntryButton> </MenuEntryButton>

View File

@@ -5,6 +5,7 @@ import { ClientRepo, SessionRepo, UserRepo, IntegrityError } from "$lib/server/d
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
import { cookieOptions } from "$lib/server/modules/auth"; import { cookieOptions } from "$lib/server/modules/auth";
import { generateChallenge, verifySignature, issueSessionId } from "$lib/server/modules/crypto"; import { generateChallenge, verifySignature, issueSessionId } from "$lib/server/modules/crypto";
import { demoLogger } from "$lib/server/modules/logger";
import { router, publicProcedure, roleProcedure } from "../init.server"; import { router, publicProcedure, roleProcedure } from "../init.server";
const authRouter = router({ const authRouter = router({
@@ -24,6 +25,10 @@ const authRouter = router({
const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret); const { sessionId, sessionIdSigned } = await issueSessionId(32, env.session.secret);
await SessionRepo.createSession(user.id, sessionId, ctx.locals.ip, ctx.locals.userAgent); await SessionRepo.createSession(user.id, sessionId, ctx.locals.ip, ctx.locals.userAgent);
ctx.cookies.set("sessionId", sessionIdSigned, cookieOptions); ctx.cookies.set("sessionId", sessionIdSigned, cookieOptions);
if (input.email === "arkvault-demo@minchan.me") {
demoLogger.log("demo:login", { ip: ctx.locals.ip, sessionId });
}
}), }),
logout: roleProcedure["any"].mutation(async ({ ctx }) => { logout: roleProcedure["any"].mutation(async ({ ctx }) => {
@@ -38,22 +43,8 @@ const authRouter = router({
newPassword: z.string().nonempty(), newPassword: z.string().nonempty(),
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(() => {
if (input.oldPassword === input.newPassword) { throw new TRPCError({ code: "NOT_IMPLEMENTED" });
throw new TRPCError({ code: "BAD_REQUEST", message: "Same passwords" });
} else if (input.newPassword.length < 8) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Too short password" });
}
const user = await UserRepo.getUser(ctx.session.userId);
if (!user) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Invalid session id" });
} else if (!(await argon2.verify(user.password, input.oldPassword))) {
throw new TRPCError({ code: "FORBIDDEN", message: "Invalid password" });
}
await UserRepo.setUserPassword(ctx.session.userId, await argon2.hash(input.newPassword));
await SessionRepo.deleteAllOtherSessions(ctx.session.userId, ctx.session.sessionId);
}), }),
upgrade: roleProcedure["notClient"] upgrade: roleProcedure["notClient"]

View File

@@ -3,6 +3,7 @@ import { z } from "zod";
import { DirectoryIdSchema } from "$lib/schemas"; import { DirectoryIdSchema } from "$lib/schemas";
import { DirectoryRepo, FileRepo, IntegrityError } from "$lib/server/db"; import { DirectoryRepo, FileRepo, IntegrityError } from "$lib/server/db";
import { safeUnlink } from "$lib/server/modules/filesystem"; import { safeUnlink } from "$lib/server/modules/filesystem";
import { demoLogger } from "$lib/server/modules/logger";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "../init.server";
const directoryRouter = router({ const directoryRouter = router({
@@ -134,6 +135,7 @@ const directoryRouter = router({
const files = await DirectoryRepo.unregisterDirectory(ctx.session.userId, input.id); const files = await DirectoryRepo.unregisterDirectory(ctx.session.userId, input.id);
return { return {
deletedFiles: files.map((file) => { deletedFiles: files.map((file) => {
demoLogger.log("file:delete", { ip: ctx.locals.ip, fileId: file.id, recursive: true });
safeUnlink(file.path); // Intended safeUnlink(file.path); // Intended
safeUnlink(file.thumbnailPath); // Intended safeUnlink(file.thumbnailPath); // Intended
return file.id; return file.id;

View File

@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db"; import { FileRepo, MediaRepo, IntegrityError } from "$lib/server/db";
import { safeUnlink } from "$lib/server/modules/filesystem"; import { safeUnlink } from "$lib/server/modules/filesystem";
import { demoLogger } from "$lib/server/modules/logger";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "../init.server";
const fileRouter = router({ const fileRouter = router({
@@ -174,6 +175,7 @@ const fileRouter = router({
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
try { try {
const { path, thumbnailPath } = await FileRepo.unregisterFile(ctx.session.userId, input.id); const { path, thumbnailPath } = await FileRepo.unregisterFile(ctx.session.userId, input.id);
demoLogger.log("file:delete", { ip: ctx.locals.ip, fileId: input.id });
safeUnlink(path); // Intended safeUnlink(path); // Intended
safeUnlink(thumbnailPath); // Intended safeUnlink(thumbnailPath); // Intended
} catch (e) { } catch (e) {

View File

@@ -8,6 +8,7 @@ const searchRouter = router({
.input( .input(
z.object({ z.object({
ancestor: DirectoryIdSchema.default("root"), ancestor: DirectoryIdSchema.default("root"),
inFavorites: z.boolean().default(false),
includeCategories: z.number().positive().array().default([]), includeCategories: z.number().positive().array().default([]),
excludeCategories: z.number().positive().array().default([]), excludeCategories: z.number().positive().array().default([]),
}), }),
@@ -15,10 +16,14 @@ const searchRouter = router({
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const [directories, files] = await Promise.all([ const [directories, files] = await Promise.all([
input.includeCategories.length === 0 && input.excludeCategories.length === 0 input.includeCategories.length === 0 && input.excludeCategories.length === 0
? DirectoryRepo.getAllRecursiveDirectoriesByParent(ctx.session.userId, input.ancestor) ? DirectoryRepo.searchDirectories(ctx.session.userId, {
parentId: input.ancestor,
inFavorites: input.inFavorites,
})
: [], : [],
FileRepo.searchFiles(ctx.session.userId, { FileRepo.searchFiles(ctx.session.userId, {
parentId: input.ancestor, parentId: input.ancestor,
inFavorites: input.inFavorites,
includeCategoryIds: input.includeCategories, includeCategoryIds: input.includeCategories,
excludeCategoryIds: input.excludeCategories, excludeCategoryIds: input.excludeCategories,
}), }),

View File

@@ -6,11 +6,13 @@ import mime from "mime";
import { dirname } from "path"; import { dirname } from "path";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { z } from "zod"; import { z } from "zod";
import { MAX_CHUNKS } from "$lib/constants";
import { DirectoryIdSchema } from "$lib/schemas"; import { DirectoryIdSchema } from "$lib/schemas";
import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "$lib/server/db"; import { FileRepo, MediaRepo, UploadRepo, IntegrityError } from "$lib/server/db";
import db from "$lib/server/db/kysely"; import db from "$lib/server/db/kysely";
import env from "$lib/server/loadenv"; import env from "$lib/server/loadenv";
import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem"; import { safeRecursiveRm, safeUnlink } from "$lib/server/modules/filesystem";
import { demoLogger } from "$lib/server/modules/logger";
import { router, roleProcedure } from "../init.server"; import { router, roleProcedure } from "../init.server";
const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours const UPLOADS_EXPIRES = 24 * 3600 * 1000; // 24 hours
@@ -28,7 +30,7 @@ const uploadRouter = router({
startFileUpload: roleProcedure["activeClient"] startFileUpload: roleProcedure["activeClient"]
.input( .input(
z.object({ z.object({
chunks: z.int().positive(), chunks: z.int().positive().max(MAX_CHUNKS),
parent: DirectoryIdSchema, parent: DirectoryIdSchema,
mekVersion: z.int().positive(), mekVersion: z.int().positive(),
dek: z.base64().nonempty(), dek: z.base64().nonempty(),
@@ -76,6 +78,7 @@ const uploadRouter = router({
: null, : null,
encLastModifiedAt: { ciphertext: input.lastModifiedAt, iv: input.lastModifiedAtIv }, encLastModifiedAt: { ciphertext: input.lastModifiedAt, iv: input.lastModifiedAtIv },
}); });
demoLogger.log("upload:start", { ip: ctx.locals.ip, uploadId: id });
return { uploadId: id }; return { uploadId: id };
} catch (e) { } catch (e) {
await safeRecursiveRm(path); await safeRecursiveRm(path);
@@ -153,6 +156,7 @@ const uploadRouter = router({
}); });
await safeRecursiveRm(session.path); await safeRecursiveRm(session.path);
demoLogger.log("upload:complete", { ip: ctx.locals.ip, uploadId, fileId });
return { file: fileId }; return { file: fileId };
} catch (e) { } catch (e) {
await safeUnlink(filePath); await safeUnlink(filePath);
@@ -183,6 +187,7 @@ const uploadRouter = router({
fileId: input.file, fileId: input.file,
dekVersion: input.dekVersion, dekVersion: input.dekVersion,
}); });
demoLogger.log("thumbnail:start", { ip: ctx.locals.ip, uploadId: id });
return { uploadId: id }; return { uploadId: id };
} catch (e) { } catch (e) {
await safeRecursiveRm(path); await safeRecursiveRm(path);
@@ -238,6 +243,11 @@ const uploadRouter = router({
await UploadRepo.deleteUploadSession(trx, uploadId); await UploadRepo.deleteUploadSession(trx, uploadId);
return oldPath; return oldPath;
}); });
demoLogger.log("thumbnail:complete", {
ip: ctx.locals.ip,
uploadId,
fileId: session.fileId,
});
await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]); await Promise.all([safeUnlink(oldThumbnailPath), safeRecursiveRm(session.path)]);
} catch (e) { } catch (e) {
await safeUnlink(thumbnailPath); await safeUnlink(thumbnailPath);