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