const express = require('express'); const cookieParser = require('cookie-parser'); const path = require('path'); const { getDb } = require('./db/database'); const { requireAuth, requireUser, requireAdmin } = require('./middleware/requireAuth'); const { recordError } = require('./services/statusRuntime'); const { securityHeaders } = require('./middleware/securityHeaders'); const { errorFormatter } = require('./middleware/errorFormatter'); const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, passwordLimiter, backupOperationLimiter } = require('./middleware/rateLimiter'); const { csrfMiddleware, csrfTokenProvider } = require('./middleware/csrf'); const authLoginRouter = require('./routes/authLogin'); const app = express(); const PORT = process.env.PORT || 3000; const DIST = path.join(__dirname, 'dist'); // ── Security headers (applied to every response) ────────────────────────────── app.use(securityHeaders); // ── CORS — disabled unless CORS_ORIGIN is explicitly configured ─────────────── // The default same-origin deployment (Express serves both API and UI) needs no // CORS headers. Set CORS_ORIGIN=https://your-frontend.example.com only when // the frontend is hosted on a separate origin. if (process.env.CORS_ORIGIN) { const cors = require('cors'); const allowed = process.env.CORS_ORIGIN.split(',').map(s => s.trim()).filter(Boolean); app.use(cors({ credentials: true, origin: allowed })); } app.use(express.json()); app.use(cookieParser()); // ── CSRF token provider - sets CSRF cookie on every response ──────────────── // This ensures the CSRF token cookie is always present for API clients app.use(csrfTokenProvider); // ── API ─────────────────────────────────────────────────────────────────────── // Auth — rate limiters applied at middleware level to prevent bypass // Login endpoint is public entry point, exempt from CSRF (no session to hijack yet) // Note: passwordLimiter is NOT applied here — it's for password change endpoints // Helper: skip rate limiting if no users exist (first-run scenario) function skipRateLimitIfNoUsers(limiter) { return (req, res, next) => { try { const db = getDb(); const userCount = db.prepare('SELECT COUNT(*) AS count FROM users').get().count; if (userCount === 0) { return next(); // first run — no rate limiting } } catch (err) { // DB not ready yet — allow request to proceed console.log('[skipRateLimit] DB not initialized, allowing request'); return next(); } // User exists — apply rate limiter return limiter(req, res, next); }; } // Mount login router with conditional rate limiting // If no users exist, rate limit is bypassed; otherwise it applies app.use('/api/auth/login', skipRateLimitIfNoUsers(loginLimiter), authLoginRouter); // Password change routes are exempt from CSRF - session-based auth is primary protection // Set csrfSkip before the auth routes middleware runs app.use('/api/auth/change-password', (req, res, next) => { req.csrfSkip = true; next(); }); app.use('/api/profile/change-password', (req, res, next) => { req.csrfSkip = true; next(); }); app.use('/api/auth/logout-all', (req, res, next) => { req.csrfSkip = true; next(); }); // All other auth routes require CSRF (loginLimiter applied via /api/auth/login above) // Note: passwordLimiter is applied individually on routes that actually change passwords app.use('/api/auth', csrfMiddleware, require('./routes/auth')); // OIDC — rate-limited; returns 501 gracefully when OIDC is not configured app.use('/api/auth/oidc', csrfMiddleware, oidcLimiter, require('./routes/authOidc')); // Admin — all routes already require auth+admin; mutation-heavy routes get // an additional per-IP rate limit applied to the whole admin namespace // Backup operations have dedicated rate limiting (5 per hour) to prevent resource exhaustion // NOTE: backupOperationLimiter is applied per-route in routes/admin.js to avoid blocking non-backup admin actions app.use('/api/admin', csrfMiddleware, requireAuth, requireAdmin, adminActionLimiter, require('./routes/admin')); app.use('/api/tracker', csrfMiddleware, requireAuth, requireUser, require('./routes/tracker')); app.use('/api/bills', csrfMiddleware, requireAuth, requireUser, require('./routes/bills')); app.use('/api/payments', csrfMiddleware, requireAuth, requireUser, require('./routes/payments')); app.use('/api/categories', csrfMiddleware, requireAuth, requireUser, require('./routes/categories')); app.use('/api/settings', csrfMiddleware, requireAuth, requireUser, require('./routes/settings')); app.use('/api/user', csrfMiddleware, requireAuth, requireUser, require('./routes/user')); app.use('/api/calendar', csrfMiddleware, requireAuth, requireUser, require('./routes/calendar')); app.use('/api/summary', csrfMiddleware, requireAuth, requireUser, require('./routes/summary')); app.use('/api/monthly-starting-amounts', csrfMiddleware, requireAuth, requireUser, require('./routes/monthly-starting-amounts')); app.use('/api/analytics', csrfMiddleware, requireAuth, requireUser, require('./routes/analytics')); app.use('/api/notifications', csrfMiddleware, requireAuth, require('./routes/notifications')); app.use('/api/status', csrfMiddleware, requireAuth, requireAdmin, require('./routes/status')); app.use('/api/about', require('./routes/about')); // public app.use('/api/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin')); // admin-only app.use('/api/version', require('./routes/version')); // public // Profile — password-change rate limit applied at middleware level app.use('/api/profile', csrfMiddleware, requireAuth, requireUser, passwordLimiter, require('./routes/profile')); // Export / Import — per-IP rate limited to deter abuse and resource exhaustion app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export')); app.use('/api/import', csrfMiddleware, requireAuth, requireUser, importLimiter, require('./routes/import')); // ── Legacy UI ("Remember When" mode) ───────────────────────────────────────── app.use('/legacy', express.static(path.join(__dirname, 'legacy'))); // ── Modern UI (Vite build) ──────────────────────────────────────────────────── app.get('/login.html', (req, res) => res.redirect(302, '/login')); app.use(express.static(DIST)); // Ensure CSRF cookie is set for SPA by calling getCsrfToken before sending index.html const { getCsrfToken } = require('./middleware/csrf'); app.get('*', (req, res) => { // Set CSRF cookie if not present (needed for SPA to read token) getCsrfToken(req, res); res.sendFile(path.join(DIST, 'index.html')); }); // ── Global error formatter middleware (runs before error handler) ─────────── // Ensures all error responses follow the standardized format. app.use(errorFormatter); // ── Global error handler ────────────────────────────────────────────────────── // Never expose stack traces, internal paths, or raw error objects in responses. app.use((err, req, res, next) => { recordError('Express', err); console.error('[error]', err.message || String(err)); if (req.path?.startsWith('/api/import/')) { const isBodyError = err.type === 'entity.too.large' || err instanceof SyntaxError; return res.status(err.status || (isBodyError ? 400 : 500)).json({ error: 'Import request failed', message: isBodyError ? 'The import request could not be read. Please retry with a smaller or valid file.' : 'Unexpected import server error. Please try again.', code: isBodyError ? 'IMPORT_REQUEST_ERROR' : 'IMPORT_SERVER_ERROR', }); } res.status(err.status || 500).json({ error: 'Internal server error', }); }); // ── Bootstrap ───────────────────────────────────────────────────────────────── async function main() { const db = getDb(); // Run session cleanup on startup const { cleanupExpiredSessions } = require('./db/database'); try { console.log('[cleanup] Running session cleanup on startup'); cleanupExpiredSessions(); } catch (err) { console.error('[cleanup-error] Failed to run startup session cleanup:', err.message); } const userCount = db.prepare('SELECT COUNT(*) AS count FROM users').get().count; if (userCount === 0) await require('./setup/firstRun').run(db); // [seed] Check for and create regular user if INIT_REGULAR_USER/INIT_REGULAR_PASS are set if (process.env.INIT_REGULAR_USER && process.env.INIT_REGULAR_PASS) { const regularUser = process.env.INIT_REGULAR_USER; const regularPass = process.env.INIT_REGULAR_PASS; // Validate password length if (regularPass && regularPass.length < 8) { console.error('[seed] INIT_REGULAR_PASS must be at least 8 characters'); process.exit(1); } // Wrap user creation in a transaction to prevent race conditions const createRegularUser = db.transaction(() => { const existingRegular = db.prepare('SELECT id FROM users WHERE username = ?').get(regularUser); if (!existingRegular) { const bcrypt = require('bcryptjs'); const regularHash = bcrypt.hashSync(regularPass, 12); db.prepare(` INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin) VALUES (?, ?, 'user', 0, 0, 0) `).run(regularUser, regularHash); console.log(`[seed] Regular user "${regularUser}" created.`); return true; } return false; }); createRegularUser(); } app.listen(PORT, () => { console.log(`Bill Tracker running on port ${PORT}`); if (userCount > 0) console.log(`Users found: ${userCount}`); // Set up periodic session cleanup const { cleanupExpiredSessions } = require('./db/database'); const rawInterval = process.env.SESSION_CLEANUP_INTERVAL_MS; let CLEANUP_INTERVAL_MS = 86400000; // 24 hours default if (rawInterval !== undefined) { const parsed = parseInt(rawInterval, 10); if (!isNaN(parsed) && parsed > 0 && parsed <= 604800000) { // max 7 days CLEANUP_INTERVAL_MS = parsed; } else { console.warn(`[cleanup] Invalid SESSION_CLEANUP_INTERVAL_MS: "${rawInterval}". Using default 24h.`); } } // Run cleanup periodically setInterval(() => { try { console.log('[cleanup] Running periodic session cleanup'); cleanupExpiredSessions(); } catch (err) { console.error('[cleanup-error] Failed to run periodic session cleanup:', err.message); } }, CLEANUP_INTERVAL_MS); console.log(`[cleanup] Scheduled periodic cleanup every ${CLEANUP_INTERVAL_MS}ms`); }); } main().catch(err => { console.error('Failed to start server:', err); process.exit(1); });