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
+24 -9
View File
@@ -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 = '<span class="muted small">No member ads are live in rotation right now. '
+ 'Views resume the moment a campaign is running.</span>';
if (!r || !r.ad) {
await earnRefresh();
box.innerHTML = '<span class="muted small">' + (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.') + '</span>';
return;
}
const ad = r.ad;
box.innerHTML = ad.imageUrl
? '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><img src="' + ad.imageUrl + '" alt="member ad" style="max-height:200px"></a>'
: '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow" style="font-size:17px"><b>' + ad.title + '</b>' + (ad.body ? '<br><span class="muted">' + ad.body + '</span>' : '') + '</a>';
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();
})();