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