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 } from "../db.js"; import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.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 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; 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(); // eslint-disable-next-line no-console console.log("[db] connected"); } catch (err) { // eslint-disable-next-line no-console console.error("[db] connection failed:", err); } 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}`); }); }); // ── 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); // ── Body / cookies / auth ──────────────────────────────────────────────── app.use(express.json({ limit: "1mb" })); app.use(cookieParser()); app.use(authMiddleware); // ── Health check ───────────────────────────────────────────────────────── app.get("/api/health", (_req, res) => { res.json({ status: "ok", env: NODE_ENV, ts: Date.now() }); }); // ── tRPC ───────────────────────────────────────────────────────────────── app.use( "/api/trpc", createExpressMiddleware({ router: appRouter, createContext, onError({ error, path }) { if (error.code === "INTERNAL_SERVER_ERROR") { // eslint-disable-next-line no-console console.error(`[trpc] ${path}:`, 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 { // eslint-disable-next-line no-console console.warn(`[static] dist/client not found at ${clientDist}`); } } // ── Error handler ──────────────────────────────────────────────────────── app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => { // eslint-disable-next-line no-console console.error("[express] error:", err); res.status(500).json({ error: "Internal Server Error" }); }); httpServer.listen(PORT, () => { // eslint-disable-next-line no-console console.log(`[server] listening on http://0.0.0.0:${PORT} (${NODE_ENV})`); // Démarre le job qui marque les patients absents après N minutes sans réponse startAutoAbsentJob(); }); const shutdown = (signal: string) => { // eslint-disable-next-line no-console console.log(`[server] received ${signal}, shutting down`); 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) => { // eslint-disable-next-line no-console console.error("[server] failed to start:", err); process.exit(1); });