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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user