Earn viewer v2: full-screen URL visits with countdown, human check, explicit return

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 07:23:30 -05:00
parent fee86d296a
commit 15b036b940
7 changed files with 238 additions and 47 deletions
+43 -3
View File
@@ -37,6 +37,9 @@ function chatLimited(ip) {
const emailCodes = new Map();
// earn-view tokens: emailLower -> {token, ts} (one live token per member)
const earnTokens = new Map();
// human-check pairs for the view verifier: [emoji shown, word named in the prompt]
const CAPTCHA = [['🚀', 'rocket'], ['⚡', 'lightning bolt'], ['🔑', 'key'], ['🎯', 'target'],
['🌊', 'wave'], ['🔥', 'flame'], ['💎', 'diamond'], ['🧲', 'magnet'], ['🔔', 'bell'], ['🌙', 'moon']];
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)) });
@@ -63,7 +66,7 @@ function siteConfig() {
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2' };
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'";
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:";
function baseHeaders(extra) {
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});
@@ -392,8 +395,36 @@ const server = http.createServer(async (req, res) => {
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', { excludeEmail: s.email });
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
earnTokens.set(s.email, { token, ts: Date.now() });
return json(res, 200, { ad, token, status });
// the viewer tab frames the advertiser's REAL url (no click counted for a paid view)
earnTokens.set(s.email, { token, ts: Date.now(), adId: ad.id,
targetUrl: await ads.targetOf(ad.id), adName: ad.title || ad.name || null });
return json(res, 200, { ad, token, viewUrl: '/view/' + token, status });
}
// the viewer tab asks where to point the frame (does not consume the token)
if (p === '/api/my/viewinfo' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const t = earnTokens.get(s.email);
if (!t || t.token !== String(u.searchParams.get('token') || ''))
return json(res, 400, { error: 'That view is no longer open. Head back to the dashboard and load the next ad.' });
return json(res, 200, { targetUrl: t.targetUrl, adName: t.adName || null,
dwell: ads.rates().viewDwellSeconds || 5 });
}
// human check: handed out only once the dwell has elapsed on the SERVER clock
if (p === '/api/my/viewchallenge' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const t = earnTokens.get(s.email);
if (!t || t.token !== String(u.searchParams.get('token') || ''))
return json(res, 400, { error: 'That view is no longer open.' });
const dwellMs = (ads.rates().viewDwellSeconds || 5) * 1000;
const age = Date.now() - t.ts;
if (age < dwellMs - 400) return json(res, 200, { early: true, wait: Math.ceil((dwellMs - age) / 1000) });
if (age > 5 * 60 * 1000) { earnTokens.delete(s.email); return json(res, 400, { error: 'That ad went stale. Load a fresh one.' }); }
const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5);
const answer = Math.floor(Math.random() * pick.length);
t.challenge = { answer };
return json(res, 200, { prompt: pick[answer][1], options: pick.map(x => x[0]) });
}
if (p === '/api/my/adview' && req.method === 'POST') {
const s = await auth.fromRequest(req);
@@ -405,6 +436,14 @@ const server = http.createServer(async (req, res) => {
const age = Date.now() - t.ts;
if (age < dwellMs - 400) return json(res, 400, { error: 'Watch the full ad first.' });
if (age > 5 * 60 * 1000) { earnTokens.delete(s.email); return json(res, 400, { error: 'That ad went stale. Load a fresh one.' }); }
// the human check must be solved on the same token
if (!t.challenge) return json(res, 400, { error: 'Finish the quick check first.', retry: true });
if (Number(b.answer) !== t.challenge.answer) {
t.attempts = (t.attempts || 0) + 1;
t.challenge = null; // force a fresh challenge for the next try
if (t.attempts >= 3) { earnTokens.delete(s.email); return json(res, 400, { error: 'Three misses — that view is void. Head back and load the next ad.' }); }
return json(res, 400, { error: 'Wrong pick.', retry: true });
}
earnTokens.delete(s.email); // single use
return json(res, 200, await ads.recordView(s.email));
}
@@ -506,6 +545,7 @@ const server = http.createServer(async (req, res) => {
if (p === '/ledger') return sendFile(res, path.join(PUBLIC_DIR, 'ledger.html'));
if (p === '/contract') return sendFile(res, path.join(PUBLIC_DIR, 'contract.html'));
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.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);