← 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,
};