Bug fixes - Sync database.js schema with actual production DB (tarifoptionen shape was wrong; new installs would have broken). - Fix SQL syntax error `SELECT to.*` (reserved keyword) in single- schaltung provision endpoint, plus the dead fallback hierarchy that selected columns that were never in the query. - Fix PUT /api/tarifoptionen/:id writing to non-existent column provision_betrag (should be provision_euro). - Fix race condition in PUT /api/schaltungen/:id where forEach with async db.run inside a transaction could send duplicate responses. - Delete dokumente rows explicitly before deleting schaltung (existing DBs lack ON DELETE CASCADE on dokumente FK). - Replace hardcoded basisBetrag=100 for percent rules; basis is now configurable per rule via the new basis_euro column. Security - Multi-user authentication: users table, bcryptjs, express-session with SQLite store, login/logout/me/setup/change-password endpoints. - Auth gate on all /api/* except /api/auth/*. - CLI to create users: npm run create-user -- <user> <pass> [--admin]. - helmet for security headers. - CORS restricted to ALLOWED_ORIGINS env (default same-origin). - Rate limits: 300/min global, 20/15min on login. - Path-traversal validation on document download/delete. - XSS hardening in frontend: escapeHtml helper applied to all DB-derived innerHTML interpolations; inline onclick handlers that embedded user-controlled strings replaced with data-* attrs + addEventListener. Architecture - Provision calculation centralised in provisionService.js as the single source of truth (was duplicated and inconsistent). - server.js cut from 2138 to ~140 lines; routes split into per- resource modules under routes/. - Promise wrapper db.js for sqlite3 callbacks; async/await in routes. - middleware/ for auth and async error handling. Tests - 14 unit tests for provisionService covering fallback hierarchy, percent calculation, tarifoptionen, hardware logic, employee commission. All green. Config - dotenv loads .env.local at startup (gitignored). - .gitignore extended for sessions.db and .env*. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
170 lines
6.1 KiB
JavaScript
170 lines
6.1 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';
|
|
|
|
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}`);
|
|
});
|