Cookie was unconditionally Secure in production, which made the browser drop it whenever the app was accessed over plain HTTP (e.g. http://host:3000) — login appeared to succeed but the cookie never came back. 'auto' delegates the decision to express-session based on req.secure, so the same build works for both direct HTTP access and HTTPS-via-proxy setups. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6.6 KiB
JavaScript
181 lines
6.6 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',
|
|
// 'auto' = secure cookie when req.secure is true (HTTPS or trust-proxy
|
|
// detected HTTPS), plaintext cookie when accessed over HTTP. Lets the
|
|
// same build serve both http://host:3000 and https://host/ without a
|
|
// login loop.
|
|
secure: 'auto',
|
|
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}`);
|
|
});
|