View-fraud protection + real ad placement surfaces

Earning views now run on single-use server-issued tokens: the view only
counts when the full dwell elapses on the server clock; instant, forged,
replayed, and stale posts are all rejected, and the client countdown
pauses whenever the tab loses visibility or focus. New placements: login
ads on the sign-in screen (the per-day format's real home), a banner slot
in the back-office Overview, a text slot in the member sidebar, and a
member-ads banner on the homepage. Assets v=20260905g.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 06:15:27 -05:00
parent 7331eac244
commit 28ed925443
7 changed files with 79 additions and 27 deletions
+27
View File
@@ -7,6 +7,7 @@
const http = require('http');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const chain = require('./chain');
const auth = require('./auth');
@@ -34,6 +35,8 @@ function chatLimited(ip) {
}
// magic-code sign-in: emailLower -> {code, exp, tries}
const emailCodes = new Map();
// earn-view tokens: emailLower -> {token, ts} (one live token per member)
const earnTokens = new Map();
async function boot() {
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
chain.init({ onEvent: ev => pushFeed(ev) });
@@ -351,9 +354,33 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await ads.viewStatus(s.email));
}
// fraud-guarded view flow: the server issues a single-use token when it
// serves the ad, and only counts the view if the dwell elapsed on the
// SERVER clock. Client-side focus tracking pauses the countdown; this is
// the floor a script cannot cheat past.
if (p === '/api/my/earnview' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const status = await ads.viewStatus(s.email);
if (status.views >= status.target || status.claimed) return json(res, 200, { ad: null, status });
const type = String(u.searchParams.get('type') || 'banner');
const ad = await ads.serve(type === 'text' ? 'text' : 'banner');
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 });
}
if (p === '/api/my/adview' && 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 = earnTokens.get(s.email);
const dwellMs = (ads.rates().viewDwellSeconds || 5) * 1000;
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That view did not check out. Load the next ad and let it finish.' });
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.' }); }
earnTokens.delete(s.email); // single use
return json(res, 200, await ads.recordView(s.email));
}
if (p === '/api/my/claim' && req.method === 'POST') {