feat: WM2026 Tippspiel - Initial Backend + Frontend

This commit is contained in:
Ronny Müller
2026-04-03 21:41:19 +02:00
commit 1c685b90a0
2507 changed files with 997210 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
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 { checkDbConnection } from './db/client';
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';
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,
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'],
},
},
})
);
// 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) => {
// 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' },
})
);
// ============================================================
// Health Check (ohne Auth für Railway/Render/Uptime-Monitoring)
// ============================================================
app.get('/health', async (_req, res) => {
const dbOk = await checkDbConnection();
res.status(dbOk ? 200 : 503).json({
status: dbOk ? 'ok' : 'degraded',
db: dbOk ? 'connected' : 'unreachable',
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);
// ============================================================
// Frontend (React Build) statisches Serving
// In Production liefert das Backend auch das Frontend aus
// ============================================================
if (process.env.NODE_ENV === 'production') {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path') as typeof import('path');
app.use(express.static(path.join(process.cwd(), 'public')));
app.get('*', (_req, res) => {
res.sendFile(path.join(process.cwd(), 'public', '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;