From 63e377d64945f6f9d61568083d64f508596f5d0d Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Mon, 13 Jul 2026 18:03:44 +0530 Subject: [PATCH] feat(hq): public GET /share/:token document view route Mount the one unauthenticated read route OUTSIDE the /api auth router. A live token resolves via validateShare to exactly one document, rendered inline (application/pdf) through the existing documentHtml -> renderPdf path; unknown/expired/revoked tokens return a plain 404 "link expired or invalid" HTML page carrying no document data. Adds a dependency-free per-IP fixed-window rate limiter to blunt token brute-forcing. renderPdf is injected so the route is TDD-tested without launching Chrome. Public reads write no audit; the token is never logged. Co-Authored-By: Claude Fable 5 --- apps/hq/src/server.ts | 97 ++++++++++++++++++++++++++ apps/hq/test/public-share.test.ts | 110 ++++++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 apps/hq/test/public-share.test.ts diff --git a/apps/hq/src/server.ts b/apps/hq/src/server.ts index 60feb70..c26ee63 100644 --- a/apps/hq/src/server.ts +++ b/apps/hq/src/server.ts @@ -8,8 +8,11 @@ import { maybePullAwsCosts } from './aws-costs' import { pollBounces } from './bounces' import { openDb, type DB } from './db' import { renderPdf } from './pdf' +import { getClient } from './repos-clients' +import { validateShare } from './repos-shares' import { runDailyScan } from './scheduler' import { seedIfEmpty } from './seed' +import { documentHtml } from './templates' // dist/server.cjs (esbuild CJS bundle) has __dirname; vitest/tsx run this file as ESM. // Both src/ and dist/ sit one level under apps/hq, so ../../hq-web/dist works from either. @@ -53,12 +56,106 @@ export function startScheduler(db: DB): NodeJS.Timeout { return handle } +/** The letterhead settings map documentHtml reads — company.* identity + template.* text. */ +function companySettings(db: DB): Record { + const rows = db.prepare( + `SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`, + ).all() as { key: string; value: string }[] + return Object.fromEntries(rows.map((row) => [row.key, row.value])) +} + +/** + * A dependency-free fixed-window rate limiter keyed by an opaque string (the caller + * passes the client IP). Returns true while the key is under `limit` in the current + * `windowMs` window, false once it is exhausted. Expired buckets are swept lazily so + * the map cannot grow without bound under IP rotation. + */ +export function makeRateLimiter(limit: number, windowMs: number): (key: string, now?: number) => boolean { + const buckets = new Map() + return (key: string, now = Date.now()): boolean => { + if (buckets.size > 5000) for (const [k, b] of buckets) if (now >= b.resetAt) buckets.delete(k) + const bucket = buckets.get(key) + if (bucket === undefined || now >= bucket.resetAt) { + buckets.set(key, { count: 1, resetAt: now + windowMs }) + return true + } + if (bucket.count >= limit) return false + bucket.count += 1 + return true + } +} + +/** Minimal, self-contained "this link is gone" page. Carries no document data. */ +function shareGonePage(message: string): string { + return ` + + +Link unavailable + +

Link unavailable

${message}

