Files
prov/server.js
T
kerimatilganandClaude Opus 4.7 ebffd8a3ca Fix login loop behind reverse proxy: trust X-Forwarded-Proto
In production we run behind nginx that terminates HTTPS and proxies
HTTP to node:3000. Without app.set('trust proxy', 1) Express sees
the connection as plaintext, so express-session refuses to set the
secure cookie. Login appeared to succeed but no cookie was issued,
causing an immediate redirect back to /login on the next request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:45:26 +02:00

177 lines
6.4 KiB
JavaScript

// Load .env.local (gitignored) before anything else reads process.env.
require('dotenv').config({ path: require('path').join(__dirname, '.env.local') });
const express = require('express');
const path = require('path');
const cors = require('cors');
const fs = require('fs');
const helmet = require('helmet');
const session = require('express-session');
const SQLiteStore = require('connect-sqlite3')(session);
const rateLimit = require('express-rate-limit');
const multer = require('multer');
const { initDatabase, closeDatabase } = require('./database');
const { requireLogin } = require('./middleware/auth');
const app = express();
const PORT = process.env.PORT || 3000;
const isProd = process.env.NODE_ENV === 'production';
// We sit behind nginx/PM2 in production; trust X-Forwarded-* headers so that
// req.secure reflects the actual client connection (otherwise secure cookies
// would never be set and rate-limiting would treat every client as the same IP).
if (isProd) {
app.set('trust proxy', 1);
}
initDatabase();
// Graceful shutdown
function gracefulShutdown(signal) {
console.log(`\n${signal} received, closing database connection...`);
closeDatabase();
process.exit(0);
}
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Uploads directory
const uploadsDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
// Resolve a stored relative filepath safely under uploadsDir, or null if it escapes.
function safeUploadPath(storedPath) {
const resolved = path.resolve(__dirname, storedPath);
const uploadsResolved = path.resolve(uploadsDir);
if (resolved !== uploadsResolved && !resolved.startsWith(uploadsResolved + path.sep)) {
return null;
}
return resolved;
}
app.locals.safeUploadPath = safeUploadPath;
// --- Security middleware ---
app.use(helmet({
contentSecurityPolicy: false, // existing inline styles/scripts in index.html; enable once frontend is cleaned up
}));
// CORS: only allow configured origins. Default = same-origin only (no CORS headers).
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);
app.use(cors({
origin: (origin, cb) => {
if (!origin) return cb(null, true); // same-origin / curl
if (allowedOrigins.includes(origin)) return cb(null, true);
return cb(new Error('CORS: Origin nicht erlaubt'));
},
credentials: true,
}));
app.use(express.json({ limit: '1mb' }));
// API rate limit (login has its own stricter limiter in routes/auth.js)
app.use('/api/', rateLimit({
windowMs: 60 * 1000,
max: 300,
standardHeaders: true,
legacyHeaders: false,
}));
// Sessions, stored in SQLite next to provision.db
app.use(session({
store: new SQLiteStore({ db: 'sessions.db', dir: __dirname }),
secret: process.env.SESSION_SECRET || 'change-me-in-production',
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: 'lax',
secure: isProd,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
}));
if (!process.env.SESSION_SECRET) {
console.warn('WARNING: SESSION_SECRET not set; using insecure default. Set it before deploying.');
}
// --- Public routes (login page + auth endpoints) ---
const authRouter = require('./routes/auth');
app.use('/api/auth', authRouter);
// Serve login page without auth
app.get(['/login', '/login.html'], (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.html'));
});
app.get('/login.js', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'login.js'));
});
// --- Auth gate for all /api/* except /api/auth/* ---
app.use('/api', (req, res, next) => {
if (req.path.startsWith('/auth/')) return next();
return requireLogin(req, res, next);
});
// --- Protected API routes ---
const anbieterRouter = require('./routes/anbieter');
const tarifeRouter = require('./routes/tarife');
const schaltungenRouter = require('./routes/schaltungen');
const tarifoptionenRouter = require('./routes/tarifoptionen');
const dokumenteRouter = require('./routes/dokumente');
const provisionenRouter = require('./routes/provisionen');
const provisionsregelnRouter = require('./routes/provisionsregeln');
const mitarbeiterRouter = require('./routes/mitarbeiter');
app.use('/api/anbieter', anbieterRouter);
app.use('/api/tarife', tarifeRouter);
app.use('/api/schaltungen', schaltungenRouter);
app.use('/api/tarifoptionen', tarifoptionenRouter);
app.use('/api/mitarbeiter-prozentsaetze', mitarbeiterRouter);
app.use('/api', dokumenteRouter); // /api/schaltungen/:id/dokumente, /api/dokumente/:id/*
app.use('/api', provisionenRouter); // /api/provisionen, /api/schaltungen/:id/provision, /api/geloeschte-schaltungen
app.use('/api', provisionsregelnRouter); // /api/provisionsregeln, /api/anbieter-provisionsregeln
// Singular tariff endpoint (kept for frontend backward compat)
app.get('/api/tarif/:id', requireLogin, async (req, res, next) => {
const db = require('./db');
try {
const row = await db.get('SELECT * FROM tarife WHERE id = ?', [req.params.id]);
if (!row) return res.status(404).json({ error: 'Tarif nicht gefunden' });
res.json(row);
} catch (err) { next(err); }
});
// --- Static files (after auth gate so /api routes don't accidentally serve assets) ---
// Frontend gates itself via /api/auth/me; static files (login page, JS, CSS) are public.
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// --- Error handler (must be last) ---
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Datei zu groß. Maximum: 10MB' });
}
return res.status(400).json({ error: err.message });
}
if (err && err.status === 404) {
return res.status(404).json({ error: err.message });
}
if (err && /CORS/.test(err.message)) {
return res.status(403).json({ error: err.message });
}
console.error('Unhandled error:', err);
res.status(err && err.status ? err.status : 500).json({ error: err && err.message ? err.message : 'Interner Server-Fehler' });
});
app.listen(PORT, () => {
console.log(`Server läuft auf http://localhost:${PORT}`);
});