← back to Wallco Ai
feat(styleguides): open to public with per-IP render cap (5 images/day)
5a812ec5a0f7ad57931930bff7e0386a0b7f3da2 · 2026-06-01 11:33:35 -0700 · Steve Abrams
- Page now served publicly on the styleguides.wallco.ai vhost root (gate removed).
- Non-admin callers metered to SG_IMAGE_CAP=5 paid renders / 24h per IP:
Generate clamps batch n to remaining (429 when 0); Recolor counts 1 each.
Admins (cookie/?admin/localhost) unlimited.
- Recolor opened to the public ONLY via the page's X-SG header — main storefront
PDP recolor stays 404 for non-admins. New GET /api/styleguides/quota.
- Page shows live 'N / 5 free renders left' pill, disables Generate/Apply at 0,
reads remaining straight off each action response.
Files touched
M public/admin/styleguides.htmlM server.js
Diff
commit 5a812ec5a0f7ad57931930bff7e0386a0b7f3da2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 1 11:33:35 2026 -0700
feat(styleguides): open to public with per-IP render cap (5 images/day)
- Page now served publicly on the styleguides.wallco.ai vhost root (gate removed).
- Non-admin callers metered to SG_IMAGE_CAP=5 paid renders / 24h per IP:
Generate clamps batch n to remaining (429 when 0); Recolor counts 1 each.
Admins (cookie/?admin/localhost) unlimited.
- Recolor opened to the public ONLY via the page's X-SG header — main storefront
PDP recolor stays 404 for non-admins. New GET /api/styleguides/quota.
- Page shows live 'N / 5 free renders left' pill, disables Generate/Apply at 0,
reads remaining straight off each action response.
---
public/admin/styleguides.html | 38 +++++++++++++++++++-----
server.js | 68 ++++++++++++++++++++++++++++++++++++++-----
2 files changed, 91 insertions(+), 15 deletions(-)
diff --git a/public/admin/styleguides.html b/public/admin/styleguides.html
index c71ff57..14698ac 100644
--- a/public/admin/styleguides.html
+++ b/public/admin/styleguides.html
@@ -156,6 +156,7 @@
<div class="bar">
<div class="bar-in">
<span class="selpill"><b id="sel-count">0</b> selected</span>
+ <span class="selpill" id="quota-pill" title="Free paid renders left for you today (per IP)" style="display:none"></span>
<span class="selnames" id="sel-names">No houses selected</span>
<div class="actions">
<button class="btn" id="btn-clear">Clear all</button>
@@ -219,9 +220,14 @@
const QS = new URLSearchParams(location.search);
const ADMIN = QS.get('admin') || '';
const authd = (p) => ADMIN ? p + (p.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(ADMIN) : p;
- const jfetch = (p, opts) => fetch(authd(p), Object.assign({ credentials:'same-origin' }, opts || {}));
+ // X-SG marks this page's paid calls so the server opens recolor to the
+ // public here (capped) without opening it on the main storefront.
+ const jfetch = (p, opts={}) => {
+ const headers = Object.assign({ 'X-SG':'1' }, opts.headers || {});
+ return fetch(authd(p), Object.assign({ credentials:'same-origin' }, opts, { headers }));
+ };
- const state = { brands: { fashion: [], wallcovering: [] }, sel: new Set() };
+ const state = { brands: { fashion: [], wallcovering: [] }, sel: new Set(), quota: { admin:false, remaining:5, cap:5 } };
const byKey = {};
const $ = (s, r=document) => r.querySelector(s);
@@ -263,7 +269,23 @@
$('#sel-count').textContent=n;
const names=[...state.sel].map(k=>byKey[k]&&byKey[k].label).filter(Boolean);
$('#sel-names').textContent = n? names.join(' · ') : 'No houses selected';
- ['btn-json','btn-export','btn-apply','btn-generate'].forEach(id=>$('#'+id).disabled = n===0);
+ const noQuota = !state.quota.admin && (state.quota.remaining|0) <= 0;
+ $('#btn-json').disabled = n===0;
+ $('#btn-export').disabled = n===0;
+ $('#btn-apply').disabled = n===0 || noQuota;
+ $('#btn-generate').disabled = n===0 || noQuota;
+ $('#btn-generate').textContent = noQuota ? 'Daily limit reached' : 'Generate Designs →';
+ }
+ function setQuota(q){ if(q) state.quota=q; renderQuota(); syncBar(); }
+ function renderQuota(){
+ const q=state.quota, pill=$('#quota-pill'); pill.style.display='';
+ if(q.admin){ pill.innerHTML='✦ Admin · unlimited'; pill.style.color='var(--gold)'; return; }
+ const r=Math.max(0,q.remaining|0);
+ pill.innerHTML='<b style="color:'+(r?'var(--gold)':'#d57')+'">'+r+'</b> / '+q.cap+' free renders left';
+ }
+ async function refreshQuota(){
+ try{ const r=await jfetch('/api/styleguides/quota'); const j=await r.json();
+ if(j.ok) setQuota({admin:!!j.admin, remaining:j.unlimited?Infinity:j.remaining, cap:j.cap}); }catch(e){}
}
// ── payload ───────────────────────────────────────────────────────────────
@@ -336,9 +358,10 @@
const r=await jfetch('/api/generator/batch',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'failed');
- $('#gen-status').innerHTML='✓ Batch <b>#'+j.run_id+'</b> running — '+(j.note||'')+' <a href="/designs" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">watch the Studio ↗</a>';
+ if(typeof j.remaining==='number') setQuota({admin:false,remaining:j.remaining,cap:state.quota.cap}); else refreshQuota();
+ $('#gen-status').innerHTML='✓ Batch <b>#'+j.run_id+'</b> running ('+j.count+' image'+(j.count>1?'s':'')+') — '+(j.note||'')+' <a href="/designs" target="_blank" rel="noopener noreferrer" style="color:var(--gold)">watch the Studio ↗</a>';
toast('Batch #'+j.run_id+' started');
- }catch(e){ $('#gen-status').textContent='✗ '+e.message; }
+ }catch(e){ $('#gen-status').textContent='✗ '+e.message; refreshQuota(); }
finally{ go.disabled=false; }
});
@@ -370,12 +393,13 @@
const r=await jfetch('/api/design/'+id+'/recolor',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({brand:applyBrand})});
const j=await r.json();
if(!j.ok) throw new Error(j.error||'failed');
+ if(typeof j.remaining==='number') setQuota({admin:false,remaining:j.remaining,cap:state.quota.cap}); else refreshQuota();
const div=el('div','r');
div.innerHTML='<a href="/design/'+j.new_id+'" target="_blank" rel="noopener noreferrer"><img src="'+j.new_image_url+'"></a><div class="cap">'+label+' · #'+j.new_id+'</div>';
$('#apply-results').prepend(div);
$('#apply-status').textContent='✓ #'+id+' → '+label+' (#'+j.new_id+')';
toast('Recolored → #'+j.new_id);
- }catch(e){ $('#apply-status').textContent='✗ '+e.message; }
+ }catch(e){ $('#apply-status').textContent='✗ '+e.message; refreshQuota(); }
}
// ── boot ──────────────────────────────────────────────────────────────────
@@ -384,7 +408,7 @@
const r=await jfetch('/api/styleguides/brands'); const j=await r.json();
state.brands.fashion=j.fashion||[]; state.brands.wallcovering=j.wallcovering||[];
[...state.brands.fashion,...state.brands.wallcovering].forEach(b=>byKey[b.key]=b);
- renderGrids(); syncBar();
+ renderGrids(); syncBar(); refreshQuota();
}catch(e){ $('#grid-fashion').innerHTML='<div class="skeleton">Failed to load houses: '+e.message+'</div>'; }
})();
})();
diff --git a/server.js b/server.js
index d106449..4c54280 100644
--- a/server.js
+++ b/server.js
@@ -1128,25 +1128,64 @@ setInterval(() => load(true), 30000);
// check as a CRITICAL bypass (anyone could mint a token with {role:"admin"}).
const { isAdmin, requireAdmin } = require('./src/admin-gate');
-// ── Style Guides — Brand Selector (admin) ──────────────────────────────────
+// ── Style Guides — Brand Selector (PUBLIC, metered) ────────────────────────
// A curated picker of fashion + wallcovering houses whose palettes/signatures
// seed the generative pipeline. Three live actions on the page:
// • Generate → POST /api/generator/batch (new SDXL designs in the houses' look)
// • Apply → POST /api/design/:id/recolor (restyle an existing studio design)
// • Export → portable style-guide JSON / Figma block (client-side)
-// Lives behind the same admin gate as the curators. Reachable two ways:
-// /admin/styleguides AND the bare root of the styleguides.wallco.ai vhost.
+// Open to the public (Steve 2026-06-01 "give user all abilities") but METERED:
+// every non-admin visitor gets SG_IMAGE_CAP paid renders per rolling 24h,
+// keyed by client IP. Generate counts its batch size; Recolor counts 1 each.
+// Admins (cookie / ?admin / localhost) are unlimited. The cap protects the
+// Replicate/Gemini bill from open-internet abuse.
+const SG_IMAGE_CAP = 5;
+const SG_WINDOW_MS = 24 * 60 * 60 * 1000;
+const _sgQuota = new Map(); // ip -> { used, resetAt }
+function sgClientIp(req) {
+ return String(req.headers['x-real-ip'] || '').trim()
+ || String(req.headers['x-forwarded-for'] || '').split(',')[0].trim()
+ || (req.socket && req.socket.remoteAddress) || 'unknown';
+}
+function sgBudget(req) {
+ const ip = sgClientIp(req), now = Date.now();
+ let e = _sgQuota.get(ip);
+ if (!e || now > e.resetAt) { e = { used: 0, resetAt: now + SG_WINDOW_MS }; _sgQuota.set(ip, e); }
+ return { ip, entry: e, used: e.used, cap: SG_IMAGE_CAP,
+ remaining: Math.max(0, SG_IMAGE_CAP - e.used), resetAt: e.resetAt };
+}
+function sgConsume(req, k) { sgBudget(req).entry.used += Math.max(0, k | 0); }
+// Header the styleguides page stamps on its paid calls, so opening recolor to
+// the public here does NOT open it on the main storefront PDPs (those stay 404
+// for non-admins). batch is already public, so it's capped for everyone non-admin.
+function fromSG(req) { return String(req.headers['x-sg'] || '') === '1'; }
+// Recolor gate: admin → unlimited; styleguides public within budget → allow;
+// anyone else → 404 (storefront recolor stays admin-only as before).
+function sgRecolorGate(req, res, next) {
+ if (isAdmin(req)) return next();
+ if (fromSG(req)) {
+ if (sgBudget(req).remaining <= 0) {
+ return res.status(429).json({ ok: false, error: `Free limit reached — ${SG_IMAGE_CAP} images per day. Come back tomorrow.` });
+ }
+ return next();
+ }
+ return res.status(404).type('html').send('<h1>404</h1>');
+}
+
app.get('/', (req, res, next) => {
if (/^styleguides\./i.test(req.hostname || '')) {
- if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
return res.sendFile(path.join(__dirname, 'public', 'admin', 'styleguides.html'));
}
next();
});
app.get('/admin/styleguides', (req, res) => {
- if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
res.sendFile(path.join(__dirname, 'public', 'admin', 'styleguides.html'));
});
+app.get('/api/styleguides/quota', (req, res) => {
+ if (isAdmin(req)) return res.json({ ok: true, admin: true, unlimited: true, cap: SG_IMAGE_CAP, remaining: SG_IMAGE_CAP, used: 0 });
+ const b = sgBudget(req);
+ res.json({ ok: true, admin: false, unlimited: false, cap: b.cap, used: b.used, remaining: b.remaining, resetAt: b.resetAt });
+});
app.get('/api/styleguides/brands', (req, res) => {
try {
const { FASHION_PALETTES } = require('./scripts/fashion_palettes.js');
@@ -21199,7 +21238,16 @@ app.post('/api/generator/batch', (req, res) => {
const { colors = '', elements = [], styles = [], patterns = [], extra_text = '' } = req.body || {};
// Clamp the requested count to a sane 1–50 so a fat-fingered number can't
// spawn a runaway render job.
- const n = Math.max(1, Math.min(50, parseInt(req.body && req.body.n, 10) || 1));
+ let n = Math.max(1, Math.min(50, parseInt(req.body && req.body.n, 10) || 1));
+ // Public metering (Steve 2026-06-01): non-admin callers get SG_IMAGE_CAP
+ // paid renders / 24h per IP. Clamp n to what's left; refuse when exhausted.
+ if (!isAdmin(req)) {
+ const b = sgBudget(req);
+ if (b.remaining <= 0) {
+ return res.status(429).json({ ok: false, error: `Free limit reached — ${SG_IMAGE_CAP} images per day. Come back tomorrow.`, remaining: 0, cap: SG_IMAGE_CAP });
+ }
+ n = Math.min(n, b.remaining);
+ }
// Build the prompt (one prompt drives the whole batch; each render gets its
// own random seed so the N outputs are siblings, not duplicates).
@@ -21278,8 +21326,11 @@ app.post('/api/generator/batch', (req, res) => {
} catch {}
});
+ // Charge the public budget for the renders we just kicked off.
+ if (!isAdmin(req)) sgConsume(req, n);
// Respond immediately — the batch renders in the background.
res.json({ ok: true, run_id: runId, count: n, status: 'running', prompt,
+ remaining: isAdmin(req) ? null : sgBudget(req).remaining,
note: `Rendering ${n} design${n>1?'s':''} in the background — watch the Recent Runs list. Safe to close this tab.` });
} catch (e) { res.status(500).json({ ok:false, error: e.message }); }
});
@@ -23729,7 +23780,7 @@ const HUE_REP_HEX = {
neutral:'#9A8C78', charcoal:'#3A3A3A',
};
-app.post('/api/design/:id/recolor', requireAdmin, express.json(), async (req, res) => {
+app.post('/api/design/:id/recolor', sgRecolorGate, express.json(), async (req, res) => {
if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
try {
const id = parseInt(req.params.id, 10);
@@ -23941,7 +23992,8 @@ app.post('/api/design/:id/recolor', requireAdmin, express.json(), async (req, re
tags: baseTags,
});
- res.json({ ok: true, parent_id: id, new_id: newId, new_image_url: '/designs/img/' + filename, hue: hueKey, elapsed_s: elapsed });
+ if (!isAdmin(req)) sgConsume(req, 1);
+ res.json({ ok: true, parent_id: id, new_id: newId, new_image_url: '/designs/img/' + filename, hue: hueKey, elapsed_s: elapsed, remaining: isAdmin(req) ? null : sgBudget(req).remaining });
} catch (e) {
res.status(500).json({ ok: false, error: e.message });
}
← 0fbb86a feat(styleguides): Brand Selector admin tool wired to genera
·
back to Wallco Ai
·
Guard /api/design and /api/design/ (missing id) → redirect h 703d174 →