This repository has been archived on 2026-05-06. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tippspiel/backend/src/index.ts
T
Ronny e86ae62309
Build & Deploy Tippspiel / build (push) Successful in 30s
fix: Health-Check vereinfacht – kein DB-Query mehr bei /health
Verhindert unnötige SELECT 1 Abfragen gegen Supabase alle 30s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:45:39 +02:00

198 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;