← back to Rentv Licensed Targets
server.js
145 lines
#!/usr/bin/env node
'use strict';
/*
* rentv-licensed-targets viewer
* Searchable / sortable grid over the 102k rentv_licensed_targets table
* (RENTV commercial advertiser-prospect universe: CA CSLB contractors,
* AZ ROC, DFPI lenders/escrow, DRE PM/subdivision, state bar, DOI title).
*
* Zero framework: 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); // 0 = OS picks a free port
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,
});
// pg emits 'error' on idle-client failure; without a listener Node crashes the process.
pool.on('error', (err) => console.error('pg pool error', err));
const TABLE = 'rentv_licensed_targets';
// Columns the client is allowed to sort by (whitelist → no injection surface).
const SORTABLE = new Set([
'entity_name', 'contact_name', 'role', 'license_no', 'license_type',
'license_status', 'city', 'county', 'market', 'phone', 'website',
'source', 'scraped_at', 'id',
]);
// Exact-match filter columns (value bound as a parameter).
const FILTERS = ['role', 'market', 'license_status', 'source', 'county'];
// Free-text search columns (ILIKE against one bound %q% param).
const SEARCH_COLS = ['entity_name', 'contact_name', 'license_no', 'city', 'address', 'phone'];
function timingSafeEqual(a, b) {
const ab = Buffer.from(a), bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return 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 timingSafeEqual(u || '', USER) && timingSafeEqual(p || '', PASS);
}
// Build the shared WHERE clause + bound params from the query string.
function buildWhere(qs) {
const clauses = [];
const 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 idx = params.length;
clauses.push('(' + SEARCH_COLS.map((c) => `${c} ILIKE $${idx}`).join(' OR ') + ')');
}
return { where: clauses.length ? 'WHERE ' + clauses.join(' AND ') : '', params };
}
async function apiTargets(qs) {
const { where, params } = buildWhere(qs);
let sort = qs.get('sort') || 'entity_name';
if (!SORTABLE.has(sort)) sort = 'entity_name';
const dir = (qs.get('dir') || 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
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 countSql = `SELECT count(*)::bigint AS n FROM ${TABLE} ${where}`;
const countRes = await pool.query(countSql, params);
const total = Number(countRes.rows[0].n);
// NULLS LAST keeps blank contact/website at the bottom regardless of dir.
const rowsSql =
`SELECT id, source, role, entity_name, contact_name, license_no, license_type,
license_status, address, city, county, state, zip, phone, website,
market, scraped_at
FROM ${TABLE} ${where}
ORDER BY ${sort} ${dir} NULLS LAST, id ASC
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`;
const rowsRes = await pool.query(rowsSql, [...params, limit, offset]);
return { total, page, limit, sort, dir, rows: rowsRes.rows };
}
let facetCache = null, facetAt = 0;
async function apiFacets() {
if (facetCache && Date.now() - facetAt < 5 * 60 * 1000) return facetCache;
const facet = async (col) => (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`
)).rows;
const [role, market, license_status, source, county] = await Promise.all(
['role', 'market', 'license_status', 'source', 'county'].map(facet)
);
const total = Number((await pool.query(`SELECT count(*)::bigint n FROM ${TABLE}`)).rows[0].n);
facetCache = { total, role, market, license_status, source, county };
facetAt = Date.now();
return facetCache;
}
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));
}
const server = http.createServer(async (req, res) => {
if (!authed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="rentv-targets"' });
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/targets') return send(res, 200, await apiTargets(url.searchParams));
if (url.pathname === '/healthz') return send(res, 200, { ok: true });
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, () => {
const p = server.address().port;
console.log(`rentv-licensed-targets viewer live: http://${HOST}:${p} (login ${USER})`);
});