import express from "express"; import http from "node:http"; import path from "node:path"; import { fileURLToPath } from "node:url"; import fs from "node:fs"; import cors from "cors"; import helmet from "helmet"; import rateLimit from "express-rate-limit"; import cookieParser from "cookie-parser"; import { Server as SocketIOServer } from "socket.io"; import { createExpressMiddleware } from "@trpc/server/adapters/express"; import { appRouter } from "../routers.js"; import { createContext } from "./context.js"; import { authMiddleware, assertAuthEnv } from "../auth.js"; import { getDb, pingDb } from "../db.js"; import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js"; import { handleWebhook as handleStripeWebhook, isStripeConfigured, verifyAndConstructEvent, } from "../services/stripe.js"; import { getActiveWhatsAppSessionsCount } from "../services/whatsapp.js"; import { logger, childLogger } from "./logger.js"; import { requestLogger } from "./requestLogger.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, "..", ".."); const PORT = Number(process.env.PORT ?? 5000); const NODE_ENV = process.env.NODE_ENV ?? "development"; const IS_PROD = NODE_ENV === "production"; const BACKUP_DIR = process.env.BACKUP_DIR ?? "/app/data/backups"; const PROD_ORIGINS = ["https://attente.cosmolan.fr"]; const DEV_ORIGINS = [ "http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:5000", "http://127.0.0.1:5000", ]; const ALLOWED_ORIGINS = IS_PROD ? PROD_ORIGINS : DEV_ORIGINS; const serverLog = childLogger("server"); const dbLog = childLogger("db"); const stripeLog = childLogger("stripe"); const trpcLog = childLogger("trpc"); function readPackageVersion(): string { try { const pkgPath = path.resolve(ROOT, "package.json"); const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { version?: string }; return pkg.version ?? "0.0.0"; } catch { return "0.0.0"; } } const APP_VERSION = readPackageVersion(); function getLastBackupTimestamp(): string | null { try { if (!fs.existsSync(BACKUP_DIR)) return null; const files = fs.readdirSync(BACKUP_DIR); let mostRecent = 0; for (const file of files) { const stat = fs.statSync(path.join(BACKUP_DIR, file)); if (stat.isFile() && stat.mtimeMs > mostRecent) { mostRecent = stat.mtimeMs; } } return mostRecent > 0 ? new Date(mostRecent).toISOString() : null; } catch { return null; } } async function bootstrap() { // Fail fast if critical secrets are missing — refuse to start instead of // erroring lazily on the first authenticated request. assertAuthEnv(); // Eagerly initialize database connection (warns early if DATABASE_URL missing) try { await getDb(); dbLog.info("connected"); } catch (err) { dbLog.error({ err }, "connection failed"); } const app = express(); // Required for express-rate-limit and secure cookies behind a reverse proxy app.set("trust proxy", 1); const httpServer = http.createServer(app); // ── Socket.io ──────────────────────────────────────────────────────────── const io = new SocketIOServer(httpServer, { cors: { origin: ALLOWED_ORIGINS, credentials: true, }, path: "/socket.io", }); (globalThis as unknown as { __socketIo: SocketIOServer }).__socketIo = io; io.on("connection", (socket) => { socket.on("clinic:subscribe", (clinicId: number) => { if (typeof clinicId === "number") socket.join(`clinic:${clinicId}`); }); socket.on("display:subscribe", (clinicId: number) => { if (typeof clinicId === "number") socket.join(`display:${clinicId}`); }); socket.on("patient:subscribe", (patientToken: string) => { if (typeof patientToken === "string" && patientToken.length > 0) { socket.join(`patient:${patientToken}`); } }); socket.on("clinic:unsubscribe", (clinicId: number) => { if (typeof clinicId === "number") socket.leave(`clinic:${clinicId}`); }); socket.on("display:unsubscribe", (clinicId: number) => { if (typeof clinicId === "number") socket.leave(`display:${clinicId}`); }); socket.on("patient:unsubscribe", (patientToken: string) => { if (typeof patientToken === "string") socket.leave(`patient:${patientToken}`); }); }); // ── Request logging (must come early to capture every API request) ─────── app.use(requestLogger); // ── Security headers ───────────────────────────────────────────────────── // CSP is intentionally disabled here: it conflicts with the Vite dev server // and the inline assets generated by the SPA build. Re-enable once a proper // policy has been defined for the production bundle. app.use( helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false, }) ); // ── CORS ───────────────────────────────────────────────────────────────── app.use( cors({ origin: (origin, callback) => { // Allow same-origin / non-browser requests (no Origin header) if (!origin) return callback(null, true); if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true); return callback(new Error(`Origin not allowed by CORS: ${origin}`)); }, credentials: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"], }) ); // ── Rate limiting ──────────────────────────────────────────────────────── // Global limiter applied to the whole API surface. const globalLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 100, standardHeaders: true, legacyHeaders: false, message: { error: "Too many requests, please try again later." }, }); // Stricter limiter for authentication endpoints (login + register). const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: true, message: { error: "Too many auth attempts, please try again later." }, }); // Password reset limiter — kept ready in case a reset endpoint is added. const passwordResetLimiter = rateLimit({ windowMs: 60 * 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, message: { error: "Too many password reset attempts, please try again later." }, }); // Apply auth limiter BEFORE the tRPC middleware so it short-circuits abuse. app.use( [ "/api/trpc/auth.login", "/api/trpc/auth.register", ], authLimiter ); app.use( [ "/api/trpc/auth.requestPasswordReset", "/api/trpc/auth.resetPassword", "/api/trpc/auth.forgotPassword", ], passwordResetLimiter ); app.use("/api", globalLimiter); // ── Stripe webhook (RAW body, must come BEFORE express.json) ───────────── app.post( "/api/stripe/webhook", express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => { // If Stripe is not configured, silently acknowledge so the route never 500s. if (!(await isStripeConfigured())) { res.status(200).json({ received: true, configured: false }); return; } const signature = req.headers["stripe-signature"]; if (typeof signature !== "string") { res.status(400).json({ error: "Missing stripe-signature header" }); return; } try { const event = await verifyAndConstructEvent(req.body as Buffer, signature); await handleStripeWebhook(event); res.status(200).json({ received: true }); } catch (err) { stripeLog.error({ err }, "webhook error"); const message = err instanceof Error ? err.message : "Webhook error"; res.status(400).json({ error: message }); } } ); // ── Body / cookies / auth ──────────────────────────────────────────────── app.use(express.json({ limit: "1mb" })); app.use(cookieParser()); app.use(authMiddleware); // ── Health / readiness / liveness probes ───────────────────────────────── app.get("/api/health", async (_req, res) => { const dbStatus = await pingDb(); const lastBackup = getLastBackupTimestamp(); res.json({ status: dbStatus.ok ? "ok" : "degraded", env: NODE_ENV, version: APP_VERSION, uptime: process.uptime(), ts: Date.now(), database: dbStatus.ok ? { status: "connected", latencyMs: dbStatus.latencyMs } : { status: "error", error: dbStatus.error }, whatsapp: { activeSessions: getActiveWhatsAppSessionsCount(), }, lastBackup, }); }); // k8s-style probes app.get("/api/live", (_req, res) => { res.json({ status: "ok", ts: Date.now() }); }); app.get("/api/ready", async (_req, res) => { const dbStatus = await pingDb(); if (!dbStatus.ok) { res.status(503).json({ status: "not_ready", database: "error", error: dbStatus.error }); return; } res.json({ status: "ready", database: "connected", latencyMs: dbStatus.latencyMs }); }); // ── tRPC ───────────────────────────────────────────────────────────────── app.use( "/api/trpc", createExpressMiddleware({ router: appRouter, createContext, onError({ error, path }) { if (error.code === "INTERNAL_SERVER_ERROR") { trpcLog.error({ err: error, path }, "internal server error"); } else { trpcLog.debug({ code: error.code, path, message: error.message }, "trpc error"); } }, }) ); // ── Static client (production) ─────────────────────────────────────────── if (IS_PROD) { const clientDist = path.resolve(ROOT, "dist", "client"); const indexHtml = path.resolve(clientDist, "index.html"); if (fs.existsSync(clientDist)) { app.use(express.static(clientDist, { maxAge: "1h", index: false })); app.get("*", (req, res, next) => { if (req.path.startsWith("/api") || req.path.startsWith("/socket.io")) return next(); if (!fs.existsSync(indexHtml)) return next(); res.sendFile(indexHtml); }); } else { serverLog.warn({ clientDist }, "static dist/client not found"); } } // ── Error handler ──────────────────────────────────────────────────────── app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { serverLog.error({ err }, "express error"); res.status(500).json({ error: "Internal Server Error" }); }); httpServer.listen(PORT, () => { serverLog.info({ port: PORT, env: NODE_ENV, version: APP_VERSION }, "server listening"); // Démarre le job qui marque les patients absents après N minutes sans réponse startAutoAbsentJob(); }); const shutdown = (signal: string) => { serverLog.info({ signal }, "received shutdown signal"); stopAutoAbsentJob(); io.close(); httpServer.close(() => process.exit(0)); setTimeout(() => process.exit(1), 10_000).unref(); }; process.on("SIGINT", () => shutdown("SIGINT")); process.on("SIGTERM", () => shutdown("SIGTERM")); } bootstrap().catch((err) => { logger.fatal({ err }, "server failed to start"); process.exit(1); });