diff --git a/README.md b/README.md index bb886b7..3f9c743 100644 --- a/README.md +++ b/README.md @@ -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 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/`. `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//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 export with `date,account,description,amount` headers. Dates accept `YYYY-MM-DD`, `DD/MM/YYYY`, or `Fri 1 Aug 2025`; amounts accept signed euros diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 0a8d8bd..157d44f 100644 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -58,6 +58,31 @@ YAML else echo "No $books_host_dir/library on this host; book files will not be readable" >&2 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//file. Layout: documents.json + files/. +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" <&2 +fi previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1) switched=0 rollback() { diff --git a/src/lib/server/money-documents.test.ts b/src/lib/server/money-documents.test.ts new file mode 100644 index 0000000..625a20a --- /dev/null +++ b/src/lib/server/money-documents.test.ts @@ -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(); + }); +}); diff --git a/src/lib/server/money-documents.ts b/src/lib/server/money-documents.ts new file mode 100644 index 0000000..c287671 --- /dev/null +++ b/src/lib/server/money-documents.ts @@ -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/ 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(); + const documents: MoneyDocument[] = []; + for (const entry of raw) { + if (!entry || typeof entry !== 'object') continue; + const item = entry as Record; + 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 { + try { + return parseManifest(JSON.parse(await readFile(path.join(root, 'documents.json'), 'utf8'))); + } catch { + return []; + } +} + +/** Resolves a manifest file inside /files, refusing anything that escapes it. */ +export async function resolveDocumentFile( + relative: string, + root = moneyDocumentsRoot() +): Promise { + 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; + } +} diff --git a/src/routes/trackers/money/+page.svelte b/src/routes/trackers/money/+page.svelte index e5f049b..d950576 100644 --- a/src/routes/trackers/money/+page.svelte +++ b/src/routes/trackers/money/+page.svelte @@ -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 @@

Money

-
diff --git a/src/routes/trackers/money/documents/+page.server.ts b/src/routes/trackers/money/documents/+page.server.ts new file mode 100644 index 0000000..2890ace --- /dev/null +++ b/src/routes/trackers/money/documents/+page.server.ts @@ -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) + })) + }; +}; diff --git a/src/routes/trackers/money/documents/+page.svelte b/src/routes/trackers/money/documents/+page.svelte new file mode 100644 index 0000000..f0f4593 --- /dev/null +++ b/src/routes/trackers/money/documents/+page.svelte @@ -0,0 +1,128 @@ + + + + Documents · Money + + + +
+
+ Money +

Documents

+

+ Contracts and other paperwork, each with a summary of what matters for money. Private: only + visible to you. +

+
+ + {#if data.documents.length === 0} +

No documents yet.

+ {/if} + + {#each data.documents as document (document.id)} +
+
+
+

{document.title}

+

+ {document.category} + {#if document.date}{dateLabel(document.date)}{/if} + {#if document.pages}· {document.pages} pages{/if} + · {document.fileName} +

+
+
+ + + +
+
+ + {#if viewing === document.id} + + {/if} + +
+ + {@html document.summaryHtml} +
+
+ {/each} +
+ + diff --git a/src/routes/trackers/money/documents/[id]/file/+server.ts b/src/routes/trackers/money/documents/[id]/file/+server.ts new file mode 100644 index 0000000..7de01ad --- /dev/null +++ b/src/routes/trackers/money/documents/[id]/file/+server.ts @@ -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; diff --git a/src/routes/trackers/money/documents/auth.test.ts b/src/routes/trackers/money/documents/auth.test.ts new file mode 100644 index 0000000..ef8d42a --- /dev/null +++ b/src/routes/trackers/money/documents/auth.test.ts @@ -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()), + 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); + }); +});