← back to Socrata Cre Viewer

server.js

133 lines

#!/usr/bin/env node
'use strict';
/*
 * socrata-cre-viewer
 * Searchable / sortable grid over realestate.socrata_cre_prospects — LA Socrata
 * CRE advertiser-prospect firms (Active Businesses + LADBS permits), net-new vs
 * the curated LABJ set. Node built-in http + pg (parameterized queries only).
 * Basic Auth admin / DW2024!  (override via VIEWER_USER / VIEWER_PASS).
 */
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { Pool } = require('pg');

const PORT = Number(process.env.PORT || 0);
const HOST = process.env.HOST || '127.0.0.1';
const USER = process.env.VIEWER_USER || 'admin';
const PASS = process.env.VIEWER_PASS || 'DW2024!';

const pool = new Pool({ host: process.env.PGHOST || '/tmp', database: process.env.PGDATABASE || 'realestate', max: 6 });
pool.on('error', (err) => console.error('pg pool error', err));
const TABLE = 'socrata_cre_prospects';

const SORTABLE = new Set(['firm_name', 'role', 'contact_name', 'city', 'naics_desc',
  'permit_sub_type', 'valuation', 'issue_date', 'source', 'first_seen', 'zip']);
const FILTERS = ['role', 'source', 'city', 'naics_desc', 'council_district'];
const SEARCH_COLS = ['firm_name', 'contact_name', 'address', 'naics_desc', 'work_description'];

function safeEq(a, b) {
  const ab = Buffer.from(a), bb = Buffer.from(b);
  return ab.length === bb.length && crypto.timingSafeEqual(ab, bb);
}
function authed(req) {
  const h = req.headers.authorization || '';
  if (!h.startsWith('Basic ')) return false;
  const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
  return safeEq(u || '', USER) && safeEq(p || '', PASS);
}

function buildWhere(qs) {
  const clauses = [], params = [];
  for (const col of FILTERS) {
    const v = qs.get(col);
    if (v) { params.push(v); clauses.push(`${col} = $${params.length}`); }
  }
  const q = (qs.get('q') || '').trim();
  if (q) {
    params.push(`%${q}%`);
    const i = params.length;
    clauses.push('(' + SEARCH_COLS.map((c) => `${c} ILIKE $${i}`).join(' OR ') + ')');
  }
  return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
}

async function apiRows(qs) {
  const { where, params } = buildWhere(qs);
  let sort = qs.get('sort') || 'firm_name';
  if (!SORTABLE.has(sort)) sort = 'firm_name';
  const dir = (qs.get('dir') || 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
  // valuation is stored as text; sort it numerically when chosen.
  const sortExpr = sort === 'valuation' ? "NULLIF(valuation,'')::bigint" : sort;
  const limit = Math.min(Math.max(parseInt(qs.get('limit'), 10) || 50, 1), 200);
  const page = Math.max(parseInt(qs.get('page'), 10) || 1, 1);
  const offset = (page - 1) * limit;

  const total = Number((await pool.query(`SELECT count(*)::bigint n FROM ${TABLE} ${where}`, params)).rows[0].n);
  const rowsSql =
    `SELECT firm_name, role, contact_name, address, city, state, zip, naics, naics_desc,
            permit_type, permit_sub_type, valuation, issue_date, source, work_description,
            council_district, first_seen
       FROM ${TABLE} ${where}
      ORDER BY ${sortExpr} ${dir} NULLS LAST, firm_name ASC
      LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
  const rows = (await pool.query(rowsSql, [...params, limit, offset])).rows;
  return { total, page, limit, sort, dir, rows };
}

let fc = null, fa = 0;
async function apiFacets() {
  if (fc && Date.now() - fa < 3 * 60 * 1000) return fc;
  const facet = async (col, lim) => (await pool.query(
    `SELECT ${col} AS v, count(*)::int AS n FROM ${TABLE}
      WHERE ${col} IS NOT NULL AND ${col} <> '' GROUP BY ${col} ORDER BY n DESC LIMIT ${lim}`)).rows;
  const [role, source, city, naics_desc] = await Promise.all([
    facet('role', 20), facet('source', 10), facet('city', 40), facet('naics_desc', 40)]);
  const total = Number((await pool.query(`SELECT count(*)::bigint n FROM ${TABLE}`)).rows[0].n);
  fc = { total, role, source, city, naics_desc }; fa = Date.now();
  return fc;
}

function send(res, code, body, type = 'application/json') {
  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
  res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
}

// Static assets from public/ (e.g. the nav-agent drop-in). Additive: only serves
// files that resolve INSIDE public/ — path-traversal attempts fall through to 404.
const PUBLIC_DIR = path.join(__dirname, 'public');
const MIME = {
  '.js': 'application/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8',
  '.html': 'text/html; charset=utf-8', '.json': 'application/json; charset=utf-8',
  '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
  '.gif': 'image/gif', '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff2': 'font/woff2',
  '.woff': 'font/woff', '.ttf': 'font/ttf', '.map': 'application/json; charset=utf-8',
};
function serveStatic(res, pathname) {
  const rel = decodeURIComponent(pathname).replace(/^\/+/, '');
  const abs = path.resolve(PUBLIC_DIR, rel);
  if (abs !== PUBLIC_DIR && !abs.startsWith(PUBLIC_DIR + path.sep)) return false; // traversal guard
  if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return false;
  const type = MIME[path.extname(abs).toLowerCase()] || 'application/octet-stream';
  res.writeHead(200, { 'Content-Type': type });
  res.end(fs.readFileSync(abs));
  return true;
}

const server = http.createServer(async (req, res) => {
  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="socrata-cre"' }); return res.end('Auth required'); }
  const url = new URL(req.url, 'http://x');
  try {
    if (url.pathname === '/' || url.pathname === '/index.html')
      return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
    if (url.pathname === '/api/facets') return send(res, 200, await apiFacets());
    if (url.pathname === '/api/rows') return send(res, 200, await apiRows(url.searchParams));
    if (url.pathname === '/healthz') return send(res, 200, { ok: true });
    if (serveStatic(res, url.pathname)) return;
    return send(res, 404, { error: 'not found' });
  } catch (e) { console.error(e); return send(res, 500, { error: String(e.message || e) }); }
});

server.listen(PORT, HOST, () => console.log(`socrata-cre-viewer live: http://${HOST}:${server.address().port}  (login ${USER})`));