← back to Petitionyour

server.js

155 lines

const express = require('express');
const path = require('path');
const petitions = require('./lib/petitions');
const reps = require('./lib/reps');

const app = express();
const PORT = process.env.PORT || 4000;

app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
app.set('trust proxy', true); // behind Cloudflare + nginx in prod

app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));

// Minimal, honest security headers — no framework dependency needed for this.
app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'SAMEORIGIN');
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
  next();
});

function siteUrl(req) {
  const proto = req.headers['x-forwarded-proto'] || req.protocol;
  return `${proto}://${req.get('host')}`;
}

// ---- health (smoke test target) ----
app.get('/health', (req, res) => {
  res.status(200).type('text/plain').send('ok');
});

// ---- browse petitions ----
app.get('/', (req, res) => {
  const list = petitions.listPetitions().sort(
    (a, b) => new Date(b.createdAt) - new Date(a.createdAt)
  );
  const totalSignatures = list.reduce((sum, p) => sum + p.signatureCount, 0);
  res.render('index', {
    petitions: list,
    categories: petitions.CATEGORIES,
    totalSignatures,
    siteUrl: siteUrl(req),
  });
});

// ---- create petition ----
app.get('/petitions/new', (req, res) => {
  res.render('new', { categories: petitions.CATEGORIES, error: null, form: {} });
});

app.post('/petitions', (req, res) => {
  try {
    const p = petitions.createPetition(req.body);
    res.redirect(`/petitions/${p.slug}?created=1`);
  } catch (err) {
    res.status(400).render('new', {
      categories: petitions.CATEGORIES,
      error: err.message,
      form: req.body,
    });
  }
});

// ---- petition detail + sign ----
app.get('/petitions/:slug', (req, res) => {
  const p = petitions.getPetitionBySlug(req.params.slug);
  if (!p) return res.status(404).render('404', {});
  const supporters = petitions.listSignaturesForPetition(p.id, { publicOnly: true }).slice(0, 25);
  res.render('petition', {
    p,
    supporters,
    reach: petitions.repReachForPetition(p.id),
    siteUrl: siteUrl(req),
    created: req.query.created === '1',
    signed: req.query.signed === '1',
    error: null,
    form: {},
  });
});

app.post('/petitions/:slug/sign', (req, res) => {
  const p = petitions.getPetitionBySlug(req.params.slug);
  if (!p) return res.status(404).render('404', {});
  try {
    petitions.addSignature(req.params.slug, req.body);
    res.redirect(`/petitions/${req.params.slug}?signed=1#top`);
  } catch (err) {
    const supporters = petitions.listSignaturesForPetition(p.id, { publicOnly: true }).slice(0, 25);
    res.status(400).render('petition', {
      p,
      supporters,
      reach: petitions.repReachForPetition(p.id),
      siteUrl: siteUrl(req),
      created: false,
      signed: false,
      error: err.message,
      form: req.body,
    });
  }
});

// ---- find & contact your representatives ----
// Standalone page. The rich resolution (actual members + contact forms) is
// rendered client-side by the reps panel via /api/reps; this page just seeds
// the ZIP and provides the always-current official .gov links as a backstop.
app.get('/find-reps', (req, res) => {
  const zip = req.query.zip || '';
  const hit = zip ? reps.resolveZip(zip) : null;
  const links = hit ? reps.officialLinks(hit.stateAbbr, hit.zip) : null;
  res.render('find-reps', { zip, hit, links, error: zip && !hit ? 'Could not match that ZIP code.' : null });
});

// Legacy JSON endpoint (state + official links). Kept for backward compat.
app.get('/api/find-reps', (req, res) => {
  const zip = req.query.zip || '';
  const hit = reps.resolveZip(zip);
  if (!hit) {
    return res.status(404).json({ ok: false, error: 'Could not match that ZIP code to a state.' });
  }
  res.json({ ok: true, ...hit, links: reps.officialLinks(hit.stateAbbr, hit.zip) });
});

// Rich resolution: ZIP (+ optional district) -> actual senators + house rep(s)
// with their official contact-form URLs and phones.
app.get('/api/reps', (req, res) => {
  const zip = req.query.zip || '';
  const district = req.query.district;
  const resolved = reps.resolveReps({ zip, district });
  if (!resolved) {
    return res.status(404).json({ ok: false, error: 'Could not match that ZIP code to a state.' });
  }
  res.json({ ok: true, ...resolved, links: reps.officialLinks(resolved.stateAbbr, resolved.zip) });
});

// Precise House district from a full street address (free U.S. Census
// geocoder, no key). Returns the district so the client can re-call /api/reps.
app.get('/api/geocode-district', async (req, res) => {
  const address = req.query.address || '';
  const geo = await reps.geocodeAddressToDistrict(address);
  if (!geo.ok) return res.status(422).json(geo);
  res.json(geo);
});

// ---- 404 ----
app.use((req, res) => {
  res.status(404).render('404', {});
});

app.listen(PORT, () => {
  console.log(`petitionyour listening on http://127.0.0.1:${PORT}`);
});