← back to Ventura Claw Leads

server.js

137 lines

require('dotenv').config();

const express = require('express');
const path = require('path');
const helmet = require('helmet');
const morgan = require('morgan');
const session = require('express-session');
const PgSession = require('connect-pg-simple')(session);
const rateLimit = require('express-rate-limit');

const db = require('./lib/db');
const auth = require('./lib/auth');
const { csrfMiddleware } = require('./lib/csrf');
const publicRoutes = require('./routes/public');
const webhookRoutes = require('./routes/webhooks');
const authRoutes = require('./routes/auth');
const claimRoutes = require('./routes/claim');
const adminRoutes = require('./routes/admin');

const app = express();

app.get(['/favicon.ico','/favicon.svg'], (req, res) => res.sendFile(require('path').join(__dirname, 'public', req.path.slice(1))));
const PORT = parseInt(process.env.PORT || '9789', 10);
const IS_PROD = process.env.NODE_ENV === 'production';

app.set('trust proxy', 1);
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));

app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      styleSrc:   ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
      fontSrc:    ["'self'", 'https://fonts.gstatic.com', 'data:'],
      scriptSrc:  ["'self'", "'unsafe-inline'", 'https://js.stripe.com'],
      imgSrc:     ["'self'", 'data:', 'https:'],
      connectSrc: ["'self'", 'https://api.stripe.com'],
      frameSrc:   ["'self'", 'https://js.stripe.com', 'https://hooks.stripe.com'],
      frameAncestors: ["'none'"],
      formAction: ["'self'"],
      baseUri:    ["'self'"],
      objectSrc:  ["'none'"],
      scriptSrcAttr: ["'none'"],
      upgradeInsecureRequests: IS_PROD ? [] : null
    }
  }
}));
app.use(morgan(IS_PROD ? 'combined' : 'dev'));

app.use(session({
  store: new PgSession({ pool: db.pool, tableName: 'session' }),
  secret: process.env.SESSION_SECRET || 'dev-only-replace-in-production',
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,
    secure: IS_PROD,
    sameSite: 'lax',
    maxAge: 30 * 24 * 60 * 60 * 1000
  }
}));

// Stripe webhook needs the raw body BEFORE express.json() runs so the
// signature verifier can hash it. Mount it first.
app.use('/webhooks/stripe', express.raw({ type: 'application/json', limit: '512kb' }), webhookRoutes);

app.use(express.urlencoded({ extended: false, limit: '64kb' }));
app.use(express.json({ limit: '64kb' }));
app.use('/static',   express.static(path.join(__dirname, 'public'), { maxAge: '7d' }));
app.use('/css',      express.static(path.join(__dirname, 'public/css'),     { maxAge: '7d' }));
app.use('/js',       express.static(path.join(__dirname, 'public/js'),      { maxAge: '7d' }));
app.use('/img',      express.static(path.join(__dirname, 'public/img'),     { maxAge: '7d' }));
app.use('/uploads',  express.static(path.join(__dirname, 'public/uploads'), { maxAge: '30d', immutable: false }));

// Locals exposed to every EJS template.
app.use((req, res, next) => {
  res.locals.publicUrl = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
  res.locals.path = req.path;
  next();
});

// Auth + CSRF (CSRF runs after attachBusiness so locals are available in error templates).
app.use(auth.attachBusiness);
// RFC 8058 one-click unsubscribe — email clients POST without browser session.
// The HMAC-derived token IS bearer auth; bypass CSRF.
app.use('/unsubscribe', (req, res, next) => { req.skipCsrf = true; next(); });
// Share-track: fire-and-forget telemetry POST from the share button on
// /business/:slug. Public + idempotent (per-session dedup), no PII, low
// abuse value. Bypass CSRF so the JS doesn't have to plumb the token.
app.use('/api/businesses/:slug/share-track', (req, res, next) => { req.skipCsrf = true; next(); });
// Photo upload is multipart/form-data — multer must parse it BEFORE
// csrfMiddleware so req.body._csrf is populated for the timing-safe compare.
// The actual route handler (in routes/admin.js) reads req.file directly.
const multer = require('multer');
const photoUploadMiddleware = require('./lib/photo-upload')(multer);
app.use('/admin/profile/photo', photoUploadMiddleware);
app.use(csrfMiddleware);

const contactLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, max: 8,
  standardHeaders: true, legacyHeaders: false,
  message: { error: 'too_many_requests' }
});
const claimLimiter = rateLimit({
  windowMs: 60 * 60 * 1000, max: 5,
  standardHeaders: true, legacyHeaders: false,
  message: { error: 'too_many_requests' }
});
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, max: 10,
  standardHeaders: true, legacyHeaders: false,
  message: { error: 'too_many_requests' }
});
app.use('/business/:slug/contact', contactLimiter);
app.use('/business/:slug/claim', claimLimiter);
app.use('/admin/login', loginLimiter);

app.use('/', publicRoutes);
app.use('/', authRoutes);
app.use('/', claimRoutes);
app.use('/admin', adminRoutes);

app.use((req, res) => {
  res.status(404).render('public/404', { title: 'Not Found' });
});

app.use((err, req, res, next) => {
  console.error('[unhandled]', err.stack || err.message);
  if (res.headersSent) return next(err);
  res.status(500).render('public/error', { title: 'Something went wrong', message: 'Please reload — if it persists, email info@venturaclaw.com.' });
});

app.listen(PORT, () => {
  console.log(`[ventura-claw-leads] listening on :${PORT} (env=${process.env.NODE_ENV || 'development'})`);
});