2026-05-03 19:51:57 -05:00
|
|
|
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');
|
2026-05-09 13:03:36 -05:00
|
|
|
const { errorFormatter } = require('./middleware/errorFormatter');
|
|
|
|
|
const { importLimiter, exportLimiter, adminActionLimiter, oidcLimiter, loginLimiter, passwordLimiter, backupOperationLimiter } =
|
2026-05-03 19:51:57 -05:00
|
|
|
require('./middleware/rateLimiter');
|
2026-05-09 13:03:36 -05:00
|
|
|
const { csrfMiddleware, csrfTokenProvider } = require('./middleware/csrf');
|
2026-05-03 19:51:57 -05:00
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
const authLoginRouter = require('./routes/authLogin');
|
2026-05-03 19:51:57 -05:00
|
|
|
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());
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
// ── CSRF token provider - sets CSRF cookie on every response ────────────────
|
|
|
|
|
// This ensures the CSRF token cookie is always present for API clients
|
|
|
|
|
app.use(csrfTokenProvider);
|
|
|
|
|
|
2026-05-03 19:51:57 -05:00
|
|
|
// ── API ───────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
// 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);
|
|
|
|
|
// 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'));
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// OIDC — rate-limited; returns 501 gracefully when OIDC is not configured
|
2026-05-09 13:03:36 -05:00
|
|
|
app.use('/api/auth/oidc', csrfMiddleware, oidcLimiter, require('./routes/authOidc'));
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// Admin — all routes already require auth+admin; mutation-heavy routes get
|
|
|
|
|
// an additional per-IP rate limit applied to the whole admin namespace
|
2026-05-09 13:03:36 -05:00
|
|
|
// 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
|
2026-05-09 16:25:12 -05:00
|
|
|
app.use('/api/about-admin', adminActionLimiter, csrfMiddleware, requireAuth, requireAdmin, require('./routes/aboutAdmin')); // admin-only
|
2026-05-09 13:03:36 -05:00
|
|
|
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'));
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// Export / Import — per-IP rate limited to deter abuse and resource exhaustion
|
2026-05-09 13:03:36 -05:00
|
|
|
app.use('/api/export', csrfMiddleware, requireAuth, requireUser, exportLimiter, require('./routes/export'));
|
|
|
|
|
app.use('/api/import', csrfMiddleware, requireAuth, requireUser, importLimiter, require('./routes/import'));
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// ── Legacy UI ("Remember When" mode) ─────────────────────────────────────────
|
|
|
|
|
app.use('/legacy', express.static(path.join(__dirname, 'legacy')));
|
|
|
|
|
|
|
|
|
|
// ── Modern UI (Vite build) ────────────────────────────────────────────────────
|
2026-05-03 22:33:21 -05:00
|
|
|
app.get('/login.html', (req, res) => res.redirect(302, '/login'));
|
2026-05-03 19:51:57 -05:00
|
|
|
app.use(express.static(DIST));
|
2026-05-09 13:03:36 -05:00
|
|
|
// 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);
|
2026-05-03 19:51:57 -05:00
|
|
|
|
|
|
|
|
// ── 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({
|
2026-05-09 13:03:36 -05:00
|
|
|
error: 'Internal server error',
|
2026-05-03 19:51:57 -05:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Bootstrap ─────────────────────────────────────────────────────────────────
|
|
|
|
|
async function main() {
|
|
|
|
|
const db = getDb();
|
|
|
|
|
const userCount = db.prepare('SELECT COUNT(*) AS count FROM users').get().count;
|
|
|
|
|
if (userCount === 0) await require('./setup/firstRun').run(db);
|
|
|
|
|
|
2026-05-09 16:38:28 -05:00
|
|
|
// [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;
|
|
|
|
|
|
|
|
|
|
// Check if regular user already exists
|
|
|
|
|
const existingRegular = db.prepare('SELECT id FROM users WHERE role = ? AND username = ?').get('user', regularUser);
|
|
|
|
|
|
|
|
|
|
if (!existingRegular) {
|
|
|
|
|
// Create new regular user
|
|
|
|
|
const bcrypt = require('bcryptjs');
|
|
|
|
|
const regularHash = await bcrypt.hash(regularPass, 12);
|
|
|
|
|
db.prepare(`
|
|
|
|
|
INSERT INTO users (username, password_hash, role, first_login, must_change_password, is_default_admin)
|
|
|
|
|
VALUES (?, ?, ?, 0, 0, 0)
|
|
|
|
|
`).run(regularUser, regularHash, 'user');
|
|
|
|
|
console.log(`[seed] Regular user "${regularUser}" created.`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
app.listen(PORT, () => {
|
|
|
|
|
console.log(`Bill Tracker running on port ${PORT}`);
|
|
|
|
|
if (userCount > 0) console.log(`Users found: ${userCount}`);
|
|
|
|
|
});
|
2026-05-03 19:51:57 -05:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 13:03:36 -05:00
|
|
|
main().catch(err => {
|
|
|
|
|
console.error('Failed to start server:', err);
|
|
|
|
|
process.exit(1);
|
|
|
|
|
});
|