You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/server.ts

177 lines
8.8 KiB
TypeScript

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<Record<string, string>> =>
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<Record<string, string>> {
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 `<!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'] ?? '')
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<http.Server> {
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) })
}