← back to Wallco Ai
wallco.ai · all target=_blank now rel=noopener noreferrer (tab-nabbing fix) + 3 external links (figma/spoonflower/dash.cloudflare) escalated to add nofollow — closes the window.opener XSS pivot vector + SEO link-juice leak. 20 internal blanks, 3 external upgraded.
d6cdb2422b68760da466af8218672d4392c737a8 · 2026-05-12 07:42:47 -0700 · SteveStudio2
Files touched
Diff
commit d6cdb2422b68760da466af8218672d4392c737a8
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 12 07:42:47 2026 -0700
wallco.ai · all target=_blank now rel=noopener noreferrer (tab-nabbing fix) + 3 external links (figma/spoonflower/dash.cloudflare) escalated to add nofollow — closes the window.opener XSS pivot vector + SEO link-juice leak. 20 internal blanks, 3 external upgraded.
---
server.js | 40 ++++++++++++++++++++--------------------
1 file changed, 20 insertions(+), 20 deletions(-)
diff --git a/server.js b/server.js
index 85ed233..3ecd8c2 100644
--- a/server.js
+++ b/server.js
@@ -351,7 +351,7 @@ app.get('/admin/golive', (_req, res) => {
<script>
const STEPS = [
{ key:'zone', title:'1. Cloudflare zone exists', desc:'CF API can\'t create zones with current token scope. One-time manual click.',
- fix:'Open <a href="https://dash.cloudflare.com" target="_blank" style="color:#d2b15c">dash.cloudflare.com</a> → Add a site → wallco.ai → Free plan. Then refresh this page.' },
+ fix:'Open <a href="https://dash.cloudflare.com" target="_blank" style="color:#d2b15c" rel="noopener noreferrer nofollow">dash.cloudflare.com</a> → Add a site → wallco.ai → Free plan. Then refresh this page.' },
{ key:'dns', title:'2. DNS records (A, www, MX, SPF, DKIM, DMARC)', desc:'Six required records on the CF zone. Run after step 1 succeeds.',
cmd:'bash scripts/golive.sh dns' },
{ key:'ns', title:'3. GoDaddy NS swapped to Cloudflare', desc:'Domain still on ns41/42.domaincontrol.com — must switch to the CF nameservers.',
@@ -1041,7 +1041,7 @@ app.get('/designs', (req, res) => {
// Admin review-mode card: sliders, K/R, chips, why-chip
return `
<div class="design-card review-card" data-id="${d.id}">
- <a href="/design/${d.id}" target="_blank" class="card-img" style="background-image:url('${d.image_url}')">
+ <a href="/design/${d.id}" target="_blank" class="card-img" style="background-image:url('${d.image_url}')" rel="noopener noreferrer">
<span class="decision-badge"></span>
</a>
<div class="card-meta">
@@ -1850,7 +1850,7 @@ ${_isAdmin ? `
? '<div class="pair-thumb" style="background-image:url('+s.thumb_url+')"></div>'
: '<div class="pair-thumb material">◧</div>';
return '<div class="pair-card' + pinned + '" data-pin-key="' + escapeAttr(s.pin_key) + '">' +
- '<a class="pair-link" href="/design/' + s.design_id + '" target="_blank">' + thumb +
+ '<a class="pair-link" href="/design/' + s.design_id + '" target="_blank" rel="noopener noreferrer">' + thumb +
'<div class="pair-body"><span class="pair-type">design · #' + s.design_id + '</span>' +
'<div class="pair-title">' + escapeHTML(s.label) + (s.hex ? '<span class="pair-hexcode">'+s.hex+'</span>' : '') + '</div>' +
'<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></a>' + pinBtn + '</div>';
@@ -2092,7 +2092,7 @@ ${_isAdmin ? `
const closenessTxt = d.closeness ? ' (' + d.closeness.band + ')' : '';
const t = escapeAttr((d.title || '#' + d.id) + ' · ΔE ' + (d.deltaE != null ? d.deltaE : '?') + closenessTxt);
const bandClass = d.closeness ? ' iso-de-' + d.closeness.band : '';
- return '<a href="/design/' + d.id + '" target="_blank" title="' + t + '" style="background-image:url(' + img + ')">' +
+ return '<a href="/design/' + d.id + '" target="_blank" title="' + t + '" style="background-image:url(' + img + ')" rel="noopener noreferrer">' +
'<span class="iso-de' + bandClass + '">ΔE ' + (d.deltaE != null ? d.deltaE.toFixed(1) : '?') + '</span>' +
'</a>';
}).join('');
@@ -3494,7 +3494,7 @@ ${htmlHeader('/designs')}
this.disabled = true;
var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: true });
this.disabled = false;
- if (j.ok) show('Dry-run OK. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank">view log</a>', false);
+ if (j.ok) show('Dry-run OK. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank" rel="noopener noreferrer">view log</a>', false);
else show('Dry-run failed: ' + (j.error||'unknown'), true);
});
@@ -3504,7 +3504,7 @@ ${htmlHeader('/designs')}
this.disabled = true;
var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: false });
this.disabled = false;
- if (j.ok) show('Upload kicked off. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank">view log</a>', false);
+ if (j.ok) show('Upload kicked off. job=' + j.job_id + '. <a href="/api/spoonflower/status/' + j.job_id + '" target="_blank" rel="noopener noreferrer">view log</a>', false);
else show('Upload failed: ' + (j.error||'unknown'), true);
});
@@ -3934,7 +3934,7 @@ ${FOOTER}
'</div>'
: '';
return '<figure style="margin:0;background:var(--paper,#faf8f2);border:1px solid var(--rule,#e5e5e0);position:relative;' + dim + '">' +
- '<a href="' + r.image_path + '" target="_blank" style="display:block;aspect-ratio:1/1;overflow:hidden;background:#000;">' +
+ '<a href="' + r.image_path + '" target="_blank" style="display:block;aspect-ratio:1/1;overflow:hidden;background:#000;" rel="noopener noreferrer">' +
'<img src="' + r.image_path + '" alt="Room with this pattern — ' + tag + '" loading="lazy" style="width:100%;height:100%;object-fit:cover;display:block;" />' +
'</a>' +
adminCtrls +
@@ -4276,7 +4276,7 @@ app.get('/age-themes', (req, res) => {
<div class="credits">
<strong style="color:#d2b15c">v${AGE_THEME_DATA.version}</strong> · validated ${AGE_THEME_DATA.validated_at} ·
<strong style="color:#d2554a">⚠ AI-generated starting point — not user-tested, not accessibility-tooled.</strong>
- Recommendations from the <span class="src">ui-ux-designer</span> subagent (font/hex/size swaps with research citations) and reviewed by the <span class="src">debate-team-fast</span> 3-LLM panel (Claude, Kimi K2.5, Qwen3:14b — Codex quota-blocked). Companion <span class="src">Figma file</span> at <a href="https://www.figma.com/design/WmwSirHywv530eACPtbqyA" target="_blank" style="color:#d2b15c">figma.com/design/WmwSirHywv530eACPtbqyA</a>. Canonical band data: <code style="color:#d2b15c">~/Projects/wallco-ai/src/bands.json</code> (skill mirror: <code style="color:#666">~/.claude/skills/age-adaptive-theme/bands.json</code>) · APCA module: <code style="color:#d2b15c">~/Projects/wallco-ai/src/apca.js</code>.
+ Recommendations from the <span class="src">ui-ux-designer</span> subagent (font/hex/size swaps with research citations) and reviewed by the <span class="src">debate-team-fast</span> 3-LLM panel (Claude, Kimi K2.5, Qwen3:14b — Codex quota-blocked). Companion <span class="src">Figma file</span> at <a href="https://www.figma.com/design/WmwSirHywv530eACPtbqyA" target="_blank" style="color:#d2b15c" rel="noopener noreferrer nofollow">figma.com/design/WmwSirHywv530eACPtbqyA</a>. Canonical band data: <code style="color:#d2b15c">~/Projects/wallco-ai/src/bands.json</code> (skill mirror: <code style="color:#666">~/.claude/skills/age-adaptive-theme/bands.json</code>) · APCA module: <code style="color:#d2b15c">~/Projects/wallco-ai/src/apca.js</code>.
<br><span style="color:#888">Debate-team consensus: Atkinson Hyperlegible / tween indigo / Nunito picks are defensible. The fg #222→#1a1a1a (toddler) and -apple-system→Inter (adult) swaps were LLM "hallucinated precision" — dropped. APCA Lc values shown are calculated by src/apca.js (Myndex 0.1.9), NOT lab-verified. v9 fixes from validation-loop: teen body 16px, line-height 1.5 baseline, btn-min-height 44/48/56px per band.</span>
</div>
</header>
@@ -4329,7 +4329,7 @@ app.get('/moodboard', (req, res) => {
return `
<section class="mb-design-block">
<header class="mb-design-header">
- <a class="mb-design-thumb" href="/design/${design.id}" target="_blank" style="background-image:url('${design.image_url || ''}')"></a>
+ <a class="mb-design-thumb" href="/design/${design.id}" target="_blank" style="background-image:url('${design.image_url || ''}')" rel="noopener noreferrer"></a>
<div class="mb-design-info">
<h2 class="mb-design-title">${escHTML(design.title || design.prompt || '#'+design.id)}</h2>
<p class="mb-design-meta">${escHTML(design.category||'')} · <span class="mb-design-dot" style="background:${design.dominant_hex||'#888'}"></span> ${escHTML(design.dominant_hex||'')}${review.decision?' · <strong style="color:'+(review.decision==='keep'?'#5fbf6e':'#d2554a')+'">'+review.decision.toUpperCase()+'</strong>':''}</p>
@@ -5024,7 +5024,7 @@ ${htmlHeader('')}
<div style="padding:10px 12px;font-size:12px">
<div style="font-weight:500">${(p.title||'(no title)').slice(0,48)}</div>
<div style="color:#888;margin-top:2px">SF#${p.sf_design_id}${p.file_bytes ? ' · '+Math.round(p.file_bytes/1024)+' KB' : ''}</div>
- <a href="${p.design_url}" target="_blank" style="color:#888;font-size:11px">view on spoonflower →</a>
+ <a href="${p.design_url}" target="_blank" style="color:#888;font-size:11px" rel="noopener noreferrer nofollow">view on spoonflower →</a>
</div>
</div>`).join('')}
</div>`}
@@ -5043,7 +5043,7 @@ ${htmlHeader('')}
<td><span style="padding:2px 6px;border-radius:3px;background:${j.status==='done'?'#d4edda':j.status==='error'?'#f8d7da':'#fff3cd'};color:${j.status==='done'?'#155724':j.status==='error'?'#721c24':'#856404'}">${j.status}</span></td>
<td>${j.dry_run ? 'yes' : '—'}</td>
<td style="color:#888">${j.created_at ? new Date(j.created_at).toLocaleString() : '—'}</td>
- <td><a href="/api/spoonflower/status/${j.id}" target="_blank" style="color:#888">log →</a></td>
+ <td><a href="/api/spoonflower/status/${j.id}" target="_blank" style="color:#888" rel="noopener noreferrer">log →</a></td>
</tr>`).join('')}
</tbody></table>`}
</main>
@@ -5086,7 +5086,7 @@ document.getElementById('btn-pull-test').addEventListener('click', async functio
document.getElementById('pull-status').textContent = 'Starting limited pull (3 designs)…';
var j = await post('/api/spoonflower/pull-all', { limit: 3, account: 'dw' });
document.getElementById('pull-status').innerHTML = j.ok
- ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank">view log</a>. Reload in ~60s.'
+ ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank" rel="noopener noreferrer">view log</a>. Reload in ~60s.'
: 'Failed: ' + (j.error||'unknown');
this.disabled=false;
});
@@ -5096,7 +5096,7 @@ document.getElementById('btn-pull-all').addEventListener('click', async function
document.getElementById('pull-status').textContent = 'Starting full pull…';
var j = await post('/api/spoonflower/pull-all', { limit: 0, account: 'dw' });
document.getElementById('pull-status').innerHTML = j.ok
- ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank">view log</a>. Reload page to refresh table.'
+ ? 'Job '+j.job_id+' running — <a href="/api/spoonflower/status/'+j.job_id+'" target="_blank" rel="noopener noreferrer">view log</a>. Reload page to refresh table.'
: 'Failed: ' + (j.error||'unknown');
this.disabled=false;
});
@@ -5611,7 +5611,7 @@ document.getElementById('btn-batch-go').addEventListener('click', async function
}).then(r=>r.json());
if (j.ok && j.design_id) {
var imgUrl = '/designs/img/'+(j.filename || '');
- var html = '<a href="/design/'+j.design_id+'" target="_blank" style="text-decoration:none;color:inherit">'+
+ var html = '<a href="/design/'+j.design_id+'" target="_blank" style="text-decoration:none;color:inherit" rel="noopener noreferrer">'+
'<div style="border:1px solid #444;border-radius:4px;overflow:hidden;background:#0f0e0c">'+
(j.filename ? '<img src="'+imgUrl+'" style="width:100%;aspect-ratio:1;object-fit:cover">' : '<div style="aspect-ratio:1;background:'+(j.dominant_hex||'#222')+'"></div>')+
'<div style="padding:4px 6px;font-size:10px;color:#eee">#'+j.design_id+' '+(j.dominant_hex||'')+'</div>'+
@@ -6563,7 +6563,7 @@ document.getElementById('btn-compose').addEventListener('click', async function(
'<div style="padding:10px 14px;background:#0f0e0c;color:#d2b15c;font-size:11px;letter-spacing:.08em;text-transform:uppercase">Final pattern · #'+j.design_id+'</div>'+
'<div style="display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:0;align-items:stretch">'+
// Big pattern preview (left)
- '<a href="/design/'+j.design_id+'" target="_blank" style="display:block;aspect-ratio:1;background:url(\\''+imgUrl+'\\') center/cover #f2efe8;text-decoration:none;cursor:zoom-in" title="Open detail page"></a>'+
+ '<a href="/design/'+j.design_id+'" target="_blank" style="display:block;aspect-ratio:1;background:url(\\''+imgUrl+'\\') center/cover #f2efe8;text-decoration:none;cursor:zoom-in" title="Open detail page" rel="noopener noreferrer"></a>'+
// Sidebar (right) — prompt + actions
'<div style="padding:18px;background:#fafafa;border-left:1px solid #eee;display:flex;flex-direction:column;gap:12px">'+
'<div>'+
@@ -6572,7 +6572,7 @@ document.getElementById('btn-compose').addEventListener('click', async function(
'</div>'+
'<div id="studio-room-frame" style="border:1px solid #eee;border-radius:6px;aspect-ratio:4/3;background:#fafafa center/cover no-repeat;position:relative;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;overflow:hidden">Room setting loading…</div>'+
'<div style="display:flex;flex-direction:column;gap:8px;margin-top:auto">'+
- '<a href="/design/'+j.design_id+'" target="_blank" class="btn-primary" style="text-align:center">View design →</a>'+
+ '<a href="/design/'+j.design_id+'" target="_blank" class="btn-primary" style="text-align:center" rel="noopener noreferrer">View design →</a>'+
'<a href="'+imgUrl+'" download class="btn-outline" style="text-align:center">Download pattern</a>'+
'<button id="studio-delete-btn" data-design-id="'+j.design_id+'" class="btn-outline" style="color:#a44;border-color:#d99">Delete this render</button>'+
'</div>'+
@@ -6728,7 +6728,7 @@ async function loadInsights(){
var j = await get('/api/studio/category-insights?cat='+encodeURIComponent(cat));
var div = document.getElementById('insights-body');
var trendsHtml = '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">' + (j.trends||[]).map(function(t){
- return '<a href="'+t.link+'" target="_blank" style="padding:4px 10px;background:#eef6ff;color:#1a5298;border-radius:11px;text-decoration:none;font-size:11px">📈 '+t.term+'</a>';
+ return '<a href="'+t.link+'" target="_blank" style="padding:4px 10px;background:#eef6ff;color:#1a5298;border-radius:11px;text-decoration:none;font-size:11px" rel="noopener noreferrer">📈 '+t.term+'</a>';
}).join('') + '</div>';
var bs = j.best_sellers || [];
var bsHtml = bs.length ? '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px">' + bs.map(function(p){
@@ -6802,7 +6802,7 @@ async function loadMyRenders() {
? '<button onclick="restoreRender(' + r.design_id + ')" style="font-size:10px;padding:4px 8px;background:transparent;color:#5a8;border:1px solid #5a8;border-radius:4px;cursor:pointer">Restore</button>'
: '<button onclick="deleteRender(' + r.design_id + ')" style="font-size:10px;padding:4px 8px;background:transparent;color:#a44;border:1px solid #d99;border-radius:4px;cursor:pointer">Delete</button>';
return '<div style="border:1px solid #eee;border-radius:6px;overflow:hidden;background:#fff;position:relative;' + dim + '">' +
- '<a href="/design/' + r.design_id + '" target="_blank" style="display:block;aspect-ratio:1;background:url(\\'' + img + '\\') center/cover #f2efe8;text-decoration:none;position:relative">' + roomBadge + '</a>' +
+ '<a href="/design/' + r.design_id + '" target="_blank" style="display:block;aspect-ratio:1;background:url(\\'' + img + '\\') center/cover #f2efe8;text-decoration:none;position:relative" rel="noopener noreferrer">' + roomBadge + '</a>' +
'<div style="padding:6px 8px;font-size:10px;color:#666;display:flex;justify-content:space-between;align-items:center;gap:6px">' +
'<span>#' + r.design_id + (r.deleted_at ? ' · deleted' : '') + '</span>' + delBtn +
'</div>' +
@@ -7360,7 +7360,7 @@ ${htmlHeader('')}
<div id="hon-title" style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:22px"></div>
<div id="hon-sub" style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:.06em;margin-top:3px"></div>
</div>
- <a id="hon-view" target="_blank" style="font-size:11px;color:#d2b15c;text-decoration:underline">view full →</a>
+ <a id="hon-view" target="_blank" style="font-size:11px;color:#d2b15c;text-decoration:underline" rel="noopener noreferrer">view full →</a>
</div>
</div>
<div id="hon-buttons" style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-top:18px">
@@ -7522,7 +7522,7 @@ function renderStack(){
card.style.transform = 'translateY(' + (i * 6) + 'px) scale(' + (1 - i*0.03) + ')';
card.innerHTML = '<img src="'+d.image_url+'" alt="'+d.title+'">' +
'<div class="meta"><div><div class="title">'+d.title+'</div><div class="sub">'+(d.category||'')+'</div></div>' +
- '<a href="/design/'+d.id+'" target="_blank" style="font-size:10px;color:#d2b15c;text-decoration:underline">view</a></div>' +
+ '<a href="/design/'+d.id+'" target="_blank" style="font-size:10px;color:#d2b15c;text-decoration:underline" rel="noopener noreferrer">view</a></div>' +
'<div class="stamp stamp-love">LOVE</div><div class="stamp stamp-skip">SKIP</div><div class="stamp stamp-super">★ SUPER</div>';
s.appendChild(card);
if (i === 0) attachDrag(card);
← 8d04faa wallco.ai · Last-Modified + 304 on /sitemap.xml + /robots.tx
·
back to Wallco Ai
·
wallco.ai · /api/designs/search?q=&limit= — lightweight in-m 7d5cb78 →