import express, { type Express } from "express"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import pinoHttp from "pino-http"; import session from "express-session"; import ConnectPgSimple from "connect-pg-simple"; import router from "./routes"; import { logger } from "./lib/logger"; import "./types/session.d.ts"; const PgStore = ConnectPgSimple(session); const isProd = process.env.NODE_ENV === "production"; if (isProd && !process.env.SESSION_SECRET) { throw new Error("SESSION_SECRET must be set in production"); } if (isProd && !process.env.VOTER_SECRET) { throw new Error("VOTER_SECRET must be set in production"); } const app: Express = express(); app.set("trust proxy", 1); app.use( pinoHttp({ logger, serializers: { req(req) { return { id: req.id, method: req.method, url: req.url?.split("?")[0], }; }, res(res) { return { statusCode: res.statusCode, }; }, }, }), ); const allowedOrigins = (process.env.CORS_ORIGIN ?? "") .split(",") .map((s) => s.trim()) .filter(Boolean); app.use((req, res, next) => { const origin = req.headers.origin; const host = req.headers.host; let sameOrigin = false; if (origin && host) { try { sameOrigin = new URL(origin).host === host; } catch { sameOrigin = false; } } const allow = !origin || sameOrigin || allowedOrigins.includes(origin); if (allow) { if (origin) res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Vary", "Origin"); res.setHeader("Access-Control-Allow-Credentials", "true"); res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); if (req.method === "OPTIONS") { res.sendStatus(204); return; } } next(); }); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use( session({ store: new PgStore({ conString: process.env.DATABASE_URL, tableName: "sessions", createTableIfMissing: true, }), secret: process.env.SESSION_SECRET || "dev-secret-change-in-production", resave: false, saveUninitialized: false, cookie: { secure: isProd, httpOnly: true, maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days sameSite: "lax", }, }), ); app.use("/api", router); // Any unmatched /api route should return a JSON 404 instead of falling // through to the SPA catch-all below. app.use("/api", (_req, res) => { res.status(404).json({ error: "Not found" }); }); // Central JSON error handler (Express 5 forwards rejected async handlers here). app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { const statusCode = (err as { status?: unknown })?.status ?? (err as { statusCode?: unknown })?.statusCode; const status = typeof statusCode === "number" && statusCode >= 400 && statusCode < 600 ? statusCode : 500; if (isProd) { logger.error({ err: (err as Error)?.message }, "Unhandled error"); } else { logger.error({ err }, "Unhandled error"); } res.status(status).json({ error: isProd ? "Internal server error" : (err as Error)?.message ?? "Internal server error" }); }); const staticDir = process.env.STATIC_DIR; if (staticDir && existsSync(staticDir)) { app.use(express.static(staticDir)); app.get("/*any", (_req, res) => { res.sendFile(resolve(staticDir, "index.html")); }); logger.info({ staticDir }, "Serving static files"); } export default app;