← back to Small Business Builder
[morning-review] small-business-builder: harden public profile + interview API
b789cdc5ea3e47bae8eda608d3927b159b0b1d54 · 2026-05-04 12:51:11 -0700 · SteveStudio2
- Strip third-party mcp.figma.com/capture.js from public home + profile pages
- Escape JSON-LD payloads so user answers can't break out of <script> (XSS)
- Wrap new URL(b.website) so scheme-less domains don't 500 the profile route
- Gate POST /api/biz/interview behind ADMIN_TOKEN cookie + admin_token field;
reject Array qa payloads, accept urlencoded qa_<id> for no-JS form fallback,
redirect non-JSON callers, trim history to 5 most-recent rows per business
- Cap homepage SELECT at 200 rows; replace /n/:slug full-table scan with
WHERE neighborhood=$1 + DISTINCT slug resolution; 409 on slug collisions
- Guard Number.isFinite on years_in_business, recorded_at date format,
empty hoodSlug links, NaN max_chars in interview form
- Move interview-form submit button inside <form> so no-JS submit + a11y work
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M src/render/home.jsM src/render/profile.jsM src/server/index.js
Diff
commit b789cdc5ea3e47bae8eda608d3927b159b0b1d54
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 4 12:51:11 2026 -0700
[morning-review] small-business-builder: harden public profile + interview API
- Strip third-party mcp.figma.com/capture.js from public home + profile pages
- Escape JSON-LD payloads so user answers can't break out of <script> (XSS)
- Wrap new URL(b.website) so scheme-less domains don't 500 the profile route
- Gate POST /api/biz/interview behind ADMIN_TOKEN cookie + admin_token field;
reject Array qa payloads, accept urlencoded qa_<id> for no-JS form fallback,
redirect non-JSON callers, trim history to 5 most-recent rows per business
- Cap homepage SELECT at 200 rows; replace /n/:slug full-table scan with
WHERE neighborhood=$1 + DISTINCT slug resolution; 409 on slug collisions
- Guard Number.isFinite on years_in_business, recorded_at date format,
empty hoodSlug links, NaN max_chars in interview form
- Move interview-form submit button inside <form> so no-JS submit + a11y work
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
src/render/home.js | 58 +++++++++++++++-
src/render/profile.js | 56 ++++++++++++----
src/server/index.js | 179 +++++++++++++++++++++++++++++++++++++++++++-------
3 files changed, 255 insertions(+), 38 deletions(-)
diff --git a/src/render/home.js b/src/render/home.js
index e2cae90..f5e60fd 100644
--- a/src/render/home.js
+++ b/src/render/home.js
@@ -106,8 +106,47 @@ function renderNeighborhoodTile(n) {
</a>`;
}
+// Build the featured-interview block, if any.
+// Pull the philosophy + best neighborhood-section answer (favorite restaurant
+// or coffee — these read best as a teaser) and link to the full profile.
+function renderFeaturedInterview(featured) {
+ if (!featured || !featured.business || !featured.qa_json) return '';
+ const b = featured.business;
+ const qa = featured.qa_json;
+ const philosophy = qa.philosophy;
+ // Pick the most appealing teaser from the neighborhood section.
+ const teaserKey = ['fav_restaurant','best_coffee','after_the_chair','hidden_gem','date_night']
+ .find(k => qa[k] && qa[k].trim());
+ const teaserMap = {
+ fav_restaurant: 'Where they actually eat',
+ best_coffee: 'Best coffee, no diplomacy',
+ after_the_chair: '30 minutes to kill, post-cut',
+ hidden_gem: 'Hidden gem, no tourists',
+ date_night: 'Where to take a date',
+ };
+ if (!philosophy && !teaserKey) return '';
+ const accent = NEIGHBORHOOD_ACCENT[b.neighborhood] || '#1f6f70';
+ return `
+ <section class="featured" style="--feat-accent:${accent}">
+ <div class="featured-inner">
+ <div class="featured-eyebrow">In this issue · <a href="/biz/${esc(b.slug)}">${esc(b.name)}</a> · ${esc(b.neighborhood || 'Los Angeles')}</div>
+ ${philosophy ? `
+ <blockquote class="featured-quote">"${esc(philosophy)}"</blockquote>
+ <div class="featured-attr">— ${esc(b.owner_name || b.name)}</div>
+ ` : ''}
+ ${teaserKey ? `
+ <div class="featured-teaser">
+ <div class="featured-teaser-label">${esc(teaserMap[teaserKey])}</div>
+ <p class="featured-teaser-text">${esc(qa[teaserKey])}</p>
+ </div>
+ ` : ''}
+ <a class="featured-cta" href="/biz/${esc(b.slug)}">Read the full interview →</a>
+ </div>
+ </section>`;
+}
+
// businesses: full list, neighborhoods: [{neighborhood, count}], featuredBarbers: shops where category in barbershop/beard
-export function renderHome({ featuredBarbers = [], otherShops = [], neighborhoods = [], totalCount = 0 } = {}) {
+export function renderHome({ featuredBarbers = [], otherShops = [], neighborhoods = [], featured = null, totalCount = 0 } = {}) {
// Sort neighborhood list by editorial priority, then by count desc, then alphabetical
const orderIdx = (n) => {
const i = NAV_ORDER.indexOf(n);
@@ -131,7 +170,6 @@ export function renderHome({ featuredBarbers = [], otherShops = [], neighborhood
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
-<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
<style>
:root {
--ink:#1a1a1a;
@@ -190,6 +228,20 @@ img{display:block;max-width:100%;height:auto}
.shop-name{font-family:var(--serif);font-size:22px;font-weight:500;letter-spacing:-.01em;line-height:1.15;margin-bottom:4px}
.shop-loc{font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mute)}
+/* ==== Featured interview ======================================== */
+.featured{padding:80px 32px;background:var(--ink);color:var(--paper);border-top:1px solid var(--ink);border-bottom:1px solid var(--ink)}
+.featured-inner{max-width:880px;margin:0 auto}
+.featured-eyebrow{font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:#aaa;margin-bottom:32px}
+.featured-eyebrow a{color:var(--feat-accent,var(--accent));border-bottom:1px solid var(--feat-accent,var(--accent));padding-bottom:1px}
+.featured-quote{font-family:var(--serif);font-style:italic;font-size:clamp(28px,3.6vw,46px);line-height:1.2;letter-spacing:-.01em;font-weight:400;margin-bottom:18px}
+.featured-quote::first-letter{color:var(--feat-accent,var(--accent));font-style:normal;font-size:1.3em}
+.featured-attr{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:#888;margin-bottom:48px}
+.featured-teaser{margin-bottom:36px;padding-left:24px;border-left:2px solid var(--feat-accent,var(--accent))}
+.featured-teaser-label{font-family:var(--mono);font-size:10px;letter-spacing:.22em;text-transform:uppercase;color:var(--feat-accent,var(--accent));margin-bottom:10px}
+.featured-teaser-text{font-family:var(--serif);font-size:21px;line-height:1.5;color:#e6e3dc}
+.featured-cta{display:inline-block;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--feat-accent,var(--accent));border:1px solid var(--feat-accent,var(--accent));padding:14px 22px}
+.featured-cta:hover{background:var(--feat-accent,var(--accent));color:var(--ink)}
+
/* ==== Manifesto strip =========================================== */
.manifesto{padding:80px 32px;background:var(--ink);color:var(--paper);border-top:1px solid var(--ink)}
.manifesto-inner{max-width:880px;margin:0 auto}
@@ -231,6 +283,8 @@ img{display:block;max-width:100%;height:auto}
</div>
</section>
+${renderFeaturedInterview(featured)}
+
<section class="section" id="neighborhoods">
<div class="section-head">
<h2>Browse by neighborhood</h2>
diff --git a/src/render/profile.js b/src/render/profile.js
index c6700b5..c7852e3 100644
--- a/src/render/profile.js
+++ b/src/render/profile.js
@@ -28,6 +28,24 @@ const CATEGORY_LABEL = {
blowout:'Blowout', waxing:'Waxing', generic:'Salon',
};
+// Escape a JSON string for safe embedding inside <script type="application/ld+json">.
+// Without this, an answer containing "</script>" can break out of the script tag (XSS).
+function escJsonLd(json) {
+ return String(json).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
+}
+
+// Safely extract a website hostname; the DB may contain bare domains like "foo.com"
+// without a scheme, which would cause `new URL(...)` to throw.
+function safeHostname(raw) {
+ if (!raw) return '';
+ const s = String(raw).trim();
+ try {
+ return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(s) ? s : `https://${s}`).hostname.replace(/^www\./, '');
+ } catch {
+ return s.replace(/^https?:\/\//i, '').replace(/^www\./i, '').split('/')[0];
+ }
+}
+
// Build the FAQ schema.org JSON-LD blob from filled answers.
function faqJsonLd(filled) {
const mainEntity = filled.map(({ q, a }) => ({
@@ -95,9 +113,8 @@ export function renderProfile({ business, interview, otherShopsInHood = [] } = {
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600;700&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
-<script src="https://mcp.figma.com/mcp/html-to-design/capture.js" async></script>
-<script type="application/ld+json">${localBusinessJsonLd(b)}</script>
-${filled.length ? `<script type="application/ld+json">${faqJsonLd(filled)}</script>` : ''}
+<script type="application/ld+json">${escJsonLd(localBusinessJsonLd(b))}</script>
+${filled.length ? `<script type="application/ld+json">${escJsonLd(faqJsonLd(filled))}</script>` : ''}
<style>
:root{
--ink:#1a1a1a;--paper:#f5f5f5;--rule:#e6e3dc;--mute:#7a766f;--accent:${accent};
@@ -186,22 +203,22 @@ img{display:block;max-width:100%;height:auto}
<div class="brand"><a href="/">LA <em>Salon</em> Directory</a></div>
<nav class="nav-mini">
<a href="/">Home</a>
- ${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>` : ''}
+ ${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>` : (b.neighborhood ? `<span>${esc(b.neighborhood)}</span>` : '')}
<a href="/biz/${esc(b.slug)}/template/1">Mockup</a>
</nav>
</header>
<section class="hero">
- <div class="crumb"><a href="/">Directory</a> · ${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a> · ` : ''}${esc(cat)}</div>
+ <div class="crumb"><a href="/">Directory</a> · ${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a> · ` : (b.neighborhood ? `${esc(b.neighborhood)} · ` : '')}${esc(cat)}</div>
<div class="hero-grid">
<div class="hero-meta">
<span class="cat">${esc(cat)}</span>
<h1>${esc(b.name)}</h1>
- <div class="loc">${b.neighborhood ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>, ` : ''}${esc(b.city || 'Los Angeles')}</div>
+ <div class="loc">${b.neighborhood && hoodSlug ? `<a href="/n/${esc(hoodSlug)}">${esc(b.neighborhood)}</a>, ` : (b.neighborhood ? `${esc(b.neighborhood)}, ` : '')}${esc(b.city || 'Los Angeles')}</div>
<div class="hero-stats">
- ${b.years_in_business ? `<div>Established<b>${b.years_in_business} yrs</b></div>` : ''}
+ ${Number.isFinite(Number(b.years_in_business)) && Number(b.years_in_business) > 0 ? `<div>Established<b>${esc(String(Number(b.years_in_business)))} yrs</b></div>` : ''}
${b.address ? `<div>Address<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0">${esc(b.address)}</b></div>` : ''}
- ${b.website ? `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(b.website)}" target="_blank" rel="noopener">${esc((new URL(b.website)).hostname.replace('www.',''))}</a></b></div>` : ''}
+ ${b.website ? `<div>Online<b style="font-size:14px;font-style:normal;font-family:var(--mono);letter-spacing:0"><a href="${esc(b.website)}" target="_blank" rel="noopener">${esc(safeHostname(b.website) || b.website)}</a></b></div>` : ''}
</div>
</div>
<div class="hero-img">
@@ -220,7 +237,8 @@ ${philosophy ? `
${filled.length === 0 ? `
<section class="no-interview">
<p>This profile is waiting for its interview.</p>
- <a href="/biz/${esc(b.slug)}/interview">Add the interview →</a>
+ <a href="/biz/${esc(b.slug)}/interview/live" style="margin-right:10px;background:var(--accent);color:var(--paper);border-color:var(--accent)">🎙 Start live AI interview</a>
+ <a href="/biz/${esc(b.slug)}/interview">Or fill the form →</a>
</section>
` : `
<article class="interview" itemscope itemtype="https://schema.org/FAQPage">
@@ -245,8 +263,12 @@ ${filled.length === 0 ? `
</div>`;
}).join('')}
<div style="margin-top:32px;padding-top:18px;border-top:1px solid var(--rule);font-family:var(--mono);font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--mute);text-align:right">
- Recorded ${interview?.recorded_at ? new Date(interview.recorded_at).toLocaleDateString('en-US',{month:'long',year:'numeric'}) : ''}
- ${interview?.recorded_by ? `· by ${esc(interview.recorded_by)}` : ''}
+ ${(() => {
+ if (!interview?.recorded_at) return interview?.recorded_by ? `Recorded · by ${esc(interview.recorded_by)}` : '';
+ const d = new Date(interview.recorded_at);
+ const dateStr = isNaN(d.getTime()) ? '' : d.toLocaleDateString('en-US',{month:'long',year:'numeric'});
+ return `Recorded ${dateStr}${interview?.recorded_by ? ` · by ${esc(interview.recorded_by)}` : ''}`;
+ })()}
</div>
</article>
`}
@@ -255,7 +277,7 @@ ${otherShopsInHood.length ? `
<section class="more">
<div class="more-head">
<h2>More in ${esc(b.neighborhood || 'Los Angeles')}</h2>
- <a href="/n/${esc(hoodSlug)}">See all in ${esc(b.neighborhood || 'LA')} →</a>
+ ${hoodSlug ? `<a href="/n/${esc(hoodSlug)}">See all in ${esc(b.neighborhood || 'LA')} →</a>` : ''}
</div>
<div class="more-grid">
${otherShopsInHood.slice(0, 4).map(s => `
@@ -355,20 +377,26 @@ h1{font-family:var(--serif);font-weight:500;font-size:48px;letter-spacing:-.02em
${sec.questions.map(q => {
const lq = localizeQuestion(q, b);
const v = qa[q.id] || '';
+ const maxChars = Number.isFinite(q.max_chars) && q.max_chars > 0 ? q.max_chars : 500;
return `
<div class="qa">
<label for="q_${esc(q.id)}">${esc(lq.q)}</label>
- <textarea id="q_${esc(q.id)}" name="qa_${esc(q.id)}" maxlength="${q.max_chars * 2}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
- <div class="hint">~${q.max_chars} chars · ${esc(q.seo_intent)}</div>
+ <textarea id="q_${esc(q.id)}" name="qa_${esc(q.id)}" maxlength="${maxChars * 2}" placeholder="${esc(lq.placeholder)}">${esc(v)}</textarea>
+ <div class="hint">~${maxChars} chars · ${esc(q.seo_intent || '')}</div>
</div>`;
}).join('')}
`).join('')}
+ <div style="margin-top:16px;text-align:right">
+ <button type="submit" style="padding:11px 24px;background:var(--accent);color:var(--paper);border:0;font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;cursor:pointer;font-weight:500">Save interview</button>
+ </div>
</form>
<div class="bar">
<span class="saved" id="status">Edit anywhere — save when ready.</span>
<span>
+ <a href="/biz/${esc(b.slug)}/interview/live">🎙 Live mode</a>
+
<a href="/biz/${esc(b.slug)}">View profile</a>
<button type="submit" form="form">Save interview</button>
diff --git a/src/server/index.js b/src/server/index.js
index 56afb79..688039f 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -29,6 +29,16 @@ import { renderGrid } from '../render/grid.js';
import { renderEdit } from '../render/edit.js';
import { renderHome } from '../render/home.js';
import { renderProfile, renderInterviewForm } from '../render/profile.js';
+import { renderInterviewLive } from '../render/interview-live.js';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const PUBLIC_DIR = path.resolve(__dirname, '..', '..', 'public');
+const UPLOADS_DIR = path.join(PUBLIC_DIR, 'uploads');
+fs.mkdirSync(UPLOADS_DIR, { recursive: true });
import { renderTemplate } from '../render/templates/index.js';
import { scrapeWebsiteMeta } from '../scrape/website_meta.js';
import { scrapeInstagramPublic } from '../scrape/instagram_public.js';
@@ -38,9 +48,12 @@ const PORT = parseInt(process.env.PORT || '9760', 10);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || '';
app.use(compression());
-app.use(express.json({ limit: '512kb' }));
+// Bumped to 8MB so webcam-frame data URLs can POST without 413.
+app.use(express.json({ limit: '8mb' }));
app.use(express.urlencoded({ extended: true, limit: '512kb' }));
app.use(cookieParser());
+// Serve uploaded webcam captures + any other public assets.
+app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d' }));
// --- helpers ----------------------------------------------------------------
@@ -79,10 +92,23 @@ app.get('/healthz', async (_req, res) => {
// Public editorial homepage — neighborhood-led, barber/beard featured first.
app.get('/', async (_req, res) => {
const all = await many(
- `SELECT slug,name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
+ `SELECT slug,name,owner_name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
FROM businesses
- ORDER BY ranking_score DESC NULLS LAST, updated_at DESC`
+ ORDER BY ranking_score DESC NULLS LAST, updated_at DESC
+ LIMIT 200`
+ );
+ // Latest filled interview drives the "In this issue" featured block.
+ const featured = await one(
+ `SELECT i.qa_json, i.recorded_at, b.slug, b.name, b.owner_name, b.neighborhood
+ FROM business_interviews i
+ JOIN businesses b ON b.id = i.business_id
+ WHERE jsonb_typeof(i.qa_json) = 'object' AND i.qa_json <> '{}'::jsonb
+ ORDER BY i.recorded_at DESC LIMIT 1`
);
+ const featuredBlock = featured ? {
+ business: { slug: featured.slug, name: featured.name, owner_name: featured.owner_name, neighborhood: featured.neighborhood },
+ qa_json: featured.qa_json,
+ } : null;
const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
const featuredBarbers = all.filter(isBarber).slice(0, 8);
const featuredIds = new Set(featuredBarbers.map(b => b.slug));
@@ -97,7 +123,7 @@ app.get('/', async (_req, res) => {
const neighborhoods = [...hoodMap.entries()].map(([neighborhood, count]) => ({ neighborhood, count }));
res.set('content-type', 'text/html; charset=utf-8');
- res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, totalCount: all.length }));
+ res.send(renderHome({ featuredBarbers, otherShops, neighborhoods, featured: featuredBlock, totalCount: all.length }));
});
// Original mockup-grid (admin/builder UI) lives at /builder now.
@@ -113,26 +139,40 @@ app.get('/builder', async (_req, res) => {
// Neighborhood detail page — list the shops in one LA neighborhood, editorially.
app.get('/n/:slug', async (req, res) => {
const slug = String(req.params.slug || '').toLowerCase();
- // resolve slug → neighborhood name (case-insensitive, dash → space)
- const all = await many(
+ // First, resolve slug → distinct neighborhood name(s). Pull only the unique
+ // neighborhood strings rather than every business row, so this stays cheap
+ // even as the catalog grows.
+ const hoodRows = await many(
+ `SELECT DISTINCT neighborhood FROM businesses WHERE neighborhood IS NOT NULL`
+ );
+ const matchedHoods = hoodRows
+ .map(r => r.neighborhood)
+ .filter(n => n && n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') === slug);
+ if (!matchedHoods.length) return res.status(404).send('Neighborhood not found');
+ if (matchedHoods.length > 1) {
+ log.err('neighborhood slug collision', { slug, matched: matchedHoods });
+ return res.status(409).send(`Ambiguous neighborhood — ${matchedHoods.length} match this slug`);
+ }
+ const neighborhood = matchedHoods[0];
+ const shops = await many(
`SELECT slug,name,category,neighborhood,city,state,tier,hero_image_url,ranking_score
- FROM businesses WHERE neighborhood IS NOT NULL
- ORDER BY ranking_score DESC NULLS LAST`
+ FROM businesses WHERE neighborhood=$1
+ ORDER BY ranking_score DESC NULLS LAST, updated_at DESC
+ LIMIT 200`,
+ [neighborhood]
+ );
+ const hoodCounts = await many(
+ `SELECT neighborhood, COUNT(*)::int AS count FROM businesses
+ WHERE neighborhood IS NOT NULL AND neighborhood <> $1
+ GROUP BY neighborhood ORDER BY count DESC LIMIT 50`,
+ [neighborhood]
);
- const match = all.find(b => b.neighborhood &&
- b.neighborhood.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') === slug);
- if (!match) return res.status(404).send('Neighborhood not found');
- const neighborhood = match.neighborhood;
- const shops = all.filter(b => b.neighborhood === neighborhood);
- const otherHoods = [...new Set(all.map(b => b.neighborhood))].filter(n => n !== neighborhood);
// We render via renderHome with featured=barbers-in-this-hood + other=rest, slightly repurposed.
const isBarber = (b) => b.category === 'barbershop' || b.category === 'beard';
const featuredBarbers = shops.filter(isBarber);
const featuredIds = new Set(featuredBarbers.map(b => b.slug));
const otherShops = shops.filter(b => !featuredIds.has(b.slug));
- const neighborhoods = otherHoods.map(n => ({
- neighborhood: n, count: all.filter(b => b.neighborhood === n).length,
- }));
+ const neighborhoods = hoodCounts.map(r => ({ neighborhood: r.neighborhood, count: r.count }));
res.set('content-type', 'text/html; charset=utf-8');
res.send(renderHome({
@@ -172,24 +212,119 @@ app.get('/biz/:slug/interview', async (req, res) => {
res.send(renderInterviewForm({ business: b, interview }));
});
-// Save (newest-wins; we keep history rows).
+// Live AI-avatar interview — webcam + speech. Browser-native, no server-side LLM.
+app.get('/biz/:slug/interview/live', async (req, res) => {
+ const b = await loadBySlug(req.params.slug);
+ if (!b) return res.status(404).send('Not found');
+ const interview = await one(
+ `SELECT qa_json, recorded_by, recorded_at FROM business_interviews
+ WHERE business_id=$1 ORDER BY recorded_at DESC LIMIT 1`, [b.id]
+ );
+ res.set('content-type', 'text/html; charset=utf-8');
+ res.send(renderInterviewLive({ business: b, interview }));
+});
+
+// Webcam frame upload — saves a JPEG into /uploads, attaches as candidate hero.
+app.post('/api/biz/photo/upload', async (req, res) => {
+ try {
+ const { slug, dataUrl, kind } = req.body || {};
+ if (!slug || !dataUrl) return res.status(400).json({ error: 'slug + dataUrl required' });
+ const b = await loadBySlug(slug);
+ if (!b) return res.status(404).json({ error: 'business not found' });
+ // Decode base64 from data URL.
+ const m = String(dataUrl).match(/^data:(image\/(?:jpeg|png|webp));base64,(.+)$/);
+ if (!m) return res.status(400).json({ error: 'invalid dataUrl (need image/jpeg|png|webp)' });
+ const mime = m[1];
+ const ext = mime === 'image/png' ? 'png' : (mime === 'image/webp' ? 'webp' : 'jpg');
+ const buf = Buffer.from(m[2], 'base64');
+ if (buf.length > 6 * 1024 * 1024) return res.status(413).json({ error: 'image too large' });
+ const filename = `${slug}-${Date.now()}.${ext}`;
+ const fullPath = path.join(UPLOADS_DIR, filename);
+ fs.writeFileSync(fullPath, buf);
+ const url = `/uploads/${filename}`;
+ // If the business has no hero_image_url yet, adopt this one.
+ if (!b.hero_image_url) {
+ await query('UPDATE businesses SET hero_image_url=$1 WHERE id=$2', [url, b.id]);
+ }
+ log.info('photo uploaded', { slug, url, bytes: buf.length, kind: kind || 'unspecified' });
+ res.json({ ok: true, url, bytes: buf.length, adopted_as_hero: !b.hero_image_url });
+ } catch (e) {
+ log.err('photo upload failed', { error: String(e.message || e) });
+ res.status(500).json({ error: String(e.message || e) });
+ }
+});
+
+// Save (newest-wins; we keep a rolling history capped at INTERVIEW_HISTORY_MAX rows).
+// Auth: gated by the same ADMIN_TOKEN cookie as /admin so anonymous visitors
+// can't overwrite arbitrary shop interviews.
app.post('/api/biz/interview', async (req, res) => {
try {
- const { slug, qa, recorded_by } = req.body || {};
- if (!slug || !qa || typeof qa !== 'object') return res.status(400).json({ error: 'slug + qa required' });
+ const tok = req.cookies?.smb_admin || req.body?.admin_token || req.query?.t || '';
+ if (!ADMIN_TOKEN || tok !== ADMIN_TOKEN) {
+ return res.status(401).json({ error: 'unauthorized' });
+ }
+
+ const body = req.body || {};
+ const slug = body.slug;
+ const recorded_by = body.recorded_by;
+
+ // Accept either JSON `{ qa: { id: "answer", ... } }` (the JS path) OR a no-JS
+ // urlencoded form post with `qa_<id>` keys. Reject arrays — `typeof []` is
+ // 'object' so the prior typeof guard alone let attackers corrupt the JSONB.
+ let qa = body.qa;
+ if (qa === undefined || qa === null) {
+ qa = {};
+ for (const [k, v] of Object.entries(body)) {
+ if (typeof k === 'string' && k.startsWith('qa_') && k.length > 3) {
+ qa[k.slice(3)] = v;
+ }
+ }
+ }
+ if (!slug || !qa || typeof qa !== 'object' || Array.isArray(qa)) {
+ return res.status(400).json({ error: 'slug + qa required' });
+ }
+
const b = await loadBySlug(slug);
if (!b) return res.status(404).json({ error: 'business not found' });
- // Strip empty answers so the JSONB stays clean.
+
+ // Strip empty answers so the JSONB stays clean. Coerce arrays-as-values to a
+ // single string so a malicious `qa_<id>=a&qa_<id>=b` repeat-key payload
+ // can't sneak in non-string types.
const cleanQa = {};
for (const [k, v] of Object.entries(qa)) {
- const s = String(v || '').trim();
+ const raw = Array.isArray(v) ? v[v.length - 1] : v;
+ const s = String(raw || '').trim();
if (s) cleanQa[k] = s.slice(0, 4000);
}
+
await query(
`INSERT INTO business_interviews (business_id, qa_json, recorded_by) VALUES ($1, $2, $3)`,
[b.id, JSON.stringify(cleanQa), (recorded_by || '').slice(0, 120) || null]
);
+
+ // Keep a rolling history per business so the per-shop row count can't grow
+ // unboundedly and slow down the latest-interview SELECT.
+ const HISTORY_MAX = 5;
+ await query(
+ `DELETE FROM business_interviews
+ WHERE business_id=$1
+ AND id NOT IN (
+ SELECT id FROM business_interviews
+ WHERE business_id=$1
+ ORDER BY recorded_at DESC NULLS LAST, id DESC
+ LIMIT $2
+ )`,
+ [b.id, HISTORY_MAX]
+ );
+
log.info('interview saved', { slug, fields: Object.keys(cleanQa).length });
+
+ // No-JS form posts get a redirect back to the profile; JSON callers get JSON.
+ const wantsJson = (req.get('content-type') || '').includes('application/json') ||
+ (req.get('accept') || '').includes('application/json');
+ if (!wantsJson) {
+ return res.redirect(`/biz/${encodeURIComponent(slug)}`);
+ }
res.json({ ok: true, slug, fields: Object.keys(cleanQa).length });
} catch (e) {
log.err('interview save failed', { error: String(e.message || e) });
← 035afe2 yolo: iter 3 trivial patches from claude-codex debate
·
back to Small Business Builder
·
[morning-review] small-business-builder: gate interview admi 41c8a33 →