← 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;