Viral welcome tour + line banner + wall, milestone stepper, rich solo editor, CTA-gated reads

- Welcome tour (3 levels x 10s) unlocks welcome credits; line banner in Profile; public /wall/<username>
- 'Your next move' redesigned as a milestone stepper
- Solo composer: BV-style rich editor (H2/H3, inline image+video, undo/redo, raw text)
- Solo read reward now requires clicking through to the advertiser, not just dwelling
- Sanitizer: inline media whitelist + script/style stripped whole

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-06 07:30:49 -05:00
parent 718d7f2d0e
commit 1ee0b5626e
15 changed files with 477 additions and 80 deletions
+86 -1
View File
@@ -31,6 +31,24 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
const uploadCounts = new Map(); // email:day -> uploads today
const gauntletTokens = new Map(); // email -> welcome-tour token (server-clock dwell floor)
// walk the referral chain upward via sponsorRef (code/username/member id)
async function uplineSlides(email, depth = 3) {
const out = [];
let cur = await accounts.byEmail(email);
for (let i = 0; i < depth && cur; i++) {
const ref = String(cur.sponsorRef || '').trim().toLowerCase();
if (!ref) break;
let s = null;
if (/^\d+$/.test(ref)) s = await accounts.byMemberId(Number(ref));
if (!s) s = await accounts.byCode(ref);
if (!s) s = await accounts.byUsername(ref);
if (!s || s.email === cur.email) break;
out.push(s);
cur = s;
}
return out;
}
const chatHits = new Map();
function chatLimited(ip) {
const now = Date.now(), rec = chatHits.get(ip);
@@ -424,7 +442,14 @@ const server = http.createServer(async (req, res) => {
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
if (out.email) out.welcomeCredits = await ads.grantWelcome(out.email); // idempotent lazy grant
if (out.email) {
// welcome credits unlock via the welcome tour when an upline with a
// line banner exists; members with no tour to walk get them instantly
const welcomed = await ads.welcomeGranted(out.email);
const tour = welcomed ? [] : (await uplineSlides(out.email)).filter(a => a.lineTargetUrl);
if (welcomed || !tour.length) out.welcomeCredits = await ads.grantWelcome(out.email);
else { out.welcomeCredits = 0; out.gauntletPending = true; }
}
if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too
if (memberId) {
try {
@@ -538,6 +563,58 @@ const server = http.createServer(async (req, res) => {
const r = await ads.claimDaily(s.email);
return json(res, r.error ? 400 : 200, r);
}
// -- line banner: the member's viral slot on welcome tours + their wall
if (p === '/api/my/linebanner' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const target = String(b.targetUrl || '').trim();
if (!/^https?:\/\/[^\s]+$/i.test(target)) return json(res, 400, { error: 'Destination URL must start with http(s)://' });
const fc = await frameCheck(target); // welcome tours frame it full screen
if (!fc.ok) return json(res, 400, { error: fc.reason });
const banner = String(b.bannerUrl || '').trim();
if (banner && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(banner))
return json(res, 400, { error: 'Banner must be an uploaded image or an https image URL.' });
const r = await accounts.setLineBanner(s.email, banner || null, target);
return json(res, r.error ? 400 : 200, r);
}
// -- welcome tour (gauntlet): meet the 3-level upline, then unlock welcome credits
if (p === '/api/my/gauntlet' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
if (await ads.welcomeGranted(s.email)) return json(res, 200, { pending: false });
const slides = (await uplineSlides(s.email)).filter(a => a.lineTargetUrl)
.map((a, i) => ({ name: a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : 'a member',
bannerUrl: a.lineBannerUrl || null, targetUrl: a.lineTargetUrl }));
if (!slides.length) return json(res, 200, { pending: false });
const token = crypto.randomBytes(16).toString('hex');
gauntletTokens.set(s.email, { token, ts: Date.now(), n: slides.length });
return json(res, 200, { pending: true, slides, dwell: 10, token });
}
if (p === '/api/my/gauntlet/complete' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const b = await readBody(req);
const t = gauntletTokens.get(s.email);
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That tour is no longer open. Reload and try again.' });
if (Date.now() - t.ts < t.n * 10 * 1000 - 1500) return json(res, 400, { error: 'Give each site its ten seconds first.' });
gauntletTokens.delete(s.email);
await ads.grantWelcome(s.email);
return json(res, 200, { ok: true, credited: ads.rates().welcomeCredits || 0 });
}
// -- public banner wall
m = /^\/api\/wall\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m && req.method === 'GET') {
const tok = m[1].toLowerCase();
let a = await accounts.byUsername(tok);
if (!a) a = await accounts.byCode(tok);
if (!a) return json(res, 404, { error: 'No wall under that name.' });
const ladder = [a, ...await uplineSlides(a.email, 2)].slice(0, 3)
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl || null }));
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
joinUrl: '/join/' + (a.username || a.code), ladder });
}
// -- onsite solo ads: member inbox with read rewards
if (p === '/api/my/inbox' && req.method === 'GET') {
const s = await auth.fromRequest(req);
@@ -587,6 +664,13 @@ const server = http.createServer(async (req, res) => {
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
}
m = /^\/api\/my\/inbox\/(\d+)\/visit$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.markSoloVisit(s.email, m[1]);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/inbox\/(\d+)\/claim$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
@@ -698,6 +782,7 @@ const server = http.createServer(async (req, res) => {
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1]));
if (/^\/tx\/0x[0-9a-fA-F]{64}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'tx.html'));
if (/^\/wall\/[A-Za-z0-9_]{1,20}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'wall.html'));
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
const file = path.join(PUBLIC_DIR, safe);
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);