From 28ed925443e31f5219dc78ab6f5d23327826c8d4 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 5 Sep 2026 06:15:27 -0500 Subject: [PATCH] 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 --- public/assets/home.js | 1 + public/assets/my.js | 33 ++++++++++++++++++++++++--------- public/contract.html | 8 ++++---- public/index.html | 16 +++++++++++----- public/ledger.html | 8 ++++---- public/my.html | 13 ++++++++----- server.js | 27 +++++++++++++++++++++++++++ 7 files changed, 79 insertions(+), 27 deletions(-) diff --git a/public/assets/home.js b/public/assets/home.js index 9b8234e..76ec918 100644 --- a/public/assets/home.js +++ b/public/assets/home.js @@ -133,6 +133,7 @@ recalc(); } + IAP.adSlot('banner', 'adSlotHome'); loadLadder(); loadStats(); loadTicker(); diff --git a/public/assets/my.js b/public/assets/my.js index 4771e15..6bd9dfd 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -286,33 +286,42 @@ } catch (e) { return null; } } async function earnShowAd() { - const st = await earnRefresh(); - if (!st || st.views >= st.target) return; const type = earnState.types[earnState.i++ % earnState.types.length]; - let ad = null; - try { ad = (await (await fetch('/api/ads/slot?type=' + type)).json()).ad; } catch (e) {} + let r = null; + try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {} const box = $('earnAdBox'); - if (!ad) { - box.innerHTML = 'No member ads are live in rotation right now. ' - + 'Views resume the moment a campaign is running.'; + if (!r || !r.ad) { + await earnRefresh(); + box.innerHTML = '' + (r && r.status && r.status.views >= r.status.target + ? 'Set complete for today.' + : 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + ''; return; } + const ad = r.ad; box.innerHTML = ad.imageUrl ? 'member ad' : '' + ad.title + '' + (ad.body ? '
' + ad.body + '' : '') + '
'; const btn = $('earnNextBtn'); btn.hidden = false; btn.disabled = true; - let left = st.dwell; + // the countdown only runs while this tab is visible AND focused; the + // server separately enforces the full dwell on its own clock + let left = r.status.dwell; btn.textContent = 'Next ad (' + left + 's)'; clearInterval(earnState.timer); earnState.timer = setInterval(async () => { + if (document.visibilityState !== 'visible' || !document.hasFocus()) { + btn.textContent = 'Paused: stay on this tab (' + left + 's)'; + return; + } left -= 1; if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; } clearInterval(earnState.timer); btn.textContent = 'Next ad'; + const v = await (await fetch('/api/my/adview', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: r.token }) })).json(); + if (v.error) { IAP.status(v.error, 'bad'); btn.disabled = false; return; } btn.disabled = false; - await fetch('/api/my/adview', { method: 'POST' }); // dwell served: count it await earnRefresh(); }, 1000); } @@ -406,5 +415,11 @@ await render(); }); + // ad surfaces: login ads greet the sign-in screen; members see live + // banner + text placements inside the back office (they ARE the audience) + IAP.adSlot('login', 'adSlotLogin'); + IAP.adSlot('banner', 'adSlotOverview'); + IAP.adSlot('text', 'adSlotSide'); + render(); })(); diff --git a/public/contract.html b/public/contract.html index e4da309..ff452d9 100644 --- a/public/contract.html +++ b/public/contract.html @@ -5,7 +5,7 @@ The contract | InstantAdPay - +
@@ -129,8 +129,8 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
- - - + + + diff --git a/public/index.html b/public/index.html index 64f1fba..43f270e 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ InstantAdPay: advertise and earn, locked in code - + @@ -344,6 +344,12 @@ +
+
+ +
+
+
@@ -399,9 +405,9 @@
- - - - + + + + diff --git a/public/ledger.html b/public/ledger.html index 3d554ca..19a36a6 100644 --- a/public/ledger.html +++ b/public/ledger.html @@ -5,7 +5,7 @@ Live ledger | InstantAdPay - +
@@ -25,8 +25,8 @@
InstantAdPay · how it works · contract source ↗
- - - + + + diff --git a/public/my.html b/public/my.html index fe345af..7e390b7 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -44,6 +44,7 @@

Crypto-native? You can also sign in with just your wallet. One free signature, no email needed.

← Back to the site

+ @@ -60,6 +61,7 @@ + + - - - - + + + + diff --git a/server.js b/server.js index 2c40159..b6591de 100644 --- a/server.js +++ b/server.js @@ -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') {