Money: private Documents page with admin-only file endpoint
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:
2026-10-24 14:32:28 +00:00
parent 8818dde99d
commit 35f78ec829
9 changed files with 422 additions and 1 deletions

View File

@@ -267,6 +267,18 @@ are created by `npm run db:migrate:money-tracker`; apply that migration to each
environment before deploying the new route. The deployment preflight checks for environment before deploying the new route. The deployment preflight checks for
all nine tracker tables and the projection columns. all nine tracker tables and the projection columns.
`/trackers/money/documents` lists private documents (contracts, pension rules) with a
written summary, an inline viewer and a download. Nothing about them is in git: the
files and the summaries live on the host in `MONEY_DOCUMENTS_ROOT` (default
`/money-documents` when it exists, otherwise `~/money-documents`) as
`documents.json` (`[{ id, title, category, date, pages, file, summary }]`, summary in
Markdown) plus `files/<file>`. `scripts/deploy-remote.sh` bind-mounts
`/home/stan/money-documents` read-only at `/money-documents` when it has a
`documents.json`. Files are only streamed to admins via
`/trackers/money/documents/<id>/file` (401 logged out, 403 for non-admins,
`private, no-store`). To add one, copy the file into `files/` and add a manifest entry;
no redeploy is needed.
Transactions can be entered manually or imported from a CSV or tab-separated Transactions can be entered manually or imported from a CSV or tab-separated
export with `date,account,description,amount` headers. Dates accept export with `date,account,description,amount` headers. Dates accept
`YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros `YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros

View File

