import 'dotenv/config'; import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import rateLimit from 'express-rate-limit'; import { staffbaseAuth as createStaffbaseAuth } from './middleware/staffbaseAuth'; import { devAuth } from './middleware/devAuth'; import { logger } from './services/logger'; import matchesRouter from './routes/matches'; import tipsRouter from './routes/tips'; import leaderboardRouter from './routes/leaderboard'; import adminRouter from './routes/admin'; import profileRouter from './routes/profile'; import devRouter from './routes/dev'; import agentRouter from './routes/agent'; const app = express(); const PORT = parseInt(process.env.PORT ?? '3001'); // ============================================================ // Security Middleware // ============================================================ app.use( helmet({ // Staffbase lädt das Plugin in einem iFrame // X-Frame-Options muss daher SAMEORIGIN oder ALLOW-FROM erlauben frameguard: false, // HSTS nur aktivieren wenn wir über HTTPS laufen (z.B. hinter einem Reverse Proxy) hsts: process.env.NODE_ENV === 'production' && process.env.PLUGIN_BASE_URL?.startsWith('https') ? { maxAge: 15552000, includeSubDomains: true } : false, contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], styleSrc: ["'self'", "'unsafe-inline'"], // für inline styles im Frontend imgSrc: ["'self'", 'data:', 'https://crests.football-data.org'], frameAncestors: ['https://app.staffbase.com', 'https://*.staffbase.com'], // upgrade-insecure-requests nur wenn HTTPS verfügbar ist, sonst werden JS-Assets geblockt upgradeInsecureRequests: process.env.PLUGIN_BASE_URL?.startsWith('https') ? [] : null, }, }, }) ); // CORS – nur Staffbase Origins erlauben const allowedOrigins = (process.env.CORS_ORIGIN ?? 'https://app.staffbase.com') .split(',') .map((o) => o.trim()); app.use( cors({ origin: (origin, callback) => { // Wildcard: alle Origins erlauben if (allowedOrigins.includes('*')) { return callback(null, true); } // Requests ohne Origin (z.B. direkte API-Calls) in Development erlauben if (!origin && process.env.NODE_ENV !== 'production') { return callback(null, true); } if (!origin || allowedOrigins.some((allowed) => origin.includes(allowed.replace('https://', '')))) { callback(null, true); } else { callback(new Error(`CORS blocked: ${origin}`)); } }, credentials: true, }) ); // Body parsing app.use(express.json({ limit: '10kb' })); // Rate limiting – API global app.use( '/api', rateLimit({ windowMs: 15 * 60 * 1000, // 15 Minuten max: 200, // Max 200 Requests pro IP standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests', retryAfter: '15 minutes' }, }) ); // Tighter rate limit für Tipp-Abgabe app.use( '/api/tips', rateLimit({ windowMs: 60 * 1000, // 1 Minute max: 20, message: { error: 'Too many tip submissions' }, }) ); // Rate limit für Agent-Chat (Claude API Calls sind kostenpflichtig) app.use( '/api/agent', rateLimit({ windowMs: 60 * 1000, // 1 Minute max: 10, // 10 Chat-Nachrichten pro User/Minute message: { error: 'Too many agent requests, bitte kurz warten.' }, }) ); // ============================================================ // Health Check (ohne Auth – für Railway/Render/Uptime-Monitoring) // ============================================================ app.get('/health', (_req, res) => { res.status(200).json({ status: 'ok', timestamp: new Date().toISOString(), version: process.env.npm_package_version ?? '1.0.0', }); }); // ============================================================ // Protected API Routes (alle erfordern Staffbase JWT) // ============================================================ // Auth: In Development ohne JWT über devAuth, sonst Staffbase JWT if (process.env.NODE_ENV === 'development') { app.use('/api', (req, res, next) => { // Wenn JWT vorhanden → Staffbase Auth, sonst Dev-Mode if (req.query.jwt || req.headers.authorization?.startsWith('Bearer ')) { return createStaffbaseAuth()(req, res, next); } return devAuth(req, res, next); }); } else { app.use('/api', createStaffbaseAuth()); } app.use('/api/matches', matchesRouter); app.use('/api/tips', tipsRouter); app.use('/api/leaderboard', leaderboardRouter); app.use('/api/admin', adminRouter); app.use('/api/profile', profileRouter); app.use('/api/agent', agentRouter); if (process.env.NODE_ENV === 'development') { app.use('/api/dev', devRouter); } // ============================================================ // Frontend (React Build) – statisches Serving // Wird in allen Modi aktiviert, wenn ein public-Ordner existiert // (Docker-Container liefern Frontend immer über das Backend aus) // ============================================================ { // eslint-disable-next-line @typescript-eslint/no-var-requires const path = require('path') as typeof import('path'); // eslint-disable-next-line @typescript-eslint/no-var-requires const fs = require('fs') as typeof import('fs'); const publicDir = path.join(process.cwd(), 'public'); if (fs.existsSync(publicDir)) { logger.info('Serving frontend static files from', { publicDir }); app.use(express.static(publicDir)); app.get('*', (_req, res) => { res.sendFile(path.join(publicDir, 'index.html')); }); } } // ============================================================ // Error Handler // ============================================================ app.use( ( err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction ) => { logger.error('Unhandled error', { message: err.message, stack: err.stack, name: err.name, }); res.status(500).json({ error: 'Internal server error', message: process.env.NODE_ENV === 'development' ? err.message : undefined, }); } ); // ============================================================ // Start Server // ============================================================ app.listen(PORT, () => { logger.info(`🚀 WM 2026 Tippspiel Backend running on port ${PORT}`, { env: process.env.NODE_ENV, port: PORT, }); }); export default app;