e86ae62309
Build & Deploy Tippspiel / build (push) Successful in 30s
Verhindert unnötige SELECT 1 Abfragen gegen Supabase alle 30s. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
198 lines
6.4 KiB
TypeScript
198 lines
6.4 KiB
TypeScript
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;
|