@@ -58,6 +58,31 @@ YAML
else else
echo "No $books_host_dir/library on this host; book files will not be readable" >&2 echo "No $books_host_dir/library on this host; book files will not be readable" >&2
fi fi
# Private money documents (contracts, pension rules) + their summaries also live on the host,
# outside the repo, bind-mounted read-only at /money-documents. The app streams them only to an
# admin via /trackers/money/documents/<id>/file. Layout: documents.json + files/<file>.
money_documents_host_dir=${MONEY_DOCUMENTS_HOST_DIR:-/home/stan/money-documents}
case "$money_documents_host_dir" in /*) ;; *) echo 'MONEY_DOCUMENTS_HOST_DIR must be absolute' >&2; exit 2 ;; esac
if sudo docker run --rm --entrypoint sh \
--mount "type=bind,source=$money_documents_host_dir,target=/money-documents,readonly" "$image" \
-c 'test -f /money-documents/documents.json' 2>/dev/null; then
money_documents_override="$HOME/compose.money-documents.yml"
rm -f "$money_documents_override"
cat >"$money_documents_override" <<YAML
services:
app:
environment:
MONEY_DOCUMENTS_ROOT: /money-documents
volumes:
- type: bind
source: $money_documents_host_dir
target: /money-documents
read_only: true
YAML
compose_files+=(-f "$money_documents_override")
else
echo "No $money_documents_host_dir/documents.json on this host; money documents will not be readable" >&2
fi
previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1) previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1)
switched=0 switched=0
rollback() { rollback() {

View File

@@ -0,0 +1,52 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
listMoneyDocuments,
parseManifest,
resolveDocumentFile,
validDocumentId
} from './money-documents';
let root: string;
beforeAll(async () => {
root = await mkdtemp(path.join(tmpdir(), 'money-docs-'));
await mkdir(path.join(root, 'files'), { recursive: true });
await writeFile(path.join(root, 'files', 'contract.pdf'), '%PDF-1.7');
await writeFile(path.join(root, 'secret.txt'), 'nope');
await symlink(path.join(root, 'secret.txt'), path.join(root, 'files', 'escape.pdf'));
await writeFile(
path.join(root, 'documents.json'),
JSON.stringify([
{ id: 'contract', title: 'Contract', file: 'contract.pdf', date: '2026-09-22', pages: 11 },
{ id: 'contract', title: 'Duplicate', file: 'contract.pdf' },
{ id: '../bad', title: 'Bad', file: 'x.pdf' },
{ id: 'no-file', title: 'No file' }
])
);
});
afterAll(() => rm(root, { recursive: true, force: true }));
describe('money documents', () => {
it('validates ids', () => {
expect(validDocumentId('cargill-contract-2026')).toBe(true);
expect(validDocumentId('../etc')).toBe(false);
expect(validDocumentId('')).toBe(false);
});
it('keeps only well-formed, unique manifest entries', async () => {
const docs = await listMoneyDocuments(root);
expect(docs.map((d) => d.id)).toEqual(['contract']);
expect(docs[0]).toMatchObject({ category: 'Other', date: '2026-09-22', pages: 11 });
expect(parseManifest({ not: 'an array' })).toEqual([]);
});
it('returns no documents when the manifest is missing', async () => {
expect(await listMoneyDocuments(path.join(root, 'missing'))).toEqual([]);
});
it('resolves files inside files/ only', async () => {
expect(await resolveDocumentFile('contract.pdf', root)).toMatch(/contract\.pdf$/);
expect(await resolveDocumentFile('../secret.txt', root)).toBeNull();
expect(await resolveDocumentFile('escape.pdf', root)).toBeNull();
expect(await resolveDocumentFile('/etc/passwd', root)).toBeNull();
});
});

View File

@@ -0,0 +1,91 @@
// Private money documents (employment contracts, pension rules, ...). The files and their
// written summaries live on the host, outside the repo, and are bind-mounted read-only at
// /money-documents in prod (see scripts/deploy-remote.sh). They are only ever streamed to an
// administrator through /trackers/money/documents/[id]/file; nothing is under static/ or in git.
//
// Layout of MONEY_DOCUMENTS_ROOT:
// documents.json manifest: [{ id, title, category, date, file, pages?, summary }]
// files/<file> the documents themselves (file is relative to files/)
import { existsSync } from 'node:fs';
import { readFile, realpath, stat } from 'node:fs/promises';
import { homedir } from 'node:os';
import path from 'node:path';
export type MoneyDocument = {
id: string;
title: string;
category: string;
date: string | null;
file: string;
pages: number | null;
/** Markdown, written by hand for this document. */
summary: string;
};
export function moneyDocumentsRoot(): string {
if (process.env.MONEY_DOCUMENTS_ROOT) return process.env.MONEY_DOCUMENTS_ROOT;
return existsSync('/money-documents/documents.json')
? '/money-documents'
: path.join(homedir(), 'money-documents');
}
const ID = /^[a-z0-9][a-z0-9-]{0,80}$/;
export function validDocumentId(id: string): boolean {
return ID.test(id);
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
/** Validates a parsed manifest; entries that are malformed are skipped, never thrown. */
export function parseManifest(raw: unknown): MoneyDocument[] {
if (!Array.isArray(raw)) return [];
const seen = new Set<string>();
const documents: MoneyDocument[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== 'object') continue;
const item = entry as Record<string, unknown>;
const id = text(item.id);
const title = text(item.title);
const file = text(item.file);
if (!id || !validDocumentId(id) || seen.has(id) || !title || !file) continue;
const date = text(item.date);
seen.add(id);
documents.push({
id,
title,
category: text(item.category) ?? 'Other',
date: date && /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null,
file,
pages: typeof item.pages === 'number' && item.pages > 0 ? Math.floor(item.pages) : null,
summary: typeof item.summary === 'string' ? item.summary : ''
});
}
return documents;
}
export async function listMoneyDocuments(root = moneyDocumentsRoot()): Promise<MoneyDocument[]> {
try {
return parseManifest(JSON.parse(await readFile(path.join(root, 'documents.json'), 'utf8')));
} catch {
return [];
}
}
/** Resolves a manifest file inside <root>/files, refusing anything that escapes it. */
export async function resolveDocumentFile(
relative: string,
root = moneyDocumentsRoot()
): Promise<string | null> {
if (!relative || relative.includes('\0') || path.isAbsolute(relative)) return null;
const base = path.join(root, 'files');
try {
const realBase = await realpath(base);
const real = await realpath(path.join(base, relative));
if (!real.startsWith(realBase + path.sep)) return null;
return (await stat(real)).isFile() ? real : null;
} catch {
return null;
}
}

View File

@@ -15,6 +15,7 @@
import Trash2 from 'lucide-svelte/icons/trash-2'; import Trash2 from 'lucide-svelte/icons/trash-2';
import Upload from 'lucide-svelte/icons/upload'; import Upload from 'lucide-svelte/icons/upload';
import Download from 'lucide-svelte/icons/download'; import Download from 'lucide-svelte/icons/download';
import FileText from 'lucide-svelte/icons/file-text';
import MoneyChart from '#lib/money-tracker/MoneyChart.svelte'; import MoneyChart from '#lib/money-tracker/MoneyChart.svelte';
import QuickAdd from '#lib/money-tracker/QuickAdd.svelte'; import QuickAdd from '#lib/money-tracker/QuickAdd.svelte';
import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js'; import { compactAmount, sortWishlist, tierClass } from '#lib/money-tracker/wishlist.js';
@@ -281,7 +282,9 @@
<h1>Money</h1> <h1>Money</h1>
</div> </div>
<div class="header-actions"> <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 ><Upload size={16} /> Import CSV</Button
><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button> ><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button>
</div> </div>

View 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)
}))
};
};

View 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>

View 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;

View 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);
});
});