` +} + +export interface PublicShareDeps { + /** HTML → PDF renderer; the real puppeteer one in production, a fake in tests. */ + renderPdf: (html: string) => Promise + /** Per-IP lookup cap; default 30 lookups per 60s to blunt token brute-forcing. */ + rateLimit?: { limit: number; windowMs: number } +} + +/** + * Mount the ONE unauthenticated read route: GET /share/:token. It lives OUTSIDE the + * /api auth router and serves nothing but the single document a live token points at — + * rendered inline as application/pdf through the same documentHtml → renderPdf path the + * authenticated /pdf route uses. Unknown/expired/revoked tokens → a plain 404 page with + * no document data. Lookups are rate-limited per client IP. The token is a secret: it is + * never logged (validateShare does the lookup; nothing writes it out). + */ +export function mountPublicShare(app: express.Express, db: DB, deps: PublicShareDeps): void { + const limit = deps.rateLimit?.limit ?? 30 + const windowMs = deps.rateLimit?.windowMs ?? 60_000 + const allow = makeRateLimiter(limit, windowMs) + const gone = (res: express.Response, status: number, message: string): void => { + res.status(status).type('html').send(shareGonePage(message)) + } + app.get('/share/:token', (req, res) => { + const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown' + if (!allow(ip)) { gone(res, 429, 'Too many requests. Please try again in a minute.'); return } + const token = String(req.params['token'] ?? '') + const doc = validateShare(db, token) // null for unknown / revoked / expired — a public read, no audit + if (doc === null) { gone(res, 404, 'This link has expired or is invalid.'); return } + const client = getClient(db, doc.clientId) + if (client === null) { gone(res, 404, 'This link has expired or is invalid.'); return } + const company = companySettings(db) + void (async () => { + try { + const pdf = await deps.renderPdf(documentHtml(doc, client, company)) + res.setHeader('Content-Type', 'application/pdf') + // docNo carries '/' (QT/26-27-0001) which is not a legal filename char. + const filename = (doc.docNo ?? 'document').replaceAll('/', '-') + res.setHeader('Content-Disposition', `inline; filename="${filename}.pdf"`) + res.send(pdf) + } catch { + gone(res, 500, 'This document could not be rendered right now.') + } + })() + }) +} + export function startServer(port: number, opts: { scheduler?: boolean } = {}): http.Server { const app = express() const db = openDb(process.env['HQ_DATA_DIR']) seedIfEmpty(db) // first boot prints the owner password once app.locals['db'] = db app.use(express.json({ limit: '2mb' })) + // Public, unauthenticated document view — mounted OUTSIDE /api, before static. + mountPublicShare(app, db, { renderPdf }) app.use('/api', apiRouter(db)) const webDist = path.resolve(moduleDir, '../../hq-web/dist') if (fs.existsSync(webDist)) app.use('/', express.static(webDist)) diff --git a/apps/hq/test/public-share.test.ts b/apps/hq/test/public-share.test.ts new file mode 100644 index 0000000..13d2604 --- /dev/null +++ b/apps/hq/test/public-share.test.ts @@ -0,0 +1,110 @@ +// apps/hq/test/public-share.test.ts +import { describe, it, expect } from 'vitest' +import express from 'express' +import { openDb } from '../src/db' +import { seedIfEmpty } from '../src/seed' +import { createStaff } from '../src/auth' +import { createClient } from '../src/repos-clients' +import { createModule, setPrice } from '../src/repos-modules' +import { createDraft } from '../src/repos-documents' +import { mintShare, revokeShare } from '../src/repos-shares' +import { mountPublicShare } from '../src/server' + +/** + * The PUBLIC read route GET /share/:token — mounted OUTSIDE the /api auth router. + * A live token renders exactly one document via the existing documentHtml/renderPdf + * path and returns it inline (application/pdf); unknown/expired/revoked → a plain 404 + * HTML page. No auth header is ever required, and lookups are rate-limited per IP. + * + * renderPdf is injected (a fake %PDF- buffer) so the route logic is exercised without + * launching Chrome — the house pattern for every PDF-touching test in this suite. + */ +function setup(rateLimit?: { limit: number; windowMs: number }) { + const db = openDb(':memory:'); seedIfEmpty(db) + createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' }) + const c = createClient(db, 'u1', { name: 'Acme Traders', code: 'ACME', stateCode: '32' }) + const m = createModule(db, 'u1', { code: 'POS', name: 'POS Billing' }) + setPrice(db, 'u1', { moduleId: m.id, kind: 'yearly', pricePaise: 10_000_00, effectiveFrom: '2026-04-01' }) + const doc = createDraft(db, 'u1', { docType: 'QUOTATION', clientId: c.id, + lines: [{ moduleId: m.id, qty: 1, kind: 'yearly' }] }) + const rendered: string[] = [] + const fakePdf = async (html: string): Promise => { rendered.push(html); return Buffer.from('%PDF-1.4 fake') } + const app = express(); app.use(express.json()); app.locals['db'] = db + const deps: Parameters[2] = { renderPdf: fakePdf } + if (rateLimit !== undefined) deps.rateLimit = rateLimit + mountPublicShare(app, db, deps) + const server = app.listen(0) + const base = `http://localhost:${(server.address() as { port: number }).port}` + return { db, server, base, c, m, doc, rendered } +} + +describe('public GET /share/:token', () => { + it('serves the one document inline as a PDF for a live token — with NO auth header', async () => { + const ctx = setup() + try { + const s = mintShare(ctx.db, 'u1', ctx.doc.id) + const res = await fetch(`${ctx.base}/share/${s.token}`) // deliberately no authorization header + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toContain('application/pdf') + const buf = Buffer.from(await res.arrayBuffer()) + expect(buf.subarray(0, 5).toString()).toBe('%PDF-') + // Rendered exactly one document, and it was THIS document (its client is on the letterhead). + expect(ctx.rendered.length).toBe(1) + expect(ctx.rendered[0]).toContain('Acme Traders') + } finally { ctx.server.close() } + }) + + it('serves the PDF inline (Content-Disposition inline, not attachment)', async () => { + const ctx = setup() + try { + const s = mintShare(ctx.db, 'u1', ctx.doc.id) + const res = await fetch(`${ctx.base}/share/${s.token}`) + expect(res.headers.get('content-disposition')).toMatch(/^inline/) + } finally { ctx.server.close() } + }) + + it('404s with a plain HTML "expired or invalid" page for an unknown token — renders nothing', async () => { + const ctx = setup() + try { + const res = await fetch(`${ctx.base}/share/deadbeefdeadbeef`) + expect(res.status).toBe(404) + expect(res.headers.get('content-type')).toContain('text/html') + const body = (await res.text()).toLowerCase() + expect(body).toMatch(/expired|invalid/) + expect(ctx.rendered.length).toBe(0) // a miss never touches the renderer + } finally { ctx.server.close() } + }) + + it('404s for an expired token', async () => { + const ctx = setup() + try { + const s = mintShare(ctx.db, 'u1', ctx.doc.id, { expiresDays: -1 }) // already expired + const res = await fetch(`${ctx.base}/share/${s.token}`) + expect(res.status).toBe(404) + expect(ctx.rendered.length).toBe(0) + } finally { ctx.server.close() } + }) + + it('404s for a revoked token', async () => { + const ctx = setup() + try { + const s = mintShare(ctx.db, 'u1', ctx.doc.id) + revokeShare(ctx.db, 'u1', s.id) + const res = await fetch(`${ctx.base}/share/${s.token}`) + expect(res.status).toBe(404) + expect(ctx.rendered.length).toBe(0) + } finally { ctx.server.close() } + }) + + it('rate-limits lookups per IP to blunt token brute-forcing', async () => { + const ctx = setup({ limit: 3, windowMs: 60_000 }) + try { + for (let i = 0; i < 3; i++) { + const r = await fetch(`${ctx.base}/share/badtoken${i}`) + expect(r.status).toBe(404) // allowed (though a miss), counts against the cap + } + const blocked = await fetch(`${ctx.base}/share/badtoken-final`) + expect(blocked.status).toBe(429) + } finally { ctx.server.close() } + }) +})