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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent a1759864bf
commit 63e377d649

@ -8,8 +8,11 @@ import { maybePullAwsCosts } from './aws-costs'
import { pollBounces } from './bounces' import { pollBounces } from './bounces'
import { openDb, type DB } from './db' import { openDb, type DB } from './db'
import { renderPdf } from './pdf' import { renderPdf } from './pdf'
import { getClient } from './repos-clients'
import { validateShare } from './repos-shares'
import { runDailyScan } from './scheduler' import { runDailyScan } from './scheduler'
import { seedIfEmpty } from './seed' import { seedIfEmpty } from './seed'
import { documentHtml } from './templates'
// dist/server.cjs (esbuild CJS bundle) has __dirname; vitest/tsx run this file as ESM. // 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. // 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 return handle
} }
/** The letterhead settings map documentHtml reads — company.* identity + template.* text. */
function companySettings(db: DB): Record<string, string> {
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<string, { count: number; resetAt: number }>()
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 `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Link unavailable</title>
<style>
body{ font-family:'Segoe UI','Inter',Arial,sans-serif; background:#eceef1; color:#1f2937;
display:flex; min-height:100vh; align-items:center; justify-content:center; margin:0; }
.card{ background:#fff; max-width:420px; padding:36px 40px; border-radius:10px;
box-shadow:0 1px 4px rgba(0,0,0,.18); text-align:center; }
h1{ font-size:18px; margin:0 0 8px; color:#111827; }
p{ margin:0; color:#6b7280; font-size:13.5px; line-height:1.5; }
</style></head>
<body><div class="card"><h1>Link unavailable</h1><p>${message}</p></div></body></html>`
}
export interface PublicShareDeps {
/** HTML → PDF renderer; the real puppeteer one in production, a fake in tests. */
renderPdf: (html: string) => Promise<Buffer>
/** 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 { export function startServer(port: number, opts: { scheduler?: boolean } = {}): http.Server {
const app = express() const app = express()
const db = openDb(process.env['HQ_DATA_DIR']) const db = openDb(process.env['HQ_DATA_DIR'])
seedIfEmpty(db) // first boot prints the owner password once seedIfEmpty(db) // first boot prints the owner password once
app.locals['db'] = db app.locals['db'] = db
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '2mb' }))
// Public, unauthenticated document view — mounted OUTSIDE /api, before static.
mountPublicShare(app, db, { renderPdf })
app.use('/api', apiRouter(db)) app.use('/api', apiRouter(db))
const webDist = path.resolve(moduleDir, '../../hq-web/dist') const webDist = path.resolve(moduleDir, '../../hq-web/dist')
if (fs.existsSync(webDist)) app.use('/', express.static(webDist)) if (fs.existsSync(webDist)) app.use('/', express.static(webDist))

@ -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<Buffer> => { rendered.push(html); return Buffer.from('%PDF-1.4 fake') }
const app = express(); app.use(express.json()); app.locals['db'] = db
const deps: Parameters<typeof mountPublicShare>[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() }
})
})
Loading…
Cancel
Save