← back to Stars of Design

server.js

116 lines

require('dotenv').config();

const express = require('express');
const path = require('path');
const morgan = require('morgan');
const helmet = require('helmet');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');

const publicRoutes = require('./routes/public');

const app = express();
const PORT = parseInt(process.env.PORT || '9928', 10);
const PUBLIC_URL = process.env.PUBLIC_URL || `http://localhost:${PORT}`;
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.disable('x-powered-by');

// gzip compression for all responses ≥ 1KB. Mirror asim — JSON-LD-heavy
// HTML pages drop significantly over the wire.
app.use(compression({ threshold: 1024 }));

// Security headers (HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy)
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      // http://localhost:9935 is the Big Red widget host (lower-right avatar
      // launcher on every page); its iframe + JS + fetch traffic must be
      // allowed here. Drop the localhost:9935 entries when the named
      // tunnel bigred.agentabrams.com goes live.
      scriptSrc: ["'self'", "'unsafe-inline'", 'http://localhost:9935', 'https://www.googletagmanager.com', 'https://www.google-analytics.com'],
      styleSrc:  ["'self'", "'unsafe-inline'", 'https://fonts.googleapis.com'],
      imgSrc:    ["'self'", 'data:', 'https:', 'http://localhost:9935'],
      fontSrc:   ["'self'", 'https://fonts.gstatic.com', 'data:'],
      connectSrc: ["'self'", 'http://localhost:9935', 'ws://localhost:9935', 'https://www.google-analytics.com', 'https://*.analytics.google.com', 'https://*.googletagmanager.com'],
      frameSrc:  ["'self'", 'http://localhost:9935'],
      frameAncestors: ["'none'"],
      objectSrc: ["'none'"],
      baseUri:   ["'self'"],
      formAction: ["'self'"],
      upgradeInsecureRequests: [],
    },
  },
  strictTransportSecurity: { maxAge: 31536000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
  crossOriginEmbedderPolicy: false,
}));
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=(), payment=()');
  next();
});

// Rate limit — 200 req/min/IP across all routes, defense in depth
app.use(rateLimit({
  windowMs: 60_000, max: 200, standardHeaders: 'draft-7', legacyHeaders: false,
  message: 'Too many requests, please slow down.',
}));

app.use(morgan(IS_PROD ? 'combined' : 'dev'));
app.use(express.json({ limit: '256kb' }));
app.use(express.urlencoded({ extended: true, limit: '256kb' }));
app.use(cookieParser());

// Static-bak guard — refuse to serve any URL path that looks like a snapshot
// or rollback artifact (foo.html.bak, foo.pre-refactor.js, etc.) even if
// someone accidentally leaves one in public/. Pair with the .gitignore rules
// for *.bak / *.bak.* / *.pre-*. Returns plain-text 404 (not 403) so an
// attacker can't confirm a snapshot exists by path-probing, and bypasses
// the EJS 404 template since the meta-locals it expects aren't wired yet
// at this point in the middleware stack.
app.use((req, res, next) => {
  if (/\.bak(?:\.|$)|\.pre-/.test(req.path)) {
    res.setHeader('Cache-Control', 'no-store');
    return res.status(404).type('text/plain').send('Not found');
  }
  next();
});

app.use(express.static(path.join(__dirname, 'public'), {
  maxAge: '1h',
  setHeaders(res, p) {
    if (p.endsWith('.html')) res.setHeader('Cache-Control', 'no-store, must-revalidate');
  },
}));

app.use((req, res, next) => {
  res.locals.GA_ID = process.env.GA_ID || 'G-YCT0F2Q692';
  res.locals.PUBLIC_URL = PUBLIC_URL;
  next();
});

app.use('/', publicRoutes);

app.use((req, res) => {
  res.status(404).render('public/404', { title: 'Not found — Stars of Design' });
});

// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
  console.error('[error]', err && err.stack ? err.stack : err);
  // Always show generic message in prod — never leak internals
  res.status(500).render('public/error', {
    title: 'Server error — Stars of Design',
    message: 'Something went wrong.',
  });
});

app.listen(PORT, () => {
  console.log(`starsofdesign listening on :${PORT} (${IS_PROD ? 'prod' : 'dev'})`);
});