← back to Sdcc Journalists Viewer

server.js

257 lines

#!/usr/bin/env node
/**
 * SDCC Journalists Viewer — live view over the Drive spreadsheet.
 * Auth: Google Sign-In (allowlisted domains/emails) with basic-auth fallback.
 * Unauth /healthz for the fleet keepalive.
 * Data source of truth: gdrive:SDCC/sdcc-journalists-media-contacts.xlsx,
 * re-pulled by scripts/refresh.py on boot, every 5 min, and via POST /api/refresh.
 */
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');

// .env loader (no deps)
const envPath = path.join(__dirname, '.env');
if (fs.existsSync(envPath)) {
  for (const line of fs.readFileSync(envPath, 'utf8').split('\n')) {
    const m = line.match(/^([A-Z_][A-Z0-9_]*)=(.*)$/);
    if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
  }
}

const PORT = parseInt(process.env.PORT || '9878', 10);
const AUTH = 'Basic ' + Buffer.from(process.env.BASIC_AUTH || 'admin:DW2024!').toString('base64');
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID || '';
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET || '';
const BASE_URL = process.env.BASE_URL || `http://127.0.0.1:${PORT}`;
const COOKIE_SECRET = process.env.COOKIE_SECRET || 'dev-secret-change-me';
const ALLOWED_DOMAINS = (process.env.ALLOWED_DOMAINS || 'studentdebtcrisis.org').toLowerCase().split(',').filter(Boolean);
const ALLOWED_EMAILS = (process.env.ALLOWED_EMAILS || 'steveabramsdesigns@gmail.com,steve@designerwallcoverings.com,browntwn@gmail.com')
  .toLowerCase().split(',').filter(Boolean);

const DATA_JSON = path.join(__dirname, 'data', 'journalists.json');
const REFRESH = path.join(__dirname, 'scripts', 'refresh.py');
// Steve-added notes — kept OUT of journalists.json so the 5-min Drive re-pull can't clobber them.
const USER_NOTES_JSON = path.join(__dirname, 'data', 'user-notes.json');
function readUserNotes() { try { return JSON.parse(fs.readFileSync(USER_NOTES_JSON, 'utf8')); } catch { return {}; } }

/* ─── Drive refresh loop ─────────────────────────────────────────────── */
let refreshing = false;
function refresh(cb) {
  if (refreshing) return cb && cb(new Error('refresh already running'));
  refreshing = true;
  execFile('python3', [REFRESH], { timeout: 150000 }, (err, stdout, stderr) => {
    refreshing = false;
    if (err) console.error('[refresh]', stderr || err.message);
    else console.log('[refresh]', stdout.trim());
    cb && cb(err);
  });
}
refresh();
setInterval(() => refresh(), 5 * 60 * 1000);

/* ─── Session helpers ────────────────────────────────────────────────── */
function sign(email) {
  const exp = Date.now() + 30 * 24 * 3600 * 1000; // 30 days
  const body = Buffer.from(JSON.stringify({ e: email, x: exp })).toString('base64url');
  const sig = crypto.createHmac('sha256', COOKIE_SECRET).update(body).digest('base64url');
  return `${body}.${sig}`;
}
function verifySession(cookieHeader) {
  const m = /(?:^|;\s*)sdccjv_sess=([^;]+)/.exec(cookieHeader || '');
  if (!m) return null;
  const [body, sig] = m[1].split('.');
  if (!body || !sig) return null;
  const want = crypto.createHmac('sha256', COOKIE_SECRET).update(body).digest('base64url');
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(want))) return null;
  try {
    const { e, x } = JSON.parse(Buffer.from(body, 'base64url').toString());
    if (Date.now() > x) return null;
    return e;
  } catch { return null; }
}
function emailAllowed(email) {
  email = (email || '').toLowerCase();
  if (ALLOWED_EMAILS.includes(email)) return true;
  const dom = email.split('@')[1] || '';
  return ALLOWED_DOMAINS.includes(dom);
}

/* ─── Google OAuth (authorization-code flow) ─────────────────────────── */
function postForm(url, form) {
  return new Promise((resolve, reject) => {
    const data = new URLSearchParams(form).toString();
    const u = new URL(url);
    const req = https.request({ hostname: u.hostname, path: u.pathname, method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) } },
      res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve(JSON.parse(b))); });
    req.on('error', reject); req.write(data); req.end();
  });
}

