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:
@@ -133,6 +133,7 @@
|
||||
recalc();
|
||||
}
|
||||
|
||||
IAP.adSlot('banner', 'adSlotHome');
|
||||
loadLadder();
|
||||
loadStats();
|
||||
loadTicker();
|
||||
|
||||
+24
-9
@@ -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();
|
||||
})();
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<title>The contract | InstantAdPay</title>
|
||||
<meta name="description" content="Plain-language review of the InstantAdPay settlement contract: what it does, what nobody can change, what the operator can and cannot touch, and how to verify all of it yourself.">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905f">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
@@ -129,8 +129,8 @@
|
||||
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260905f"></script>
|
||||
<script src="/assets/contract.js?v=20260905f"></script>
|
||||
<script src="/assets/chat.js?v=20260905f"></script>
|
||||
<script src="/assets/common.js?v=20260905g"></script>
|
||||
<script src="/assets/contract.js?v=20260905g"></script>
|
||||
<script src="/assets/chat.js?v=20260905g"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+11
-5
@@ -5,7 +5,7 @@
|
||||
<title>InstantAdPay: advertise and earn, locked in code</title>
|
||||
<meta name="description" content="Real ad packages with instant on-chain settlement. Every purchase pays the sponsor line in the same transaction, verifiable by anyone on the live ledger.">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905f">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -344,6 +344,12 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="card" id="adSlotHome" hidden style="text-align:center"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="wrap">
|
||||
<div class="sectionhead">
|
||||
@@ -399,9 +405,9 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script src="/assets/common.js?v=20260905f"></script>
|
||||
<script src="/assets/wallet.js?v=20260905f"></script>
|
||||
<script src="/assets/home.js?v=20260905f"></script>
|
||||
<script src="/assets/chat.js?v=20260905f"></script>
|
||||
<script src="/assets/common.js?v=20260905g"></script>
|
||||
<script src="/assets/wallet.js?v=20260905g"></script>
|
||||
<script src="/assets/home.js?v=20260905g"></script>
|
||||
<script src="/assets/chat.js?v=20260905g"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+4
-4
@@ -5,7 +5,7 @@
|
||||
<title>Live ledger | InstantAdPay</title>
|
||||
<meta name="description" content="Every purchase, payout, and pass-up on InstantAdPay, streamed straight from the blockchain with a verify link on every line.">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905f">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
@@ -25,8 +25,8 @@
|
||||
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/assets/common.js?v=20260905f"></script>
|
||||
<script src="/assets/ledger.js?v=20260905f"></script>
|
||||
<script src="/assets/chat.js?v=20260905f"></script>
|
||||
<script src="/assets/common.js?v=20260905g"></script>
|
||||
<script src="/assets/ledger.js?v=20260905g"></script>
|
||||
<script src="/assets/chat.js?v=20260905g"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+8
-5
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Member area | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905f">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905g">
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<p class="small muted">Crypto-native? You can also <a href="#" id="walletSigninLink">sign in with just your wallet</a>.
|
||||
One free signature, no email needed.</p>
|
||||
<p class="small muted"><a href="/">← Back to the site</a></p>
|
||||
<div class="card" id="adSlotLogin" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -60,6 +61,7 @@
|
||||
<button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button>
|
||||
<button data-pane="wallet" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18"/><circle cx="16.5" cy="14.5" r="1.4"/></svg>Wallet</button>
|
||||
</nav>
|
||||
<div class="bo-links" id="adSlotSide" hidden style="border-top:1px solid var(--line)"></div>
|
||||
<div class="bo-links">
|
||||
<span class="bo-cap">Site</span>
|
||||
<a href="/">Ad packages</a>
|
||||
@@ -116,6 +118,7 @@
|
||||
<p id="qualLine" class="muted small">…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" id="adSlotOverview" hidden></div>
|
||||
</div>
|
||||
|
||||
<div class="pane" id="pane-line" hidden>
|
||||
@@ -250,9 +253,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260905f"></script>
|
||||
<script src="/assets/wallet.js?v=20260905f"></script>
|
||||
<script src="/assets/my.js?v=20260905f"></script>
|
||||
<script src="/assets/chat.js?v=20260905f"></script>
|
||||
<script src="/assets/common.js?v=20260905g"></script>
|
||||
<script src="/assets/wallet.js?v=20260905g"></script>
|
||||
<script src="/assets/my.js?v=20260905g"></script>
|
||||
<script src="/assets/chat.js?v=20260905g"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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