import express from 'express'; import * as dotenv from 'dotenv'; import swaggerUi from 'swagger-ui-express'; import swaggerJSDoc from 'swagger-jsdoc'; import cors from 'cors'; import {toNodeHandler} from 'better-auth/node'; import logger from './middleware/logger.js'; // Router imports import {calendarRouter} from './models/calendar/Calendar.router.js'; import {feedbackRouter} from './models/feedback/Feedback.router.js'; import {ticketsRouter} from './models/tickets/Tickets.router.js'; import {adminRouter} from './models/admin/Admin.router.js'; import {auth} from './models/admin/admin.auth.js'; import {ADMIN_ALLOWED_ORIGINS, isProd} from './models/admin/admin.config.js'; dotenv.config(); /** * Builds the Express app with every router and middleware in place. * * Separate from app.ts so the integration tests can drive the *real* wiring * with supertest instead of a hand-rolled copy of it. The order below is not * cosmetic - CORS has to precede the better-auth handler so preflights get * their headers, and the better-auth handler has to precede express.json() * because it reads the raw body stream itself. */ export const createApp = (): express.Application => { const app: express.Application = express(); // Behind Plesk's nginx, req.ip is the proxy unless we trust the forwarded header. // Verify the resolved client IP is correct in staging before relying on it // (used by the feedback rate limiter). app.set('trust proxy', 1); // Configure CORS. This has to run before the better-auth handler below, so // that preflights for /admin/auth/* get their headers, which is why it now // sits above express.json() instead of after it. let allowedHosts = [ 'https://www.nachklang.art', 'https://calendar.nachklang.art', 'https://feedback.nachklang.art', 'https://tickets.nachklang.art', 'https://admin.nachklang.art', // The admin app's origin comes from ADMIN_APP_URL, so a rename or a // staging host does not need a code change here. ...ADMIN_ALLOWED_ORIGINS ]; // `isProd` from admin.config, NOT `NODE_ENV !== 'production'`. The two are not // the same when NODE_ENV is unset, which is exactly what a fresh Plesk vhost // gives you: the old test called that "dev" and opened the loopback and // private-LAN exceptions below. With `credentials: true` on this CORS config // and a session cookie scoped to .nachklang.art, that let any page served // from localhost read a signed-in admin's data cross-origin. admin.config // treats anything but an explicit 'development'/'test' as production, so an // unset value now fails closed. const isDev = !isProd; const localhostRegex = /^http:\/\/localhost:\d+$/; // Matches http://: - needed so the feedback form can // be reached from a real phone over WiFi during dev (the phone's Origin is // the dev machine's LAN IP, never "localhost"). Dev-only, same as above. const lanIpRegex = /^http:\/\/(192\.168\.\d{1,3}\.\d{1,3}|10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}):\d+$/; app.use(cors({ // Content-Type alone. X-Session-Id / X-Session-Key were allowed here // through the two cutovers so that a browser still holding a pre-cutover // bundle got a clean 401 rather than a confusing CORS preflight failure. // Nothing has read them since the first cutover and nothing has sent them // since the second, so they came out with the rest of the legacy path. allowedHeaders: ['Content-Type'], // The admin session lives in a cookie, so browsers must be allowed to send // it cross-origin - this is what makes credentials: 'include' work. credentials: true, origin: function (origin: any, callback: any) { // Allow requests with no origin if (!origin) return callback(null, true); // Any localhost port, or a private-LAN IP, is fine outside production - // dev servers pick whatever port is free (Next.js falls back from 3000 // if it's taken), and real-device testing hits the dev machine by IP. if (isDev && (localhostRegex.test(origin) || lanIpRegex.test(origin))) { return callback(null, true); } // Block requests with wrong origin if (allowedHosts.indexOf(origin) === -1) { return callback(new Error('The CORS policy doesn\'t allow access for your origin.'), false); } // Allow all other requests return callback(null, true); } })); // better-auth's own handler, mounted before express.json(): it reads the raw // request body stream itself and a parsed body would leave it hanging. // // Wrapped, because Express 4 does not await an async handler: a rejected // promise escapes as an unhandled rejection instead of becoming a response. // Nearly every better-auth route touches the admin database, so a database // blip would leave the request hanging with no answer at all while the // process logged an uncaughtException - observed by pointing ADMIN_DB at a // database the user cannot open. Answer 503 instead: the caller learns, and // the other domains keep serving. const authHandler = toNodeHandler(auth); app.all('/admin/auth/*', (req, res) => { Promise.resolve(authHandler(req, res)).catch((e: any) => { logger.error('Admin auth handler failed', {path: req.path, detail: e?.message}); if (!res.headersSent) { res.status(503).send({ status: 'SERVICE_UNAVAILABLE', message: 'Die Anmeldung ist derzeit nicht verfügbar. Bitte versuche es später erneut.' }); } }); }); // here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // Swagger documentation const swaggerDefinition = { openapi: '3.0.0', info: { title: 'Nachklang e.V. REST API', version: '1.0.0', license: { name: 'Licensed Under MIT', url: 'https://spdx.org/licenses/MIT.html' }, contact: { name: 'Nachklang e.V.', url: 'https://www.nachklang.art' } } }; const options = { swaggerDefinition, // Paths to files containing OpenAPI definitions apis: [ './src/models/**/*.interface.ts', './src/models/**/*.router.ts' ] }; const swaggerSpec = swaggerJSDoc(options); app.use( '/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec) ); // Add routers app.use('/calendar', calendarRouter); app.use('/feedback', feedbackRouter); app.use('/tickets', ticketsRouter); // JSON routes only; the auth handler above is mounted separately. app.use('/admin', adminRouter); // this is a simple route to make sure everything is working properly app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send('Welcome to the Nachklang e.V. REST API!'); }); return app; };