Video ads (watch-to-earn): duration-tiered pricing, escape-proof player, per-view charge

- New 'video' campaign type; MP4/WebM upload or direct https link + required-watch tier (10/30/60s → 3/7/12 cr per view; viewer earns 1/2/4)
- Composer: video fields, tier dropdown, live price hint
- Earn credits > Watch videos sub-tab: no-seek player, server-clock watch floor, daily cap, self-exclusion, single-use tokens
- media-src CSP for direct-link/hosted video; video skips frame-check

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-06 08:03:01 -05:00
parent 49508c2a13
commit 860aedabf6
12 changed files with 199 additions and 33 deletions
BIN
View File
Binary file not shown.
+1 -1
View File
@@ -53,7 +53,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 — unlocked by the WELCOME TOUR on first sign-in: they visit their upline's line-banner sites (up to 3, 10 seconds each — the same 3 levels the contract pays), then claim the credits. Members with no upline banners get the credits instantly.
- LINE BANNER (free, set in Profile): every member can set a destination URL (must allow framing) plus an optional banner image. It is shown to their next THREE levels of new members during welcome tours (position 1 for directs, 2, 3 below), and on their public BANNER WALL at /wall/<username> — a shareable page showing their line ladder with their join link. Free viral traffic that compounds as the team grows; no credits spent.
- 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), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), and solo ads. Coming: featured rotation with disclosed rotation size, verified-visit packs.
- Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: featured rotation with disclosed rotation size, verified-visit packs.
- 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.
- Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad.
- Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only).
+1
View File
@@ -89,6 +89,7 @@ async function bootstrap() {
last_ts BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (email, day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await alterSafe('ALTER TABLE daily_views ADD COLUMN video_count INT NOT NULL DEFAULT 0'); // watch-to-earn videos/day
await q(`CREATE TABLE IF NOT EXISTS solo_inbox (
id INT AUTO_INCREMENT PRIMARY KEY,
campaign_id INT NOT NULL,
+105 -4
View File
@@ -414,28 +414,127 @@
$('cBodyRow').hidden = t !== 'text';
$('cSoloRow').hidden = t !== 'solo';
$('cSoloHint').hidden = t !== 'solo';
$('cVideoRow').hidden = t !== 'video';
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
soloHint();
if (t === 'video') videoHint();
});
// video composer: tier dropdown + upload + price hint
function videoHint() {
if (!lastRates || !lastRates.videoTiers) return;
if ($('cWatchSecs') && !$('cWatchSecs').options.length)
$('cWatchSecs').innerHTML = lastRates.videoTiers.map(t =>
'<option value="' + t.secs + '">Watch ' + t.secs + 's — ' + t.cost + ' credits/view (viewer earns ' + t.reward + ')</option>').join('');
const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0];
const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0;
$('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view'
+ (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : '';
}
document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); });
$('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); });
$('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click());
$('cVideoFile').addEventListener('change', async () => {
const f = $('cVideoFile').files[0];
if (!f) return;
$('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)';
try {
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; }
$('cVideoUrl').value = r.url;
$('cVideoInfo').textContent = f.name + ' uploaded';
$('cVideoPrev').hidden = false;
$('cVideoPrev').innerHTML = '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>';
} catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; }
$('cVideoFile').value = '';
});
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
// in raw mode the source of truth is the textarea; sync it back first
let soloBody = $('cSoloEd').innerHTML;
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
const isVideo = $('cType').value === 'video';
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
title: $('cTitle').value,
title: isVideo ? $('cVideoTitle').value : $('cTitle').value,
body: $('cType').value === 'solo' ? soloBody : $('cBody').value,
ctaLabel: $('cCtaLabel').value,
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
budget: Number($('cBudget').value) });
IAP.status('Campaign is live. It starts serving right away.', 'ok');
$('cName').value = ''; $('cBudget').value = '';
$('cSoloEd').innerHTML = ''; $('cSoloRaw').value = ''; $('cCtaLabel').value = '';
$('cVideoUrl').value = ''; $('cVideoTitle').value = ''; $('cVideoCta').value = '';
$('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = '';
$('edMediaInfo').textContent = '';
await loadCampaigns();
}));
// defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
// ── watch-to-earn videos: escape-proof player, server-clock reward ──
const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false };
async function loadVideoStatus() {
try {
const st = await (await fetch('/api/my/videos')).json();
if (st.error) return;
$('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched';
if (st.status.left <= 0) {
$('vidBox').innerHTML = '<span class="muted small">That is today\'s video set. Come back tomorrow.</span>';
$('vidStartBtn').hidden = true;
return;
}
$('vidStartBtn').hidden = false;
} catch (e) {}
}
async function loadVideoAd() {
let r = null;
try { r = await (await fetch('/api/my/videos')).json(); } catch (e) {}
if (!r || !r.ad) {
$('vidBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.left <= 0
? 'That is today\'s video set. Come back tomorrow.'
: 'No member videos are live right now. Check back when a campaign is running.') + '</span>';
$('vidWrap').hidden = true;
return;
}
const ad = r.ad;
vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false;
$('vidBox').hidden = true;
$('vidWrap').hidden = false;
$('vidCta').hidden = true;
$('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : '';
const p = $('vidPlayer');
p.src = ad.videoUrl;
p.currentTime = 0;
// escape-proof: no seeking past what's been watched; track max reached
p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; };
p.ontimeupdate = () => {
if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime;
const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen));
$('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…';
if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); }
};
$('vidStartBtn').textContent = 'Playing…';
$('vidStartBtn').disabled = true;
try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; }
$('vidCta').href = ad.ctaUrl;
$('vidCta').textContent = ad.ctaLabel || 'Learn more';
$('vidCta').hidden = false;
}
async function completeVideo(ad) {
if (vidState.credited) return;
vidState.credited = true;
try {
const r = await (await fetch('/api/my/videowatch', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json();
if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error; }
else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); }
else $('vidHint').textContent = 'That video just ran out of budget — load another.';
$('vidProgress').textContent = 'today: ' + ((r.status && r.status.count) || 0) + ' / ' + ((r.status && r.status.cap) || 0) + ' videos watched';
} catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); }
$('vidStartBtn').disabled = false;
$('vidStartBtn').textContent = 'Next video';
}
$('vidStartBtn').addEventListener('click', () => loadVideoAd());
// ── solo-ads inbox: list, read view, dwell-gated read reward ──
let ibTimer = null;
function setInboxBadge(n) {
@@ -447,13 +546,15 @@
// Earn credits sub-tabs: Watch ads | Inbox
let earnSub = 'watch';
function setEarnSub(which) {
earnSub = which === 'inbox' ? 'inbox' : 'watch';
const w = $('earn-watch'), i = $('earn-inbox');
earnSub = ['inbox', 'videos'].includes(which) ? which : 'watch';
const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos');
if (w) w.hidden = earnSub !== 'watch';
if (i) i.hidden = earnSub !== 'inbox';
if (v) v.hidden = earnSub !== 'videos';
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
b.classList.toggle('on', b.dataset.earn === earnSub));
if (earnSub === 'inbox') loadInbox();
else if (earnSub === 'videos') loadVideoStatus();
else earnRefresh();
}
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
+4 -4
View File
@@ -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=20260905w">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
</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=20260905w"></script>
<script src="/assets/contract.js?v=20260905w"></script>
<script src="/assets/chat.js?v=20260905w"></script>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/contract.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
</body>
</html>
+5 -5
View File
@@ -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=20260905w">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
</head>
<body>
@@ -410,9 +410,9 @@
</div>
</section>
<script src="/assets/common.js?v=20260905w"></script>
<script src="/assets/wallet.js?v=20260905w"></script>
<script src="/assets/home.js?v=20260905w"></script>
<script src="/assets/chat.js?v=20260905w"></script>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/wallet.js?v=20260905x"></script>
<script src="/assets/home.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
</body>
</html>
+4 -4
View File
@@ -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=20260905w">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
</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=20260905w"></script>
<script src="/assets/ledger.js?v=20260905w"></script>
<script src="/assets/chat.js?v=20260905w"></script>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/ledger.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
</body>
</html>
+40 -5
View File
@@ -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=20260905w">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
</head>
<body class="bo-body">
@@ -245,6 +245,7 @@
<option value="text">Text ad (per impression)</option>
<option value="login">Login ad (per day)</option>
<option value="solo">Solo ad (inbox delivery)</option>
<option value="video">Video ad (watch to earn)</option>
</select></p>
<p><input id="cName" placeholder="Campaign name" style="width:100%"></p>
<p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
@@ -287,6 +288,17 @@
placeholder="Call-to-action button label (default: Learn more) — the button opens your target URL"></p>
</div>
<p class="small muted" id="cSoloHint" hidden></p>
<div id="cVideoRow" hidden>
<p><input id="cVideoUrl" placeholder="Video URL (direct https link ending .mp4 or .webm)" style="width:100%"></p>
<p><input type="file" id="cVideoFile" accept="video/mp4,video/webm" hidden>
<button type="button" class="btn small sec" id="cVideoUploadBtn">Upload video</button>
<span id="cVideoInfo" class="small muted"></span></p>
<div id="cVideoPrev" hidden style="margin:8px 0"></div>
<p><input id="cVideoTitle" maxlength="80" placeholder="Video title (optional)" style="width:100%"></p>
<p><select id="cWatchSecs" style="width:100%"></select></p>
<p><input id="cVideoCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
<p class="small muted" id="cVideoHint"></p>
</div>
<button class="btn" id="createCampBtn">Launch campaign</button>
</div>
</div>
@@ -294,6 +306,7 @@
<div class="pane" id="pane-earn" hidden>
<div class="subtabs" role="tablist">
<button class="subtab on" data-earn="watch" type="button">Watch ads</button>
<button class="subtab" data-earn="videos" type="button">Watch videos</button>
<button class="subtab" data-earn="inbox" type="button">Inbox<span class="pill" id="inboxBadge2" hidden></span></button>
</div>
@@ -318,6 +331,28 @@
</div>
</div>
<div class="earn-sub" id="earn-videos" hidden>
<div class="card">
<h3>Watch videos, earn credits</h3>
<p class="muted small">Watch a member's video for its required time and earn credits. The player
can't be skipped — the timer runs on our server. You never see your own videos.</p>
<p><span class="badge" id="vidProgress">…</span></p>
<div class="card" style="background:rgba(4,8,7,.5)" id="vidStage">
<div id="vidBox" class="small muted" style="min-height:120px;display:grid;place-items:center">
Press the button to load the first video.</div>
<div id="vidWrap" hidden style="max-width:560px;margin:0 auto">
<video id="vidPlayer" playsinline preload="auto" style="width:100%;border-radius:12px;background:#000"></video>
<p class="small muted" id="vidTimer" style="text-align:center;margin:8px 0 0"></p>
</div>
<p style="margin-top:14px">
<button class="btn" id="vidStartBtn" type="button">Load a video</button>
<a class="btn sec" id="vidCta" target="_blank" rel="noopener nofollow" hidden>Learn more</a>
</p>
<p class="small muted" id="vidHint"></p>
</div>
</div>
</div>
<div class="earn-sub" id="earn-inbox" hidden>
<div class="card" id="inboxListCard">
<h3>Solo ads inbox</h3>
@@ -449,9 +484,9 @@
</div>
</div>
<script src="/assets/common.js?v=20260905w"></script>
<script src="/assets/wallet.js?v=20260905w"></script>
<script src="/assets/my.js?v=20260905w"></script>
<script src="/assets/chat.js?v=20260905w"></script>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/wallet.js?v=20260905x"></script>
<script src="/assets/my.js?v=20260905x"></script>
<script src="/assets/chat.js?v=20260905x"></script>
</body>
</html>
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Transaction | 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=20260905w">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
</head>
<body>
<div id="nav"></div>
@@ -33,7 +33,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div>
</section>
<script src="/assets/common.js?v=20260905w"></script>
<script src="/assets/tx.js?v=20260905w"></script>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/tx.js?v=20260905x"></script>
</body>
</html>
+2 -2
View File
@@ -4,7 +4,7 @@
<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=20260905w">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
<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)}
@@ -43,6 +43,6 @@
</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=20260905w"></script>
<script src="/assets/view.js?v=20260905x"></script>
</body>
</html>
+3 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Banner wall | 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=20260905w">
<link rel="stylesheet" href="/assets/site.css?v=20260905x">
</head>
<body>
<div id="nav"></div>
@@ -26,7 +26,7 @@
</div>
</div>
</section>
<script src="/assets/common.js?v=20260905w"></script>
<script src="/assets/wall.js?v=20260905w"></script>
<script src="/assets/common.js?v=20260905x"></script>
<script src="/assets/wall.js?v=20260905x"></script>
</body>
</html>
+31 -2
View File
@@ -32,6 +32,7 @@ const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on th
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
const uploadCounts = new Map(); // email:day -> uploads today
const gauntletTokens = new Map(); // email -> welcome-tour token (server-clock dwell floor)
const videoTokens = new Map(); // email -> watch-to-earn video token (server-clock watch floor)
// walk the referral chain upward via sponsorRef (code/username/member id)
async function uplineSlides(email, depth = 3) {
const out = [];
@@ -139,7 +140,7 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': '
'.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',
'.gif': 'image/gif', '.webm': 'video/webm' };
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:";
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; media-src 'self' https: blob:; 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 || {});
@@ -615,6 +616,34 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
joinUrl: '/join/' + (a.username || a.code), ladder });
}
// -- watch-to-earn video ads: serve one, then reward a server-clock-verified watch
if (p === '/api/my/videos' && 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.videoStatus(s.email);
if (status.left <= 0) return json(res, 200, { ad: null, status });
const ad = await ads.serveVideo({ excludeEmail: s.email }); // never your own video
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs });
return json(res, 200, { ad, token, status });
}
if (p === '/api/my/videowatch' && 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 = videoTokens.get(s.email);
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That video is no longer open. Load the next one.' });
const age = Date.now() - t.ts;
if (age < t.secs * 1000 - 600) return json(res, 400, { error: 'Watch the full video first.' });
if (age > t.secs * 1000 + 10 * 60 * 1000) { videoTokens.delete(s.email); return json(res, 400, { error: 'That watch went stale. Load a fresh video.' }); }
videoTokens.delete(s.email); // single use
const tier = await ads.chargeVideoView(t.id); // charge advertiser; null if it ran dry
if (!tier) return json(res, 200, { ok: true, credited: 0, status: await ads.videoStatus(s.email), gone: true });
await ads.addEarned(s.email, tier.reward);
const status = await ads.recordVideoWatch(s.email);
return json(res, 200, { ok: true, credited: tier.reward, status });
}
// -- onsite solo ads: member inbox with read rewards
if (p === '/api/my/inbox' && req.method === 'GET') {
const s = await auth.fromRequest(req);
@@ -723,7 +752,7 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text
const b = await readBody(req);
if (!['login', 'solo'].includes(String(b.type || ''))) { // banner/text surf views frame the target: catch frame-breakers at the door (login opens a new tab, solo links are click-through)
if (!['login', 'solo', 'video'].includes(String(b.type || ''))) { // banner/text surf views frame the target: catch frame-breakers (login/video/solo open in a new tab or play in our own player)
const fc = await frameCheck(b.targetUrl);
if (!fc.ok) return json(res, 400, { error: fc.reason });
}