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