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:
52
src/lib/server/money-documents.test.ts
Normal file
52
src/lib/server/money-documents.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
91
src/lib/server/money-documents.ts
Normal file
91
src/lib/server/money-documents.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user