← back to CelebritySignatures
Celebrity signature upload + commission system: account-owned uploads (private storage, never statically served), tracked download endpoint with append-only commission ledger (50% of $20/download), identity-review admin gate, upload page + earnings dashboard at /upload; CLOSED pre-existing hole: data/{users,sessions}.json were directly fetchable -> 403 guard (TK-10181)
b5a94582e63768fdfe9cc0a2a30365a2f4ba4322 · 2026-08-03 11:43:33 -0700 · Steve Abrams
Files touched
M .gitignoreM public/index.htmlA public/upload-signature.htmlM server.js
Diff
commit b5a94582e63768fdfe9cc0a2a30365a2f4ba4322
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Aug 3 11:43:33 2026 -0700
Celebrity signature upload + commission system: account-owned uploads (private storage, never statically served), tracked download endpoint with append-only commission ledger (50% of $20/download), identity-review admin gate, upload page + earnings dashboard at /upload; CLOSED pre-existing hole: data/{users,sessions}.json were directly fetchable -> 403 guard (TK-10181)
---
.gitignore | 4 ++
public/index.html | 1 +
public/upload-signature.html | 151 +++++++++++++++++++++++++++++++++++++++++++
server.js | 122 +++++++++++++++++++++++++++++++++-
4 files changed, 277 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 6bb551d..fb274bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,7 @@ tmp_theme_cache/
tmp_artists_cache/
tmp_*.png
tmp_*.sh
+data/celebrity-uploads.json
+data/uploads-private/
+data/download-ledger.jsonl
+data/admin-token.txt
diff --git a/public/index.html b/public/index.html
index bcc31b8..5eaa4b2 100644
--- a/public/index.html
+++ b/public/index.html
@@ -79,6 +79,7 @@
<div class="tabs">
<span class="tab on" data-v="grid">Grid</span>
<span class="tab" data-v="collages">Collages</span>
+ <a class="tab" href="/upload" style="text-decoration:none">✍ Upload yours</a>
</div>
<div class="controls" id="controls">
<div><label>Category</label></div>
diff --git a/public/upload-signature.html b/public/upload-signature.html
new file mode 100644
index 0000000..969b5fe
--- /dev/null
+++ b/public/upload-signature.html
@@ -0,0 +1,151 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Upload Your Signature — Celebrity Signatures</title>
+<style>
+ :root { --ink:#1a1a1a; --muted:#6b6b6b; --line:#e6e3dc; --bg:#f7f5f0; --card:#fff; --green:#1d7a36; }
+ * { box-sizing:border-box; }
+ body { margin:0; font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:var(--bg); }
+ header { padding:26px 28px 18px; border-bottom:1px solid var(--line); background:#fff; }
+ h1 { margin:0; font-weight:600; font-size:22px; }
+ h1 a { color:var(--muted); text-decoration:none; font-size:14px; font-weight:400; margin-left:10px; }
+ main { max-width:760px; margin:0 auto; padding:28px 20px 80px; }
+ .card { background:var(--card); border:1px solid var(--line); border-radius:14px; padding:22px 24px; margin-bottom:22px; }
+ .card h2 { margin:0 0 8px; font-size:17px; }
+ .sub { color:var(--muted); font-size:13.5px; }
+ label { display:block; font-size:12px; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin:14px 0 5px; }
+ input[type=text], input[type=email], input[type=password] { width:100%; padding:9px 12px; border:1px solid var(--line); border-radius:8px; font:inherit; }
+ input[type=file] { font:inherit; }
+ .btn { display:inline-block; margin-top:14px; padding:9px 20px; border-radius:999px; border:1px solid var(--ink); background:var(--ink); color:#fff; font:inherit; cursor:pointer; }
+ .btn:disabled { opacity:.5; cursor:default; }
+ .btn.ghost { background:#fff; color:var(--ink); }
+ .msg { margin-top:10px; font-size:13.5px; }
+ .msg.err { color:#b3261e; } .msg.ok { color:var(--green); }
+ .attest { display:flex; gap:9px; align-items:flex-start; margin-top:14px; font-size:13.5px; color:var(--muted); }
+ .attest input { margin-top:3px; }
+ table { width:100%; border-collapse:collapse; margin-top:10px; font-size:13.5px; }
+ th, td { text-align:left; padding:7px 8px; border-bottom:1px solid var(--line); }
+ th { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
+ .st { font-size:11px; padding:2px 9px; border-radius:999px; font-weight:600; }
+ .st-pending { background:#fdf1d8; color:#9a6b00; } .st-approved { background:#e3f3e6; color:#1d7a36; } .st-rejected { background:#fae0df; color:#b3261e; }
+ .total { font-size:15px; margin-top:12px; }
+ .total b { color:var(--green); }
+ .privacy { font-size:12.5px; color:var(--muted); border-top:1px dashed var(--line); margin-top:14px; padding-top:12px; }
+ #preview { max-height:90px; max-width:100%; object-fit:contain; margin-top:10px; display:none; background:#fff; border:1px solid var(--line); border-radius:8px; padding:6px; }
+</style>
+</head>
+<body>
+<header><h1>Upload Your Signature <a href="/">← back to the gallery</a></h1></header>
+<main>
+
+ <div class="card">
+ <h2>Your signature. Your account. Your commission.</h2>
+ <div class="sub">Upload your own signature as a digital file. It is tied to your account and stored privately —
+ the file is <b>never shared, resold, or publicly exposed</b>. Every single download is tracked to your account
+ and earns you a commission (currently <b>50% of a $20 download</b>). Payouts are reconciled from the
+ download ledger.</div>
+ </div>
+
+ <div class="card" id="authCard">
+ <h2 id="authTitle">Sign in or create an account</h2>
+ <div id="authForms">
+ <label>Email</label><input type="email" id="aEmail" autocomplete="email">
+ <label>Password</label><input type="password" id="aPw" autocomplete="current-password">
+ <label>Name (for new accounts)</label><input type="text" id="aName" autocomplete="name">
+ <button class="btn" id="loginBtn">Sign in</button>
+ <button class="btn ghost" id="signupBtn">Create account</button>
+ <div class="msg" id="authMsg"></div>
+ </div>
+ <div id="whoami" style="display:none">
+ <div class="sub">Signed in as <b id="whoEmail"></b> · <a href="#" id="logoutLink">sign out</a></div>
+ </div>
+ </div>
+
+ <div class="card" id="uploadCard" style="display:none">
+ <h2>Upload a signature</h2>
+ <label>Your name (as it should appear)</label>
+ <input type="text" id="celebName" placeholder="e.g. Jane Q. Celebrity">
+ <label>Signature file — PNG, JPEG, or SVG (dark ink on light/transparent background)</label>
+ <input type="file" id="sigFile" accept="image/png,image/jpeg,image/svg+xml">
+ <img id="preview" alt="preview">
+ <div class="attest"><input type="checkbox" id="attest">
+ <span>I attest this is <b>my own signature</b> (or I am legally authorized to license it), and I grant
+ Celebrity Signatures the right to sell tracked downloads with the commission split shown above.
+ Identity is reviewed before anything goes live.</span></div>
+ <button class="btn" id="uploadBtn">Submit for review</button>
+ <div class="msg" id="upMsg"></div>
+ <div class="privacy">Privacy: the raw file lives outside the public web root; it can only leave the server
+ through the tracked download endpoint, which writes a ledger entry crediting your account first.</div>
+ </div>
+
+ <div class="card" id="dashCard" style="display:none">
+ <h2>My signatures & earnings</h2>
+ <table id="dashTable"><thead><tr><th>Name</th><th>Status</th><th>Downloads</th><th>Price</th><th>Cut</th><th>Earned</th></tr></thead><tbody></tbody></table>
+ <div class="total">Total earned: <b id="totalEarned">$0.00</b></div>
+ </div>
+
+</main>
+<script>
+const $ = s => document.querySelector(s);
+const api = (p, opts) => fetch(p, { headers:{'Content-Type':'application/json'}, ...opts }).then(r => r.json());
+
+async function refresh() {
+ const me = await api('/api/auth/me');
+ const on = !!me.user;
+ $('#authForms').style.display = on ? 'none' : '';
+ $('#whoami').style.display = on ? '' : 'none';
+ $('#authTitle').textContent = on ? 'Account' : 'Sign in or create an account';
+ $('#uploadCard').style.display = on ? '' : 'none';
+ $('#dashCard').style.display = on ? '' : 'none';
+ if (on) {
+ $('#whoEmail').textContent = me.user.email;
+ const d = await api('/api/my-signatures');
+ if (d.ok) {
+ $('#dashTable tbody').innerHTML = d.uploads.length ? d.uploads.map(u => `<tr>
+ <td>${u.celebrity_name}</td>
+ <td><span class="st st-${u.status}">${u.status}</span></td>
+ <td>${u.downloads}</td><td>$${u.priceUsd}</td><td>${u.commissionPct}%</td>
+ <td>$${u.earnedUsd.toFixed(2)}</td></tr>`).join('')
+ : '<tr><td colspan="6" class="sub">no uploads yet</td></tr>';
+ $('#totalEarned').textContent = '$' + d.totalEarnedUsd.toFixed(2);
+ }
+ }
+}
+$('#loginBtn').onclick = async () => {
+ const r = await api('/api/auth/login', { method:'POST', body: JSON.stringify({ email:$('#aEmail').value, password:$('#aPw').value }) });
+ $('#authMsg').className = 'msg ' + (r.ok ? 'ok' : 'err'); $('#authMsg').textContent = r.ok ? 'signed in' : (r.error || 'failed');
+ if (r.ok) refresh();
+};
+$('#signupBtn').onclick = async () => {
+ const r = await api('/api/auth/signup', { method:'POST', body: JSON.stringify({ email:$('#aEmail').value, password:$('#aPw').value, name:$('#aName').value }) });
+ $('#authMsg').className = 'msg ' + (r.ok ? 'ok' : 'err'); $('#authMsg').textContent = r.ok ? 'account created' : (r.error || 'failed');
+ if (r.ok) refresh();
+};
+$('#logoutLink').onclick = async (e) => { e.preventDefault(); await api('/api/auth/logout', { method:'POST' }); refresh(); };
+
+let dataUrl = null;
+$('#sigFile').onchange = () => {
+ const f = $('#sigFile').files[0];
+ if (!f) return;
+ const rd = new FileReader();
+ rd.onload = () => { dataUrl = rd.result; $('#preview').src = dataUrl; $('#preview').style.display = 'block'; };
+ rd.readAsDataURL(f);
+};
+$('#uploadBtn').onclick = async () => {
+ $('#upMsg').textContent = '';
+ if (!dataUrl) { $('#upMsg').className='msg err'; $('#upMsg').textContent='choose a signature file first'; return; }
+ if (!$('#attest').checked) { $('#upMsg').className='msg err'; $('#upMsg').textContent='the attestation checkbox is required'; return; }
+ $('#uploadBtn').disabled = true;
+ const r = await api('/api/signature-upload', { method:'POST', body: JSON.stringify({
+ celebrity_name: $('#celebName').value, dataUrl, attest: $('#attest').checked === true }) });
+ $('#uploadBtn').disabled = false;
+ $('#upMsg').className = 'msg ' + (r.ok ? 'ok' : 'err');
+ $('#upMsg').textContent = r.ok ? ('submitted — status: ' + r.status) : (r.error || 'failed');
+ if (r.ok) { $('#celebName').value=''; $('#sigFile').value=''; $('#preview').style.display='none'; dataUrl=null; $('#attest').checked=false; refresh(); }
+};
+refresh();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 6193e68..f20e61f 100644
--- a/server.js
+++ b/server.js
@@ -3,7 +3,7 @@
// Adds lightweight email/password accounts (scrypt + cookie sessions, JSON-backed)
// so a visitor can SAVE a mural placement they set in the wall studio.
import { createServer } from 'node:http';
-import { readFile, writeFile } from 'node:fs/promises';
+import { readFile, writeFile, appendFile, mkdir } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { scryptSync, randomBytes, timingSafeEqual, createHash } from 'node:crypto';
@@ -30,6 +30,34 @@ function readBody(req) {
});
}
function sendJSON(res, code, obj, headers = {}) { res.writeHead(code, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); }
+// larger body reader for signature uploads (base64 data URLs; ~4MB cap)
+function readBodyBig(req) {
+ return new Promise((resolve, reject) => {
+ let raw = '';
+ req.on('data', c => { raw += c; if (raw.length > 4.5e6) reject(new Error('too large')); });
+ req.on('end', () => { try { resolve(raw ? JSON.parse(raw) : {}); } catch { reject(new Error('bad json')); } });
+ req.on('error', reject);
+ });
+}
+
+// ---- celebrity signature uploads (owned, tracked, NEVER shared raw) ---------
+// Files live in data/uploads-private/ (blocked from static serving); the ONLY
+// way a file leaves the server is the tracked /api/signature-file endpoint,
+// which appends a commission entry to data/download-ledger.jsonl per download.
+const UPLOADS_DIR = join(DATA, 'uploads-private');
+const DEFAULT_PRICE_USD = 20; // download price (payments wired later — Steve-gated)
+const DEFAULT_COMMISSION_PCT = 50; // owner's cut per download
+async function adminToken() {
+ // Bootstrap a local admin token on first use (data/admin-token.txt, gitignored
+ // + deploy-protected). Steve reads it from the file to approve uploads.
+ try { return (await readFile(join(DATA, 'admin-token.txt'), 'utf8')).trim(); }
+ catch {
+ const t = randomBytes(18).toString('hex');
+ await writeFile(join(DATA, 'admin-token.txt'), t + '\n');
+ console.log('upload admin token created → data/admin-token.txt');
+ return t;
+ }
+}
// ---- auth helpers -----------------------------------------------------------
const COOKIE = 'cs_sess';
@@ -67,6 +95,13 @@ createServer(async (req, res) => {
let path = decodeURIComponent(url.pathname);
const M = req.method;
+ // ===== sensitive-path guard =====
+ // data/ (accounts, sessions, ledgers, private uploads) and scripts/ are
+ // NEVER directly fetchable; public JSON flows only through /api/* routes.
+ if (/^\/(data|scripts|tmp_|\.)/.test(path) || path.includes('..')) {
+ res.writeHead(403, { 'Content-Type': 'text/plain' }).end('forbidden'); return;
+ }
+
// ===== AUTH =====
if (path === '/api/auth/signup' && M === 'POST') {
const b = await readBody(req);
@@ -140,6 +175,90 @@ createServer(async (req, res) => {
return sendJSON(res, 200, { ok: true, id });
}
+ // ===== CELEBRITY SIGNATURE UPLOADS (owned + commission-tracked) =====
+ // A celebrity uploads THEIR OWN signature as a digital file. The file is
+ // tied to their account, stored privately, and every download is logged to
+ // an append-only ledger that accrues their commission. Raw files are never
+ // shared or statically served — the tracked endpoint is the only exit.
+ if (path === '/api/signature-upload' && M === 'POST') {
+ const u = await currentUser(req);
+ if (!u) return sendJSON(res, 401, { ok: false, error: 'sign in to upload your signature' });
+ const b = await readBodyBig(req);
+ const name = String(b.celebrity_name || '').trim().slice(0, 120);
+ if (name.length < 2) return sendJSON(res, 400, { ok: false, error: 'name required' });
+ if (b.attest !== true) return sendJSON(res, 400, { ok: false, error: 'you must attest this is your own signature (or you are authorized to license it)' });
+ const m = String(b.dataUrl || '').match(/^data:(image\/(png|jpeg|svg\+xml));base64,([A-Za-z0-9+/=]+)$/);
+ if (!m) return sendJSON(res, 400, { ok: false, error: 'file must be a PNG, JPEG, or SVG data URL' });
+ const buf = Buffer.from(m[3], 'base64');
+ if (buf.length < 500 || buf.length > 3e6) return sendJSON(res, 400, { ok: false, error: 'file must be 0.5KB–3MB' });
+ const ext = m[2] === 'svg+xml' ? 'svg' : m[2];
+ const id = 'up_' + randomBytes(8).toString('hex');
+ await mkdir(UPLOADS_DIR, { recursive: true });
+ await writeFile(join(UPLOADS_DIR, `${id}.${ext}`), buf);
+ const ups = await load('celebrity-uploads.json', []);
+ ups.push({
+ id, userId: u.id, ownerEmail: u.email, celebrity_name: name,
+ file: `${id}.${ext}`, mime: m[1], bytes: buf.length,
+ at: new Date().toISOString(), status: 'pending',
+ priceUsd: DEFAULT_PRICE_USD, commissionPct: DEFAULT_COMMISSION_PCT, downloads: 0,
+ });
+ await store('celebrity-uploads.json', ups);
+ return sendJSON(res, 200, { ok: true, id, status: 'pending', note: 'submitted for identity review — you will see it in your dashboard' });
+ }
+ if (path === '/api/my-signatures' && M === 'GET') {
+ const u = await currentUser(req);
+ if (!u) return sendJSON(res, 401, { ok: false, error: 'sign in' });
+ const ups = (await load('celebrity-uploads.json', [])).filter(x => x.userId === u.id);
+ const mine = ups.map(({ file, userId, ...pub }) => ({
+ ...pub,
+ earnedUsd: +(pub.downloads * pub.priceUsd * pub.commissionPct / 100).toFixed(2),
+ }));
+ return sendJSON(res, 200, { ok: true, uploads: mine, totalEarnedUsd: +mine.reduce((s, x) => s + x.earnedUsd, 0).toFixed(2) });
+ }
+ if (path === '/api/celebrity-signatures' && M === 'GET') {
+ // public directory of APPROVED uploads — metadata only, no file paths
+ const ups = await load('celebrity-uploads.json', []);
+ return sendJSON(res, 200, ups.filter(x => x.status === 'approved')
+ .map(x => ({ id: x.id, celebrity_name: x.celebrity_name, downloads: x.downloads, priceUsd: x.priceUsd, at: x.at })));
+ }
+ const fm = path.match(/^\/api\/signature-file\/(up_[a-f0-9]+)$/);
+ if (fm && M === 'GET') {
+ const ups = await load('celebrity-uploads.json', []);
+ const x = ups.find(e => e.id === fm[1]);
+ if (!x || x.status !== 'approved') return sendJSON(res, 404, { ok: false, error: 'not available' });
+ const dl = await currentUser(req); // downloader identity when signed in
+ const commission = +(x.priceUsd * x.commissionPct / 100).toFixed(2);
+ x.downloads++;
+ await store('celebrity-uploads.json', ups);
+ await appendFile(join(DATA, 'download-ledger.jsonl'), JSON.stringify({
+ ts: new Date().toISOString(), uploadId: x.id, ownerEmail: x.ownerEmail,
+ downloader: dl ? dl.email : null, priceUsd: x.priceUsd, commissionUsd: commission,
+ }) + '\n');
+ const buf = await readFile(join(UPLOADS_DIR, x.file));
+ res.writeHead(200, {
+ 'Content-Type': x.mime,
+ 'Content-Disposition': `attachment; filename="${x.celebrity_name.replace(/[^a-zA-Z0-9 _-]/g, '')} signature.${extname(x.file).slice(1)}"`,
+ });
+ return res.end(buf);
+ }
+ // admin review (token from data/admin-token.txt, never in git or rsync)
+ if (path === '/api/admin/signature-uploads' && M === 'GET') {
+ if (url.searchParams.get('token') !== await adminToken()) return sendJSON(res, 403, { ok: false });
+ return sendJSON(res, 200, { ok: true, uploads: await load('celebrity-uploads.json', []) });
+ }
+ if (path === '/api/admin/signature-review' && M === 'POST') {
+ const b = await readBody(req);
+ if (b.token !== await adminToken()) return sendJSON(res, 403, { ok: false });
+ const ups = await load('celebrity-uploads.json', []);
+ const x = ups.find(e => e.id === b.id);
+ if (!x) return sendJSON(res, 404, { ok: false, error: 'no such upload' });
+ if (!['approve', 'reject'].includes(b.action)) return sendJSON(res, 400, { ok: false, error: 'action approve|reject' });
+ x.status = b.action === 'approve' ? 'approved' : 'rejected';
+ x.reviewedAt = new Date().toISOString();
+ await store('celebrity-uploads.json', ups);
+ return sendJSON(res, 200, { ok: true, id: x.id, status: x.status });
+ }
+
// ===== signatures feed = hand-curated catalog + authoritative Artists set =====
// artists.json is the SINGLE AUTHORITY for the Artists category (occupation-
// filtered to real artists, famous-first — see scripts/filter-artists.mjs).
@@ -170,6 +289,7 @@ createServer(async (req, res) => {
// ===== static + page routes =====
if (path === '/') path = '/public/index.html';
if (path === '/murals') path = '/public/murals.html';
+ if (path === '/upload') path = '/public/upload-signature.html';
if (path === '/account.js') path = '/public/account.js';
if (path === '/api/murals-catalog') path = '/data/murals-catalog.json';
if (path === '/api/signature-evolution') path = '/data/signature-evolution.json';
← b6b478a Museum expansion final: 1262 artists (Cleveland 219, MFA Bos
·
back to CelebritySignatures
·
Five-Horsemen UX pass (ui-ux + graphic-designer panel verdic 03c2beb →