Money: private Documents page with admin-only file endpoint
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
All checks were successful
Deploy production / deploy (push) Successful in 2m32s
Lists contracts/pension docs with written summaries, an inline viewer and download at /trackers/money/documents. Files and summaries live on the host (MONEY_DOCUMENTS_ROOT, bind-mounted read-only by deploy-remote.sh), never in git or static/; the file endpoint is admin-only (401/403) and private, no-store.
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
import Trash2 from 'lucide-svelte/icons/trash-2';
|
||||
import Upload from 'lucide-svelte/icons/upload';
|
||||
import Download from 'lucide-svelte/icons/download';
|
||||
import FileText from 'lucide-svelte/icons/file-text';
|
||||
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
|
||||
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
|
||||
import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
|
||||
@@ -281,7 +282,9 @@
|
||||
<h1>Money</h1>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button variant="outline" onclick={() => open('import')}
|
||||
<Button variant="outline" href={resolve('/trackers/money/documents')}
|
||||
><FileText size={16} /> Documents</Button
|
||||
><Button variant="outline" onclick={() => open('import')}
|
||||
><Upload size={16} /> Import CSV</Button
|
||||
><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button>
|
||||
</div>
|
||||
|
||||
20
src/routes/trackers/money/documents/+page.server.ts
Normal file
20
src/routes/trackers/money/documents/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { renderMarkdown } from '#lib/server/markdown.js';
|
||||
import { listMoneyDocuments } from '#lib/server/money-documents.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async (event) => {
|
||||
if (!event.locals.user) redirect(303, '/login?returnTo=/trackers/money/documents');
|
||||
requireAdmin(event);
|
||||
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||
const documents = await listMoneyDocuments();
|
||||
return {
|
||||
documents: documents.map(({ file, summary, ...item }) => ({
|
||||
...item,
|
||||
fileName: file.split('/').at(-1) ?? file,
|
||||
// The manifest is written by the owner on the host, not user input.
|
||||
summaryHtml: renderMarkdown(summary)
|
||||
}))
|
||||
};
|
||||
};
|
||||
128
src/routes/trackers/money/documents/+page.svelte
Normal file
128
src/routes/trackers/money/documents/+page.svelte
Normal file
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Badge } from '#lib/components/ui/badge/index.js';
|
||||
import { Button } from '#lib/components/ui/button/index.js';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import Download from 'lucide-svelte/icons/download';
|
||||
import Eye from 'lucide-svelte/icons/eye';
|
||||
import EyeOff from 'lucide-svelte/icons/eye-off';
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let viewing = $state<string | null>(null);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-NL', { dateStyle: 'medium' });
|
||||
function dateLabel(date: string) {
|
||||
return dateFormat.format(new Date(date + 'T12:00:00'));
|
||||
}
|
||||
function fileHref(id: string, download = false) {
|
||||
const href = resolve('/trackers/money/documents/[id]/file', { id });
|
||||
return download ? href + '?download' : href;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Documents · Money</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-screen-lg space-y-6 px-4 py-8 sm:px-6 sm:py-10">
|
||||
<header>
|
||||
<a
|
||||
class="inline-flex items-center gap-1 text-sm opacity-70 hover:opacity-100"
|
||||
href={resolve('/trackers/money')}><ArrowLeft size={14} /> Money</a
|
||||
>
|
||||
<h1 class="text-2xl font-bold">Documents</h1>
|
||||
<p class="text-sm opacity-70">
|
||||
Contracts and other paperwork, each with a summary of what matters for money. Private: only
|
||||
visible to you.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if data.documents.length === 0}
|
||||
<p class="rounded-lg border p-6 text-sm opacity-70">No documents yet.</p>
|
||||
{/if}
|
||||
|
||||
{#each data.documents as document (document.id)}
|
||||
<article class="space-y-4 rounded-lg border p-5" id={document.id}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold">{document.title}</h2>
|
||||
<p class="flex flex-wrap items-center gap-2 text-sm opacity-70">
|
||||
<Badge variant="secondary">{document.category}</Badge>
|
||||
{#if document.date}<span>{dateLabel(document.date)}</span>{/if}
|
||||
{#if document.pages}<span>· {document.pages} pages</span>{/if}
|
||||
<span>· {document.fileName}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-expanded={viewing === document.id}
|
||||
onclick={() => (viewing = viewing === document.id ? null : document.id)}
|
||||
>
|
||||
{#if viewing === document.id}<EyeOff size={14} /> Hide{:else}<Eye size={14} /> View{/if}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" href={fileHref(document.id)} target="_blank"
|
||||
><ExternalLink size={14} /> Open</Button
|
||||
>
|
||||
<Button variant="outline" size="sm" href={fileHref(document.id, true)}
|
||||
><Download size={14} /> Download</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if viewing === document.id}
|
||||
<iframe
|
||||
class="h-[75vh] w-full rounded border"
|
||||
title={document.title}
|
||||
src={fileHref(document.id)}
|
||||
></iframe>
|
||||
{/if}
|
||||
|
||||
<div class="document-summary text-sm leading-relaxed">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- owner-written manifest on the host -->
|
||||
{@html document.summaryHtml}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.document-summary :global {
|
||||
h3 {
|
||||
margin-top: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
table {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
ul {
|
||||
list-style: disc;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
ol {
|
||||
list-style: decimal;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid color-mix(in oklab, currentColor 15%, transparent);
|
||||
padding: 0.3rem 0.5rem 0.3rem 0;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
src/routes/trackers/money/documents/[id]/file/+server.ts
Normal file
32
src/routes/trackers/money/documents/[id]/file/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Admin-only stream of a private money document (contracts etc.). Logged-out visitors get 401,
|
||||
// non-admins 403. Files come from MONEY_DOCUMENTS_ROOT/files on the host, never from static/.
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { requireAdmin } from '#lib/server/authorization.js';
|
||||
import { streamFile } from '#lib/server/books/files.js';
|
||||
import {
|
||||
listMoneyDocuments,
|
||||
resolveDocumentFile,
|
||||
validDocumentId
|
||||
} from '#lib/server/money-documents.js';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
const handler: RequestHandler = async (event) => {
|
||||
requireAdmin(event);
|
||||
const id = event.params.id;
|
||||
const document = validDocumentId(id)
|
||||
? (await listMoneyDocuments()).find((item) => item.id === id)
|
||||
: undefined;
|
||||
const resolved = document ? await resolveDocumentFile(document.file) : null;
|
||||
if (!resolved) error(404, 'Not Found');
|
||||
const name = resolved.split('/').at(-1) ?? 'document';
|
||||
const disposition = event.url.searchParams.has('download') ? 'attachment' : 'inline';
|
||||
return streamFile(resolved, event.request, {
|
||||
'cache-control': 'private, no-store',
|
||||
'x-robots-tag': 'noindex, nofollow, noarchive',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'content-disposition': `${disposition}; filename*=UTF-8''${encodeURIComponent(name)}`
|
||||
});
|
||||
};
|
||||
|
||||
export const GET = handler;
|
||||
export const HEAD = handler;
|
||||
58
src/routes/trackers/money/documents/auth.test.ts
Normal file
58
src/routes/trackers/money/documents/auth.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// Money documents are admin-only: the page redirects logged-out visitors to /login, and the
|
||||
// file endpoint answers 401 logged out and 403 for non-admins without touching the disk.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isHttpError, isRedirect } from '@sveltejs/kit';
|
||||
|
||||
vi.mock('#lib/server/money-documents.js', async (original) => ({
|
||||
...(await original<typeof import('#lib/server/money-documents.js')>()),
|
||||
listMoneyDocuments: vi.fn(async () => []),
|
||||
resolveDocumentFile: vi.fn(async () => null)
|
||||
}));
|
||||
|
||||
import * as documents from '#lib/server/money-documents.js';
|
||||
import { GET } from './[id]/file/+server';
|
||||
import { load } from './+page.server';
|
||||
|
||||
const ADMIN = { id: 'u1', email: 'stan@stanrunge.dev', role: 'admin' };
|
||||
const MEMBER = { id: 'u2', email: 'someone@example.com', role: 'user' };
|
||||
|
||||
function event(user: unknown, id = 'contract') {
|
||||
const url = new URL(`https://stanrunge.dev/trackers/money/documents/${id}/file`);
|
||||
return {
|
||||
locals: { user },
|
||||
params: { id },
|
||||
url,
|
||||
request: new Request(url),
|
||||
setHeaders: vi.fn()
|
||||
} as never;
|
||||
}
|
||||
|
||||
async function status(run: () => unknown) {
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
if (isHttpError(err)) return err.status;
|
||||
if (isRedirect(err)) return err.status;
|
||||
throw err;
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('money documents auth', () => {
|
||||
it('file endpoint: 401 logged out, 403 non-admin, no disk access', async () => {
|
||||
expect(await status(() => GET(event(null)))).toBe(401);
|
||||
expect(await status(() => GET(event(MEMBER)))).toBe(403);
|
||||
expect(documents.listMoneyDocuments).not.toHaveBeenCalled();
|
||||
});
|
||||
it('file endpoint: 404 for unknown ids for the admin', async () => {
|
||||
expect(await status(() => GET(event(ADMIN, 'nope')))).toBe(404);
|
||||
expect(await status(() => GET(event(ADMIN, '..')))).toBe(404);
|
||||
});
|
||||
it('page redirects logged-out visitors and rejects non-admins', async () => {
|
||||
expect(await status(() => load(event(null)))).toBe(303);
|
||||
expect(await status(() => load(event(MEMBER)))).toBe(403);
|
||||
expect(await status(() => load(event(ADMIN)))).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user