import express from 'express' import fs from 'node:fs' import type http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' import { apiRouter } from './api' import { maybePullAwsCosts } from './aws-costs' import { pollBounces } from './bounces' import { openDb, type DB } from './db' import { openPgDb } from './db-pg' import { makeRateLimiter } from './rate-limit' 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. const moduleDir = typeof __dirname === 'undefined' ? path.dirname(fileURLToPath(import.meta.url)) : __dirname function schedulerDeps(db: DB) { const company = async (): Promise> => Object.fromEntries((await db.all<{ key: string; value: string }>( `SELECT key, value FROM setting WHERE key LIKE 'company.%'`, )).map((r) => [r.key, r.value])) const gmail = { f: fetch, clientId: process.env['GOOGLE_CLIENT_ID'] ?? '', clientSecret: process.env['GOOGLE_CLIENT_SECRET'] ?? '', keyHex: process.env['HQ_SECRET_KEY'] ?? '', } return { gmail, renderPdf, company } } /** Boot the scan + bounce poll now and every 6h. unref() so it never blocks exit. */ export function startScheduler(db: DB): NodeJS.Timeout { const deps = schedulerDeps(db) const awsCreds = { accessKeyId: process.env['AWS_ACCESS_KEY_ID'] ?? '', secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'] ?? '', } const today = (): string => new Date().toISOString().slice(0, 10) const tick = (): void => { void runDailyScan(db, deps, today()).catch((e: unknown) => console.error('[scheduler] scan failed', e)) void pollBounces(db, deps).catch((e: unknown) => console.error('[scheduler] bounce poll failed', e)) // AWS cost pull only when credentials are configured — env-gated, once per month. if (awsCreds.accessKeyId !== '' && awsCreds.secretAccessKey !== '') { void maybePullAwsCosts(db, { f: fetch, creds: awsCreds }, today()) .catch((e: unknown) => console.error('[scheduler] aws cost pull failed', e)) } } tick() const handle = setInterval(tick, 6 * 60 * 60 * 1000) handle.unref() return handle } /** The letterhead settings map documentHtml reads — company.* identity + template.* text. */ async function companySettings(db: DB): Promise> { const rows = await db.all<{ key: string; value: string }>( `SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`, ) return Object.fromEntries(rows.map((row) => [row.key, row.value])) } // Re-exported for existing importers/tests; the source of truth is rate-limit.ts, // shared with the api router without a server↔api import cycle. export { makeRateLimiter } from './rate-limit' /** 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'] ?? '') void (async () => { const doc = await 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 = await getClient(db, doc.clientId) if (client === null) { gone(res, 404, 'This link has expired or is invalid.'); return } const company = await companySettings(db) 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.') } })().catch(() => { // A DB failure resolving the token must not become an unhandled rejection. gone(res, 500, 'This document could not be rendered right now.') }) }) } export async function startServer(port: number, opts: { scheduler?: boolean } = {}): Promise { const app = express() // D24 (red-team): behind the reverse proxy (nginx/Caddy), trust the first hop so // req.ip reflects the real client via X-Forwarded-For — without this every request // shares the proxy's IP and per-IP rate limiting collapses to one global bucket. // The proxy MUST strip client-supplied X-Forwarded-For so it cannot be spoofed. app.set('trust proxy', 1) // Hardcoded production DB connection (team decision — used instead of a .env DATABASE_URL, // to match how the other apps on this box are configured). To change the database, edit // HARDCODED_DATABASE_URL below and rebuild the image. Gated to NODE_ENV=production so dev // and the test suite keep selecting SQLite (an unset/empty DATABASE_URL) and stay green. // ⚠️ Once the real password is filled in, this file holds a secret — do not push the // filled-in value to a repo others can read. const HARDCODED_DATABASE_URL = 'postgres://postgres:CHANGE_ME_PASSWORD@host.docker.internal:5432/hq' // D19 engine selection: DATABASE_URL (or the hardcoded prod value) → Postgres; else SQLite. const pgUrl = process.env['DATABASE_URL'] || (process.env['NODE_ENV'] === 'production' ? HARDCODED_DATABASE_URL : '') const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) if (pgUrl !== '') console.log('[db] engine: postgres') await 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)) if (opts.scheduler === true) startScheduler(db) return app.listen(port) } // Direct launch (bundled dist/server.cjs); vitest imports startServer instead. if (process.argv[1]?.endsWith('server.cjs') || process.argv[1]?.endsWith('server.ts')) { const PORT = Number(process.env['HQ_PORT'] ?? 5182) startServer(PORT, { scheduler: true }) .then(() => { console.log(`SiMS HQ console on http://localhost:${PORT}`) }) .catch((err: unknown) => { console.error('[server] failed to start', err); process.exit(1) }) }