aa95ab2745
Step 5, the last one, of docs/calendar-auth-migration.md. Step 4 is deployed and verified, which is what this was waiting on: it removes the fallbacks that step 4 still leaned on. Gone: src/models/calendar/users/ entirely - registration, login, activation, both password-reset routes, and the session checking that the feedback and tickets admin areas used to authenticate against - along with its mount. That was the API's last unauthenticated account-creation and mail-sending endpoint. A survey confirmed nothing outside that directory imported it and nothing else touched its tables. Also gone: the two joins against the calendar users table in events.service.ts and the created_by_id / version_created_by_id columns they read, from the SQL, the row mapper, the Event interface and the swagger schema; and X-Session-Id / X-Session-Key from the CORS allowedHeaders, which nothing has read since the first cutover and nothing has sent since the second. An event's author still renders, because migration 002 snapshotted the names before this could erase them. That was brought forward from this step on purpose, and it is the reason 004 can rename the accounts aside at all. The accounts are renamed rather than dropped - they still hold e-mail addresses and password hashes, and a rename makes them unreachable without destroying anything. InnoDB rewires the sessions foreign key to the new name; verified on MariaDB 11, along with the whole 001-004 chain from the pre-cutover production schema, which lands byte-identical to a fresh dev database. Migration 004 must be applied AFTER deploying, not before - the reverse of step 4, whose migration only added things. Its own header and the runbook both say so, since getting it wrong by analogy is the obvious mistake. DEFERRED_SECURITY.md items 3 and 4 close with it: the activation and reset tokens that never expired are gone along with the code that issued them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
6.5 KiB
TypeScript
170 lines
6.5 KiB
TypeScript
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://<private-LAN-IPv4>:<port> - 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;
|
|
};
|