Files
prov/server.js
T
kerimatilganandClaude Opus 4.7 4303b00b9b Major refactor: auth, bug fixes, security hardening, modular routes
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>
2026-05-14 21:43:19 +02:00

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}`);
});