← back to NationalPaperHangers
server.js
252 lines
require('dotenv').config();
const express = require('express');
const path = require('path');
const morgan = require('morgan');
const session = require('express-session');
const PgSession = require('connect-pg-simple')(session);
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { pool } = require('./lib/db');
const { attachInstaller } = require('./lib/auth');
const { csrfMiddleware } = require('./lib/csrf');
const publicRoutes = require('./routes/public');
const authRoutes = require('./routes/auth');
const adminRoutes = require('./routes/admin');
const apiRoutes = require('./routes/api');
const claimRoutes = require('./routes/claim');
const webhookRoutes = require('./routes/webhooks');
const unsubscribeRoutes = require('./routes/unsubscribe');
const callInstallerRoutes = require('./routes/call-installer');
const app = express();
const PORT = parseInt(process.env.PORT || '9765', 10);
const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
const IS_PROD = process.env.NODE_ENV === 'production';
const HTTPS_PUBLIC = PUBLIC_URL.startsWith('https://');
// Fail-closed: do not boot prod with the dev secret.
if (IS_PROD && (!process.env.SESSION_SECRET || process.env.SESSION_SECRET === 'dev-secret-change-me')) {
throw new Error('SESSION_SECRET must be set to a strong value in production');
}
// Trust the first proxy hop (nginx → Node) so req.secure + cookie secure flag
// behave correctly when terminated upstream.
app.set('trust proxy', 1);
// View engine
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
// Inline styles are used in EJS templates and email previews; inline
// scripts power the GA snippet and small page-local handlers. Tighten
// to nonces in a follow-up if/when templates are refactored.
styleSrc: ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
fontSrc: ["'self'", 'https://fonts.gstatic.com', 'data:'],
scriptSrc: [
"'self'", "'unsafe-inline'",
'https://www.googletagmanager.com',
// Stripe.js + Elements
'https://js.stripe.com', 'https://q.stripe.com'
],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: [
"'self'",
// GA4 also POSTs to google.com/g/collect — without this whitelist
// ~50% of analytics beacons CSP-fail (caught in post-launch QA).
'https://www.google.com',
'https://www.google-analytics.com',
'https://*.analytics.google.com',
// Stripe API + Elements telemetry
'https://api.stripe.com', 'https://m.stripe.network', 'https://m.stripe.com',
// OpenStreetMap Nominatim — client-side reverse-geocode on /map
// when the user clicks "📍 Use my location"
'https://nominatim.openstreetmap.org'
],
// Stripe iframes (Elements card field, 3DS challenge, hCaptcha for radar)
// + social-video embeds on installer profiles (YouTube nocookie / Instagram / TikTok).
frameSrc: [
"'self'",
'https://js.stripe.com', 'https://hooks.stripe.com',
'https://www.youtube.com', 'https://www.youtube-nocookie.com',
'https://www.instagram.com', 'https://instagram.com',
'https://www.tiktok.com',
// lib/social-embed.js emits player.vimeo.com iframes; without this CSP
// silently strips them. (claude-codex r3 finding HIGH#2)
'https://player.vimeo.com',
// LinkedIn embeds for /watch industry posts.
'https://www.linkedin.com'
],
frameAncestors: ["'none'"],
formAction: ["'self'"],
baseUri: ["'self'"]
}
},
hsts: HTTPS_PUBLIC ? { maxAge: 63072000, includeSubDomains: true, preload: true } : false,
referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
crossOriginEmbedderPolicy: false
}));
// Morgan with sensitive-token redaction. The claim-verify and unsubscribe
// flows put short-lived secrets in URL query params (?token=… and ?t=…);
// logging them in production is a credential leak via log retention.
morgan.token('safe-url', (req) => {
const u = req.originalUrl || req.url || '';
return u.replace(/([?&](?:token|t|sig|key)=)[^&#]+/gi, '$1[REDACTED]');
});
const morganFormat = IS_PROD
? ':remote-addr - :remote-user [:date[clf]] ":method :safe-url HTTP/:http-version" :status :res[content-length] ":referrer" ":user-agent"'
: ':method :safe-url :status :response-time ms - :res[content-length]';
app.use(morgan(morganFormat));
// Stripe webhook needs raw body — mount BEFORE json parser AND before CSRF.
app.use('/webhooks/stripe', express.raw({ type: 'application/json' }), webhookRoutes);
app.use(express.json({ limit: '512kb' }));
app.use(express.urlencoded({ extended: true, limit: '512kb' }));
app.use(express.static(path.join(__dirname, 'public'), { maxAge: '1h' }));
app.use(session({
store: new PgSession({ pool, tableName: 'session', createTableIfMissing: true }),
secret: process.env.SESSION_SECRET || 'dev-secret-change-me',
resave: false,
saveUninitialized: false,
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 30,
httpOnly: true,
sameSite: 'lax',
// Force secure cookies whenever PUBLIC_URL is https, regardless of NODE_ENV.
secure: HTTPS_PUBLIC || IS_PROD
},
name: 'nph.sid'
}));
// Locals every view gets — set BEFORE CSRF so the CSRF reject page can
// render error.ejs (which expects `path` to be defined).
const segmentImage = require('./lib/segment-image');
app.use((req, res, next) => {
res.locals.publicUrl = PUBLIC_URL;
res.locals.path = req.path;
res.locals.flash = req.session && req.session.flash || null;
// Stripe.js needs the publishable key on the page. Empty string → book.ejs
// falls through to mocked-redirect mode (no Elements mount).
res.locals.stripePublishableKey = process.env.STRIPE_PUBLISHABLE_KEY || '';
// Public-domain segment image picker — every view can call this on any
// installer row to get a deterministic PD photo or null.
res.locals.pickSegmentImage = segmentImage.pickSegmentImage;
res.locals.imageAttribution = segmentImage.attributionFor;
// Buyer-side Google sign-in identity (populated by /auth/google/callback).
// Views can show "signed in as …" + auto-fill name/email on the booking form.
res.locals.consumer = (req.session && req.session.consumer) || null;
if (req.session) req.session.flash = null;
next();
});
// CSRF runs after session + locals, before routes. The webhook route is
// mounted above and never reaches this middleware.
app.use(csrfMiddleware);
app.use(attachInstaller);
// Rate limiters — applied to specific high-abuse routes.
// Production: 10 logins per 15 min per IP. Non-prod (dev / CI / e2e): 100,
// because the chained `npm run test:e2e` runs several login-requiring suites
// back-to-back (claim-flow, template-chooser, coi-request, paper-comments)
// and would otherwise trip the limiter mid-run. The widening is gated on
// NODE_ENV so prod never sees it.
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: process.env.NODE_ENV === 'production' ? 10 : 100,
standardHeaders: true, legacyHeaders: false,
message: { error: 'too_many_requests' }
});
// Per-route hourly abuse limiters. Each call returns its OWN rate-limit store,
// so the buckets are independent — previously a single `claimLimiter` was
// shared across claim / notify / COI / comments, meaning a designer who fired
// 5 COI requests could no longer claim a profile. Non-prod max is widened to
// 100 so back-to-back e2e suites (many POSTs from one IP) don't trip mid-run;
// gated on NODE_ENV so prod sees the real per-route cap.
const hourLimiter = (prodMax) => rateLimit({
windowMs: 60 * 60 * 1000,
max: process.env.NODE_ENV === 'production' ? prodMax : 100,
standardHeaders: true, legacyHeaders: false,
message: { error: 'too_many_requests' }
});
const claimLimiter = hourLimiter(5); // account-claim — sensitive takeover surface, kept tight
const notifyLimiter = hourLimiter(10); // notify-when-live — low-value visitor signup
const coiLimiter = hourLimiter(20); // COI requests — designers hit several installers per project
const commentLimiter = hourLimiter(20); // paper-thread comments — knowledge contributors post in bursts
const callLimiter = hourLimiter(6); // call-installer — places a real phone call; keep tight to
// protect installers from a flood of customer-triggered dials.
const bookLimiter = rateLimit({
windowMs: 60 * 60 * 1000, max: 8,
standardHeaders: true, legacyHeaders: false,
message: { error: 'too_many_requests' }
});
// Per-minute limiters — independent buckets (see hourLimiter rationale above).
// geoLimiter guards the public map JSON: browsers load it once per /map view,
// abusive scrapers hit it in tight loops. voteLimiter guards helpful-votes on
// paper comments. They're split so heavy map browsing can't exhaust a reader's
// vote budget. 60/min/IP keeps legit UX snappy while making abuse expensive.
const minuteLimiter = (max) => rateLimit({
windowMs: 60 * 1000, max,
standardHeaders: true, legacyHeaders: false,
message: { error: 'too_many_requests' }
});
const geoLimiter = minuteLimiter(60);
const voteLimiter = minuteLimiter(60);
app.use('/login', loginLimiter);
app.use('/signup', loginLimiter);
app.use(['/installer/:slug/claim', '/installer/:slug/claim/complete'], claimLimiter);
app.use('/api/installers/:slug/book', bookLimiter);
app.use('/api/installers.geo', geoLimiter);
app.use('/installer/:slug/notify-when-live', notifyLimiter);
app.use('/installer/:slug/coi-request', coiLimiter);
app.use('/installer/:slug/call-installer', callLimiter);
app.use('/papers/:slug/comments', commentLimiter);
app.use('/papers/:slug/comments/:id/helpful', voteLimiter);
// 60 votes/min/IP — high enough
// to not block real browsing,
// low enough to make scripted
// ballot stuffing visible.
app.use('/', publicRoutes);
app.use('/', callInstallerRoutes);
app.use('/', authRoutes);
app.use('/', require('./routes/auth-google'));
app.use('/', require('./routes/auth-linkedin'));
app.use('/', claimRoutes);
app.use('/unsubscribe', unsubscribeRoutes);
app.use('/admin', adminRoutes);
app.use('/api', apiRoutes);
// 404
app.use((req, res) => {
res.status(404).render('public/404', { title: 'Not Found', path: req.path });
});
// Error handler
app.use((err, req, res, next) => {
console.error('[error]', err);
res.status(err.status || 500);
if (req.accepts('html')) {
return res.render('public/error', { title: 'Something went wrong', message: err.message, path: req.path });
}
res.json({ error: err.message || 'internal_error' });
});
// Don't listen when required by tests (tests/app.js exports app via a separate factory)
if (require.main === module) {
app.listen(PORT, () => {
console.log(`[nph] listening on :${PORT} (${process.env.NODE_ENV || 'development'})`);
});
}
module.exports = app;