← back to Stars of Design

lib/data.js

149 lines

// JSON-backed designer registry. Loaded once at boot, refreshed on each
// request via fs.statSync mtime check (cheap), so editing data/designers.json
// in place is picked up without a restart.

const fs = require('fs');
const path = require('path');

const FILE = path.join(__dirname, '..', 'data', 'designers.json');

let _cache = null;
let _mtime = 0;

// URL safety — only allow http(s) in href fields rendered to <a> tags.
// Blocks javascript:, data:, vbscript:, file:, mailto: (mailto would be fine for some fields but we have no use for it in this directory).
function safeUrl(u) {
  if (typeof u !== 'string') return null;
  return /^https?:\/\//i.test(u) ? u : null;
}

// preferred_dw must point at designerwallcoverings.com (the directory's
// publisher). Anything else gets rewritten to '#' so a bad data edit can't
// silently redirect users to a phishing clone.
function sanitizeDwUrl(u) {
  return (typeof u === 'string' && /^https:\/\/designerwallcoverings\.com\//i.test(u)) ? u : '#';
}

function sanitizeRow(d) {
  return {
    ...d,
    firm_url:      safeUrl(d.firm_url),
    instagram_url: safeUrl(d.instagram_url),
    preferred_dw:  (d.preferred_dw || []).map((p) => ({ label: p.label, url: sanitizeDwUrl(p.url) })),
    sources:       (d.sources || []).map((s) => ({ ...s, url: safeUrl(s.url) || '#' })),
  };
}

function load() {
  const stat = fs.statSync(FILE);
  if (!_cache || stat.mtimeMs !== _mtime) {
    const raw = JSON.parse(fs.readFileSync(FILE, 'utf8'));
    _cache = raw.map((d, idx) => sanitizeRow({ id: idx + 1, ...d }));
    _mtime = stat.mtimeMs;
  }
  return _cache;
}

function bySlug(slug) {
  return load().find((d) => d.slug === slug) || null;
}

function allStyles() {
  const set = new Set();
  for (const d of load()) (d.styles || []).forEach((s) => set.add(s));
  return Array.from(set).sort();
}

function allEras() {
  const set = new Set();
  for (const d of load()) if (d.era) set.add(d.era);
  return Array.from(set).sort();
}

function filter(opts = {}) {
  let rows = load();
  if (opts.style) rows = rows.filter((d) => (d.styles || []).includes(opts.style));
  if (opts.era)   rows = rows.filter((d) => d.era === opts.era);
  if (opts.q) {
    // Clamp q to 120 chars — nginx caps URL at 8kb, but 120 is plenty for a search box
    const q = String(opts.q).slice(0, 120).toLowerCase();
    rows = rows.filter((d) =>
      (d.name || '').toLowerCase().includes(q) ||
      (d.city || '').toLowerCase().includes(q) ||
      (d.bio || '').toLowerCase().includes(q) ||
      (d.styles || []).some((s) => s.toLowerCase().includes(q))
    );
  }
  return rows;
}

// ── Videos: JSON-backed video gallery (data/videos.json) ──────────────
// Same mtime-cache pattern as designers; cross-links to designer.slug.
const VIDEOS_FILE = path.join(__dirname, '..', 'data', 'videos.json');
let _videos = null;
let _videosMtime = 0;

function loadVideos() {
  try {
    const stat = fs.statSync(VIDEOS_FILE);
    if (!_videos || stat.mtimeMs !== _videosMtime) {
      const raw = JSON.parse(fs.readFileSync(VIDEOS_FILE, 'utf8'));
      // Sanitize the URL fields the same way designer URLs are sanitized.
      // YouTube embed/thumb/watch URLs are constructed from video_id (already-regex-validated)
      // so they're safe by construction. We still guard channel_url which is free-form.
      _videos = raw.map((v, idx) => ({
        ...v,
        id: idx + 1,
        channel_url: safeUrl(v.channel_url),
        // Re-derive these from video_id to be defensive — never trust subagent-provided URLs
        url:       /^[A-Za-z0-9_-]{11}$/.test(v.video_id) ? `https://www.youtube.com/watch?v=${v.video_id}` : null,
        embed_url: /^[A-Za-z0-9_-]{11}$/.test(v.video_id) ? `https://www.youtube.com/embed/${v.video_id}` : null,
        thumb_url: /^[A-Za-z0-9_-]{11}$/.test(v.video_id) ? `https://i.ytimg.com/vi/${v.video_id}/hqdefault.jpg` : null,
      })).filter(v => v.url);  // drop any rows whose video_id failed the regex
      _videosMtime = stat.mtimeMs;
    }
  } catch (e) {
    if (e.code === 'ENOENT') return [];
    throw e;
  }
  return _videos || [];
}

function videosByDesigner(slug) {
  if (!slug) return [];
  return loadVideos().filter(v => v.designer_slug === slug);
}

function videoCategories() {
  const set = new Set();
  for (const v of loadVideos()) if (v.category) set.add(v.category);
  return Array.from(set).sort();
}

function videoSources() {
  const set = new Set();
  for (const v of loadVideos()) if (v.source_org) set.add(v.source_org);
  return Array.from(set).sort();
}

function filterVideos(opts = {}) {
  let rows = loadVideos();
  if (opts.category)   rows = rows.filter(v => v.category === opts.category);
  if (opts.source_org) rows = rows.filter(v => v.source_org === opts.source_org);
  if (opts.q) {
    const q = String(opts.q).slice(0, 120).toLowerCase();
    rows = rows.filter(v =>
      (v.title || '').toLowerCase().includes(q) ||
      (v.channel || '').toLowerCase().includes(q) ||
      (v.summary || '').toLowerCase().includes(q) ||
      (v.category || '').toLowerCase().includes(q)
    );
  }
  return rows;
}

module.exports = {
  load, bySlug, filter, allStyles, allEras,
  loadVideos, videosByDesigner, videoCategories, videoSources, filterVideos,
};