const LOGIN_HTML = (msg) => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SDCC Media Contacts — Sign in</title><style>body{margin:0;background:#0C0F1A;color:#f0f0f5;font:15px/1.5 -apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh}
.box{background:#131829;border:1px solid #2A3048;border-radius:14px;padding:40px;text-align:center;max-width:380px}
h1{font-size:18px;margin:0 0 6px}p{color:#a1a1b5;font-size:13px}
input{display:block;width:100%;box-sizing:border-box;background:#1C2237;border:1px solid #2A3048;border-radius:8px;color:#f0f0f5;padding:9px 12px;font-size:14px;margin-top:10px}
button{width:100%;background:#10b981;color:#08110c;border:0;border-radius:8px;padding:10px;font-weight:700;font-size:14px;margin-top:12px;cursor:pointer}
.or{display:flex;align-items:center;gap:10px;color:#6B7280;font-size:11px;margin:18px 0 4px;text-transform:uppercase;letter-spacing:.08em}
.or:before,.or:after{content:"";flex:1;height:1px;background:#2A3048}
a.g{display:inline-flex;align-items:center;gap:10px;background:#fff;color:#1f1f1f;border-radius:8px;padding:10px 22px;font-weight:600;text-decoration:none;margin-top:10px}
.err{color:#f43f5e;font-size:13px;margin-top:12px}</style></head><body><div class="box">
<h1>SDCC Media Contacts</h1><p>Sign in to use the journalist data.</p>
<form method="POST" action="/login">
  <input name="username" placeholder="Username" autocomplete="username" required>
  <input name="password" type="password" placeholder="Password" autocomplete="current-password" required>
  <button type="submit">Sign in</button>
</form>
<div class="or">or, if needed</div>
<a class="g" href="/auth/google"><svg width="18" height="18" viewBox="0 0 48 48"><path fill="#EA4335" d="M24 9.5c3.5 0 6.6 1.2 9 3.5l6.7-6.7C35.7 2.5 30.2 0 24 0 14.6 0 6.5 5.4 2.6 13.2l7.8 6.1C12.3 13.2 17.7 9.5 24 9.5z"/><path fill="#4285F4" d="M46.5 24.5c0-1.6-.1-3.1-.4-4.5H24v9h12.7c-.6 3-2.3 5.5-4.8 7.2l7.5 5.8c4.4-4.1 7.1-10.1 7.1-17.5z"/><path fill="#FBBC05" d="M10.4 28.7A14.4 14.4 0 0 1 9.5 24c0-1.6.3-3.2.8-4.7l-7.8-6.1A24 24 0 0 0 0 24c0 3.9.9 7.5 2.6 10.8l7.8-6.1z"/><path fill="#34A853" d="M24 48c6.2 0 11.7-2 15.5-5.6l-7.5-5.8c-2.1 1.4-4.8 2.3-8 2.3-6.3 0-11.7-3.7-13.6-9l-7.8 6.1C6.5 42.6 14.6 48 24 48z"/></svg>Sign in with Gmail</a>
${msg ? `<div class="err">${msg}</div>` : ''}</div></body></html>`;

/* ─── Server ─────────────────────────────────────────────────────────── */
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml' };

const server = http.createServer(async (req, res) => {
  const u = new URL(req.url, BASE_URL);
  const url = u.pathname;

  if (url === '/healthz') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end('{"ok":true}'); }

  if (url === '/auth/google') {
    if (!CLIENT_ID) { res.writeHead(500); return res.end('Google OAuth not configured'); }
    const p = new URLSearchParams({
      client_id: CLIENT_ID, redirect_uri: `${BASE_URL}/auth/google/callback`,
      response_type: 'code', scope: 'openid email profile', prompt: 'select_account',
    });
    res.writeHead(302, { Location: `https://accounts.google.com/o/oauth2/v2/auth?${p}` });
    return res.end();
  }

  if (url === '/auth/google/callback') {
    try {
      const tok = await postForm('https://oauth2.googleapis.com/token', {
        code: u.searchParams.get('code'), client_id: CLIENT_ID, client_secret: CLIENT_SECRET,
        redirect_uri: `${BASE_URL}/auth/google/callback`, grant_type: 'authorization_code',
      });
      if (!tok.id_token) throw new Error(tok.error_description || tok.error || 'no id_token');
      const claims = JSON.parse(Buffer.from(tok.id_token.split('.')[1], 'base64url').toString());
      if (!claims.email || claims.email_verified === false) throw new Error('email not verified');
      if (!emailAllowed(claims.email)) {
        res.writeHead(403, { 'Content-Type': 'text/html' });
        return res.end(LOGIN_HTML(`${claims.email} isn’t on the SDCC access list. Ask Steve to add you.`));
      }
      console.log('[auth] signed in:', claims.email);
      res.writeHead(302, {
        'Set-Cookie': `sdccjv_sess=${sign(claims.email)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${BASE_URL.startsWith('https') ? '; Secure' : ''}`,
        Location: '/',
      });
      return res.end();
    } catch (e) {
      console.error('[auth]', e.message);
      res.writeHead(500, { 'Content-Type': 'text/html' });
      return res.end(LOGIN_HTML('Sign-in failed: ' + e.message));
    }
  }

  if (url === '/logout') {
    res.writeHead(302, { 'Set-Cookie': 'sdccjv_sess=; Path=/; Max-Age=0', Location: '/login' });
    return res.end();
  }
  if (url === '/login' && req.method === 'GET') { res.writeHead(200, { 'Content-Type': 'text/html' }); return res.end(LOGIN_HTML('')); }

  if (url === '/login' && req.method === 'POST') {
    let body = '';
    req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
    req.on('end', () => {
      const p = new URLSearchParams(body);
      const expect = (process.env.BASIC_AUTH || 'admin:DW2024!');
      const got = `${p.get('username') || ''}:${p.get('password') || ''}`;
      const a = Buffer.from(got), b = Buffer.from(expect);
      if (a.length === b.length && crypto.timingSafeEqual(a, b)) {
        console.log('[auth] password sign-in');
        res.writeHead(302, {
          'Set-Cookie': `sdccjv_sess=${sign('admin')}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${BASE_URL.startsWith('https') ? '; Secure' : ''}`,
          Location: '/',
        });
      } else {
        res.writeHead(401, { 'Content-Type': 'text/html' });
        res.end(LOGIN_HTML('Wrong username or password.'));
        return;
      }
      res.end();
    });
    return;
  }

  // auth gate: Google session OR basic-auth fallback
  const sessEmail = verifySession(req.headers.cookie);
  if (!sessEmail && req.headers.authorization !== AUTH) {
    if (url.startsWith('/api/')) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end('{"error":"sign in"}'); }
    res.writeHead(302, { Location: '/login' });
    return res.end();
  }

  if (url === '/api/me') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify({ email: sessEmail || 'basic-auth' }));
  }

  if (url === '/api/data') {
    try {
      const body = fs.readFileSync(DATA_JSON);
      res.writeHead(200, { 'Content-Type': 'application/json' });
      return res.end(body);
    } catch {
      res.writeHead(503, { 'Content-Type': 'application/json' });
      return res.end('{"error":"data not yet loaded — try /api/refresh"}');
    }
  }

  if (url === '/api/notes' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    return res.end(JSON.stringify(readUserNotes()));
  }

  if (url === '/api/notes' && req.method === 'POST') {
    let body = '';
    req.on('data', c => { body += c; if (body.length > 20000) req.destroy(); });
    req.on('end', () => {
      try {
        const { name, note } = JSON.parse(body || '{}');
        if (!name || typeof name !== 'string') { res.writeHead(400, { 'Content-Type': 'application/json' }); return res.end('{"error":"name required"}'); }
        const notes = readUserNotes();
        const clean = String(note == null ? '' : note).slice(0, 4000);
        if (clean.trim() === '') delete notes[name]; else notes[name] = clean;   // empty note = delete
        fs.writeFileSync(USER_NOTES_JSON, JSON.stringify(notes, null, 2));
        res.writeHead(200, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ ok: true, name, note: clean }));
      } catch (e) {
        res.writeHead(400, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ ok: false, error: e.message }));
      }
    });
    return;
  }

  if (url === '/api/refresh' && req.method === 'POST') {
    return refresh((err) => {
      res.writeHead(err ? 500 : 200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ ok: !err, error: err ? err.message : undefined }));
    });
  }

  const file = path.join(__dirname, 'public', url === '/' ? 'index.html' : url);
  if (!file.startsWith(path.join(__dirname, 'public'))) { res.writeHead(403); return res.end(); }
  fs.readFile(file, (err, body) => {
    if (err) { res.writeHead(404); return res.end('not found'); }
    res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
    res.end(body);
  });
});

server.listen(PORT, () => console.log(`sdcc-journalists-viewer on ${BASE_URL} (port ${PORT})`));