← back to Dandeana Hub

server.js

90 lines

// Dandeana Hub — private client news hub for the Larchmont Street Subdivision (Poway)
// Whole-site HTTP Basic Auth: every route 401s without creds except /healthz (pm2/deploy smoke).
const express = require('express');
const fs = require('fs');
const path = require('path');
const { runRefresh } = require('./scripts/refresh');
const { pullParcels } = require('./scripts/pull-parcels');

const PORT = process.env.PORT || 9917;
const DATA = path.join(__dirname, 'data');
// BASIC_AUTH="user:pass" overrides; default is the client credential pair.
// Split on the FIRST colon only so passwords containing colons are preserved.
const _rawAuth = process.env.BASIC_AUTH || 'dandeana:w3tEgaYeOPDQ';
const _authColon = _rawAuth.indexOf(':');
const AUTH_USER = _rawAuth.slice(0, _authColon);
const AUTH_PASS = _rawAuth.slice(_authColon + 1);
// Second accepted pair: Steve's unified admin credential (ADMIN_AUTH="user:pass" overrides).
// The client pair above must keep working — never collapse these into one.
const _rawAdmin = process.env.ADMIN_AUTH || 'admin:DW2024!';
const _admColon = _rawAdmin.indexOf(':');
const ADMIN_USER = _rawAdmin.slice(0, _admColon);
const ADMIN_PASS = _rawAdmin.slice(_admColon + 1);

const app = express();
app.disable('x-powered-by');

app.get('/healthz', (_req, res) => res.json({ ok: true, service: 'dandeana-hub' }));

app.use((req, res, next) => {
  const hdr = req.headers.authorization || '';
  const [scheme, b64] = hdr.split(' ');
  if (scheme === 'Basic' && b64) {
    const decoded = Buffer.from(b64, 'base64').toString();
    const ci = decoded.indexOf(':');
    const u = decoded.slice(0, ci), p = decoded.slice(ci + 1);
    if (u === AUTH_USER && p === AUTH_PASS) return next();
    if (u === ADMIN_USER && p === ADMIN_PASS) return next();
  }
  res.set('WWW-Authenticate', 'Basic realm="Dandeana Private Hub"');
  return res.status(401).send('Authentication required');
});

app.use(express.static(path.join(__dirname, 'public')));
app.use('/docs', express.static(path.join(__dirname, 'docs')));

function readJson(file, fallback) {
  try { return JSON.parse(fs.readFileSync(path.join(DATA, file), 'utf8')); }
  catch { return fallback; }
}
function readItems() {
  try {
    return fs.readFileSync(path.join(DATA, 'news.jsonl'), 'utf8')
      .split('\n').filter(Boolean).map(l => JSON.parse(l));
  } catch { return []; }
}

app.get('/api/project', (_req, res) => res.json(readJson('project.json', {})));
app.get('/api/timeline', (_req, res) => res.json(readJson('timeline.json', [])));
app.get('/api/documents', (_req, res) => res.json(readJson('documents.json', [])));
app.get('/api/records', (_req, res) => res.json(readJson('records.json', [])));
app.get('/api/parcels', (_req, res) => res.json(readJson('parcels.json', { lots: [] })));
app.get('/api/items', (_req, res) => {
  const meta = readJson('refresh-meta.json', {});
  res.json({ lastRefresh: meta.lastRefresh || null, items: readItems() });
});

let refreshing = false;
app.post('/api/refresh', async (_req, res) => {
  if (refreshing) return res.status(409).json({ ok: false, error: 'refresh already running' });
  refreshing = true;
  try {
    const result = await runRefresh(DATA);
    res.json({ ok: true, ...result });
  } catch (e) {
    res.status(500).json({ ok: false, error: String(e.message || e) });
  } finally { refreshing = false; }
});

app.listen(PORT, '127.0.0.1', () => console.log(`dandeana-hub listening on 127.0.0.1:${PORT}`));

// Daily self-refresh so the hub stays current with zero external schedulers.
const DAY = 24 * 60 * 60 * 1000;
const BOOT_DELAY = 15 * 1000; // wait for process to fully settle before first pull
function dailyPull() {
  runRefresh(DATA).catch(e => console.error('scheduled refresh failed:', e.message));
  pullParcels(DATA).catch(e => console.error('parcel pull failed:', e.message));
}
setInterval(dailyPull, DAY);
setTimeout(dailyPull, BOOT_DELAY);