Earn viewer v2: full-screen URL visits with countdown, human check, explicit return
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -139,6 +139,10 @@ const J = {
|
||||
this.save();
|
||||
return c.targetUrl;
|
||||
},
|
||||
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
|
||||
const c = this.db.campaigns.find(x => x.id === Number(id));
|
||||
return c ? c.targetUrl : null;
|
||||
},
|
||||
async dailySweep() {
|
||||
const r = rates();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -242,6 +246,10 @@ const D = {
|
||||
await db.q('UPDATE campaigns SET clicks=clicks+1 WHERE id=?', [Number(id)]);
|
||||
return rows[0].target_url;
|
||||
},
|
||||
async targetOf(id) { // resolve without counting a click (earn-view frames the real url)
|
||||
const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]);
|
||||
return rows.length ? rows[0].target_url : null;
|
||||
},
|
||||
async dailySweep() {
|
||||
const r = rates();
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
@@ -415,10 +423,11 @@ async function setStatus(owner, id, status) {
|
||||
}
|
||||
async function serve(type, opts) { return TYPES.includes(type) ? impl().serve(type, opts) : null; }
|
||||
async function click(id) { return impl().click(id); }
|
||||
async function targetOf(id) { return impl().targetOf(id); }
|
||||
async function dailySweep() { return impl().dailySweep(); }
|
||||
async function pendingBurns() { return impl().pendingBurns(); }
|
||||
async function markBurned(id, tx) { return impl().markBurned(id, tx); }
|
||||
|
||||
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
|
||||
serve, click, dailySweep, availableCredits, earnedBalance, grantWelcome,
|
||||
serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome,
|
||||
viewStatus, recordView, claimDaily, pendingBurns, markBurned };
|
||||
|
||||
+3
-1
@@ -33,6 +33,8 @@ const CANNED = [
|
||||
a: 'Join free with just your email at https://instantadpay.com/my, no wallet and no password needed. Your wallet only comes out when you buy a package or switch on payouts, and the site walks you through it.' },
|
||||
{ re: /(referral link|invite link|share link|refer)/i,
|
||||
a: 'You get your share link the moment you sign in, free members included. One tip: switch on payouts (one free wallet step in Members) before your people start buying, because the contract locks each buyer to their sponsor at their first purchase.' },
|
||||
{ re: /((view|watch|see).{0,12}ads?|earn.{0,12}credits?|daily (set|ads|views))/i,
|
||||
a: 'In the Earn credits section of Members, each ad in the daily set opens full screen in its own tab, showing the advertiser\'s real site. A countdown runs while you watch (it pauses if you leave the tab), then you pass a quick click-the-icon check and the view counts. Finish the set, claim your daily credits, and spend them on your own banner or text campaigns. You never see your own ads, and viewer rewards are credits, never cash.' },
|
||||
{ re: /(credit|impression|cpm|what do i get|what am i buying)/i,
|
||||
a: 'Packages mint ad credits on-chain, and every new member also gets a small batch of welcome credits just for joining. One credit is one cent of ad delivery: banners, text ads, and login ads across the network, managed from the campaign manager in Members. Only your campaigns can spend your credits.' },
|
||||
{ re: /(price|cost|package|how much is)/i,
|
||||
@@ -49,7 +51,7 @@ FACTS:
|
||||
- Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits at signup (engine-side bonus credits, separate from on-chain purchased credits).
|
||||
- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery.
|
||||
- Live formats: display banners (per impression), text ads (per impression), login ads (per day). Coming: inbox ads, featured rotation with disclosed rotation size, verified-visit packs.
|
||||
- Members EARN credits by attention: view the daily set of member ads (a few seconds each) in the Earn credits section of Members, then claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash.
|
||||
- Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site.
|
||||
- Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly.
|
||||
- Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires.
|
||||
- Every member gets a share link immediately (site code, /join/<code>); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy.
|
||||
|
||||
+16
-30
@@ -426,47 +426,33 @@
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
async function earnShowAd() {
|
||||
// each view happens full screen in its own tab: /view/<token> frames the
|
||||
// advertiser's site, runs the countdown, then a human check credits it
|
||||
const type = earnState.types[earnState.i++ % earnState.types.length];
|
||||
let r = null;
|
||||
try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {}
|
||||
const box = $('earnAdBox');
|
||||
if (!r || !r.ad) {
|
||||
if (!r || !r.ad || !r.viewUrl) {
|
||||
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;
|
||||
// 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 earnRefresh();
|
||||
}, 1000);
|
||||
const w = window.open(r.viewUrl, '_blank');
|
||||
box.innerHTML = '<span class="muted small">Ad open in its own tab. Watch the countdown, pass the quick '
|
||||
+ 'check, and the view credits itself — this page updates the moment it does.</span>'
|
||||
+ (w ? '' : '<br><a href="' + r.viewUrl + '" target="_blank" rel="noopener">Pop-up blocked — open the ad here.</a>');
|
||||
$('earnStartBtn').textContent = 'View next ad';
|
||||
}
|
||||
$('earnStartBtn').addEventListener('click', () => { $('earnStartBtn').hidden = true; earnShowAd(); });
|
||||
$('earnNextBtn').addEventListener('click', earnShowAd);
|
||||
$('earnStartBtn').addEventListener('click', () => earnShowAd());
|
||||
// the viewer tab pings localStorage when a view credits; refresh instantly
|
||||
window.addEventListener('storage', e => {
|
||||
if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); }
|
||||
});
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh();
|
||||
});
|
||||
$('earnClaimBtn').addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await api('/api/my/claim');
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// Full-screen ad viewer: the advertiser URL fills the tab, a countdown runs in
|
||||
// the top bar (paused whenever this tab loses focus), and once the dwell is
|
||||
// done the server hands out a human check. Solve it and the view credits.
|
||||
// The dwell floor is enforced on the SERVER clock; this UI cannot cheat it.
|
||||
(() => {
|
||||
const $ = id => document.getElementById(id);
|
||||
const token = location.pathname.split('/').pop();
|
||||
let left = 5, timer = null, credited = false, asking = false;
|
||||
const setMsg = t => { $('vMsg').textContent = t; };
|
||||
async function j(url, body) {
|
||||
const r = await fetch(url, body
|
||||
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
|
||||
: undefined);
|
||||
return r.json();
|
||||
}
|
||||
function tick() {
|
||||
if (credited || asking) return;
|
||||
if (document.visibilityState !== 'visible' || !document.hasFocus()) {
|
||||
$('vTimer').classList.add('paused');
|
||||
$('vTimer').textContent = 'paused';
|
||||
setMsg('Come back to this tab to keep the countdown moving.');
|
||||
return;
|
||||
}
|
||||
$('vTimer').classList.remove('paused');
|
||||
left = Math.max(0, left - 0.25);
|
||||
$('vTimer').textContent = Math.ceil(left) + 's left';
|
||||
if (left <= 0) { clearInterval(timer); askChallenge(); }
|
||||
}
|
||||
async function askChallenge() {
|
||||
asking = true;
|
||||
$('vTimer').textContent = 'check';
|
||||
$('vTimer').classList.add('done');
|
||||
setMsg('One quick check to count the view:');
|
||||
let c = await j('/api/my/viewchallenge?token=' + token);
|
||||
if (c.early) { // server clock says not quite yet: wait it out and re-ask
|
||||
await new Promise(r => setTimeout(r, (c.wait || 1) * 1000 + 300));
|
||||
c = await j('/api/my/viewchallenge?token=' + token);
|
||||
}
|
||||
if (c.error) return fail(c.error);
|
||||
renderChallenge(c);
|
||||
}
|
||||
function renderChallenge(c) {
|
||||
$('vPrompt').textContent = 'Click the ' + c.prompt + ':';
|
||||
const w = $('vOpts');
|
||||
w.innerHTML = '';
|
||||
c.options.forEach((em, i) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = em;
|
||||
b.addEventListener('click', () => answer(i));
|
||||
w.appendChild(b);
|
||||
});
|
||||
$('vCheck').classList.add('on');
|
||||
}
|
||||
async function answer(i) {
|
||||
const r = await j('/api/my/adview', { token, answer: i });
|
||||
if (r.error) {
|
||||
if (r.retry) {
|
||||
setMsg('Not that one — try again.');
|
||||
const c = await j('/api/my/viewchallenge?token=' + token);
|
||||
if (!c.error) return renderChallenge(c);
|
||||
return fail(c.error);
|
||||
}
|
||||
return fail(r.error);
|
||||
}
|
||||
credited = true;
|
||||
$('vCheck').classList.remove('on');
|
||||
$('vTimer').textContent = '✓ credited';
|
||||
const st = r.status || r;
|
||||
setMsg('View ' + st.views + ' of ' + st.target + ' counted for today.'
|
||||
+ (st.views >= st.target && !st.claimed ? ' Head back and claim your credits.' : ''));
|
||||
$('vDone').classList.add('on');
|
||||
try { localStorage.setItem('iap-view-done', String(Date.now())); } catch (e) {}
|
||||
}
|
||||
function fail(msg) {
|
||||
credited = true; // stop the loop; this view is over either way
|
||||
if (timer) clearInterval(timer);
|
||||
$('vCheck').classList.remove('on');
|
||||
$('vTimer').textContent = '—';
|
||||
setMsg(msg);
|
||||
$('vDone').classList.add('on');
|
||||
}
|
||||
$('vClose').addEventListener('click', () => {
|
||||
window.close();
|
||||
setMsg('You can close this tab now.');
|
||||
});
|
||||
(async () => {
|
||||
const info = await j('/api/my/viewinfo?token=' + token);
|
||||
if (info.error) {
|
||||
$('vFrame').remove();
|
||||
const d = document.createElement('div');
|
||||
d.className = 'vfail';
|
||||
d.textContent = info.error;
|
||||
document.querySelector('.vw').appendChild(d);
|
||||
$('vDone').classList.add('on');
|
||||
$('vTimer').textContent = '—';
|
||||
setMsg('');
|
||||
return;
|
||||
}
|
||||
left = info.dwell || 5;
|
||||
$('vFrame').src = info.targetUrl;
|
||||
setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.');
|
||||
$('vTimer').textContent = left + 's left';
|
||||
timer = setInterval(tick, 250);
|
||||
})();
|
||||
})();
|
||||
+12
-12
@@ -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=20260905k">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905l">
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
@@ -225,17 +225,17 @@
|
||||
<div class="pane" id="pane-earn" hidden>
|
||||
<div class="card">
|
||||
<h3>Earn credits by viewing ads</h3>
|
||||
<p class="muted small">View today's set of member ads, each for a few seconds, and claim your
|
||||
daily credits. Attention earns advertising here: your earned credits spend on banner and text
|
||||
campaigns, just like purchased ones.</p>
|
||||
<p class="muted small">Each ad opens full screen in its own tab, pointed at the advertiser's
|
||||
real site. A countdown runs while you watch; when it hits zero, pass a quick check and the
|
||||
view counts. Finish today's set, claim your credits, and spend them on banner and text
|
||||
campaigns just like purchased ones.</p>
|
||||
<p><span class="badge" id="earnProgress">…</span>
|
||||
<span class="badge" id="earnBalance">…</span></p>
|
||||
<div class="card" style="background:rgba(4,8,7,.5)" id="earnStage">
|
||||
<div id="earnAdBox" class="small muted" style="min-height:120px;display:grid;place-items:center">
|
||||
Press start to see the first ad.</div>
|
||||
<div id="earnAdBox" class="small muted" style="min-height:80px;display:grid;place-items:center">
|
||||
Press the button to open the first ad in a new tab.</div>
|
||||
<p style="margin-top:14px">
|
||||
<button class="btn" id="earnStartBtn" type="button">Start viewing</button>
|
||||
<button class="btn" id="earnNextBtn" type="button" hidden disabled>Next ad</button>
|
||||
<button class="btn" id="earnStartBtn" type="button">View today's ads</button>
|
||||
<button class="btn" id="earnClaimBtn" type="button" hidden>Claim today's credits</button>
|
||||
</p>
|
||||
<p class="small muted" id="earnHint"></p>
|
||||
@@ -330,9 +330,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260905k"></script>
|
||||
<script src="/assets/wallet.js?v=20260905k"></script>
|
||||
<script src="/assets/my.js?v=20260905k"></script>
|
||||
<script src="/assets/chat.js?v=20260905k"></script>
|
||||
<script src="/assets/common.js?v=20260905l"></script>
|
||||
<script src="/assets/wallet.js?v=20260905l"></script>
|
||||
<script src="/assets/my.js?v=20260905l"></script>
|
||||
<script src="/assets/chat.js?v=20260905l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Viewing ad — InstantAdPay</title>
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905l">
|
||||
<style>
|
||||
html,body{height:100%;margin:0;overflow:hidden}
|
||||
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
|
||||
.vbar{flex:0 0 auto;display:flex;align-items:center;gap:14px;padding:10px 16px;
|
||||
background:var(--panel-solid,#071a12);border-bottom:1px solid var(--line,rgba(67,232,195,.18));flex-wrap:wrap}
|
||||
.vbrand{font-family:var(--disp,sans-serif);font-weight:800;font-size:15px;white-space:nowrap}
|
||||
.vbrand em{color:var(--mint,#43e8c3);font-style:normal}
|
||||
.vtimer{font-family:var(--mono,monospace);font-weight:700;font-size:14px;border:1px solid var(--line-strong,rgba(67,232,195,.32));
|
||||
border-radius:999px;padding:5px 14px;min-width:104px;text-align:center}
|
||||
.vtimer.paused{color:var(--amber,#ffb238);border-color:rgba(255,178,56,.55)}
|
||||
.vtimer.done{color:var(--mint,#43e8c3)}
|
||||
.vmsg{font-size:13px;color:var(--muted,#8ba69c);flex:1;min-width:140px}
|
||||
.vcheck{display:none;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.vcheck.on{display:flex}
|
||||
.vcheck .prompt{font-size:13.5px;font-weight:700}
|
||||
.vcheck button{font-size:20px;line-height:1;background:var(--panel,rgba(9,26,19,.85));color:inherit;
|
||||
border:1px solid var(--line-strong,rgba(67,232,195,.32));border-radius:10px;padding:7px 12px;cursor:pointer;margin-right:6px}
|
||||
.vcheck button:hover,.vcheck button:focus-visible{border-color:var(--mint,#43e8c3);outline:none}
|
||||
.vdone{display:none;gap:8px;align-items:center}
|
||||
.vdone.on{display:flex}
|
||||
.vframe{flex:1;border:0;width:100%;background:#fff}
|
||||
.vfail{flex:1;display:grid;place-items:center;color:var(--muted,#8ba69c);padding:30px;text-align:center;font-size:15px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="vw">
|
||||
<div class="vbar">
|
||||
<span class="vbrand">Instant<em>AdPay</em></span>
|
||||
<span class="vtimer" id="vTimer">…</span>
|
||||
<span class="vmsg" id="vMsg">Loading the ad…</span>
|
||||
<span class="vcheck" id="vCheck"><span class="prompt" id="vPrompt"></span><span id="vOpts"></span></span>
|
||||
<span class="vdone" id="vDone">
|
||||
<button class="btn small" id="vClose" type="button">Close tab</button>
|
||||
<a class="btn small sec" href="/my#earn">Back to dashboard</a>
|
||||
</span>
|
||||
</div>
|
||||
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
|
||||
</div>
|
||||
<script src="/assets/view.js?v=20260905l"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -37,6 +37,9 @@ function chatLimited(ip) {
|
||||
const emailCodes = new Map();
|
||||
// earn-view tokens: emailLower -> {token, ts} (one live token per member)
|
||||
const earnTokens = new Map();
|
||||
// human-check pairs for the view verifier: [emoji shown, word named in the prompt]
|
||||
const CAPTCHA = [['🚀', 'rocket'], ['⚡', 'lightning bolt'], ['🔑', 'key'], ['🎯', 'target'],
|
||||
['🌊', 'wave'], ['🔥', 'flame'], ['💎', 'diamond'], ['🧲', 'magnet'], ['🔔', 'bell'], ['🌙', 'moon']];
|
||||
async function boot() {
|
||||
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
|
||||
chain.init({ onEvent: ev => attachNames([ev]).then(a => pushFeed(a[0])).catch(() => pushFeed(ev)) });
|
||||
@@ -63,7 +66,7 @@ function siteConfig() {
|
||||
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
|
||||
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
||||
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2' };
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'";
|
||||
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https: http:";
|
||||
function baseHeaders(extra) {
|
||||
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
|
||||
'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});
|
||||
@@ -392,8 +395,36 @@ const server = http.createServer(async (req, res) => {
|
||||
const ad = await ads.serve(type === 'text' ? 'text' : 'banner', { excludeEmail: s.email });
|
||||
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 });
|
||||
// the viewer tab frames the advertiser's REAL url (no click counted for a paid view)
|
||||
earnTokens.set(s.email, { token, ts: Date.now(), adId: ad.id,
|
||||
targetUrl: await ads.targetOf(ad.id), adName: ad.title || ad.name || null });
|
||||
return json(res, 200, { ad, token, viewUrl: '/view/' + token, status });
|
||||
}
|
||||
// the viewer tab asks where to point the frame (does not consume the token)
|
||||
if (p === '/api/my/viewinfo' && req.method === 'GET') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const t = earnTokens.get(s.email);
|
||||
if (!t || t.token !== String(u.searchParams.get('token') || ''))
|
||||
return json(res, 400, { error: 'That view is no longer open. Head back to the dashboard and load the next ad.' });
|
||||
return json(res, 200, { targetUrl: t.targetUrl, adName: t.adName || null,
|
||||
dwell: ads.rates().viewDwellSeconds || 5 });
|
||||
}
|
||||
// human check: handed out only once the dwell has elapsed on the SERVER clock
|
||||
if (p === '/api/my/viewchallenge' && req.method === 'GET') {
|
||||
const s = await auth.fromRequest(req);
|
||||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||
const t = earnTokens.get(s.email);
|
||||
if (!t || t.token !== String(u.searchParams.get('token') || ''))
|
||||
return json(res, 400, { error: 'That view is no longer open.' });
|
||||
const dwellMs = (ads.rates().viewDwellSeconds || 5) * 1000;
|
||||
const age = Date.now() - t.ts;
|
||||
if (age < dwellMs - 400) return json(res, 200, { early: true, wait: Math.ceil((dwellMs - age) / 1000) });
|
||||
if (age > 5 * 60 * 1000) { earnTokens.delete(s.email); return json(res, 400, { error: 'That ad went stale. Load a fresh one.' }); }
|
||||
const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5);
|
||||
const answer = Math.floor(Math.random() * pick.length);
|
||||
t.challenge = { answer };
|
||||
return json(res, 200, { prompt: pick[answer][1], options: pick.map(x => x[0]) });
|
||||
}
|
||||
if (p === '/api/my/adview' && req.method === 'POST') {
|
||||
const s = await auth.fromRequest(req);
|
||||
@@ -405,6 +436,14 @@ const server = http.createServer(async (req, res) => {
|
||||
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.' }); }
|
||||
// the human check must be solved on the same token
|
||||
if (!t.challenge) return json(res, 400, { error: 'Finish the quick check first.', retry: true });
|
||||
if (Number(b.answer) !== t.challenge.answer) {
|
||||
t.attempts = (t.attempts || 0) + 1;
|
||||
t.challenge = null; // force a fresh challenge for the next try
|
||||
if (t.attempts >= 3) { earnTokens.delete(s.email); return json(res, 400, { error: 'Three misses — that view is void. Head back and load the next ad.' }); }
|
||||
return json(res, 400, { error: 'Wrong pick.', retry: true });
|
||||
}
|
||||
earnTokens.delete(s.email); // single use
|
||||
return json(res, 200, await ads.recordView(s.email));
|
||||
}
|
||||
@@ -506,6 +545,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (p === '/ledger') return sendFile(res, path.join(PUBLIC_DIR, 'ledger.html'));
|
||||
if (p === '/contract') return sendFile(res, path.join(PUBLIC_DIR, 'contract.html'));
|
||||
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
|
||||
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html'));
|
||||
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
|
||||
const file = path.join(PUBLIC_DIR, safe);
|
||||
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);
|
||||
|
||||
Reference in New Issue
Block a user