← 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'})`);
});