Files
instantadpay/server.js
T
martbost ade0a13064 Sponsor Chat: two-way member<->sponsor messaging (presence, availability, mute)
Evolves the one-way broadcast into real support threads, keeping broadcast alongside.
- messages.js: kind='chat' rides sponsor_messages; sendChat/thread/threadList/
  markChatRead/chatUnread; broadcast inbox + login modal scoped to kind='broadcast'
- accounts.js: presence (last_seen), chat availability toggle, per-member mutes;
  sponsorOf() + isDownlineOf() resolvers
- server.js: /api/my/chat/{send,thread,threads,available,mute} + /api/my/ping
  heartbeat; dashboard emits chatUnread/chatAvailable/sponsor; auth = direct
  sponsor up, any downline down, or an existing thread; email only when offline
  and not mid-chat
- my.html/my.js/site.css: slide-in chat drawer (threads list + live thread,
  4s poll), presence dot, Message buttons on direct rows, Message-my-sponsor
  quick action, availability switch in Profile
- db.js: additive migrations (kind, pair index, chat_available, chat_mutes)
- chatbot.js: canned answer + AI fact for Sponsor Chat

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-07 05:24:23 -05:00

1167 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// InstantAdPay — membership advertising with immutable on-chain settlement.
// Zero-dependency Node server (RM Circle pattern): static pages + JSON API,
// wallet sign-in (SIWE), live contract ledger, sponsor join links.
//
// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json —
// see chain.js. Wipe the volume's accounts/sessions + flip config = launch.
const http = require('http');
const https = require('https');
const dns = require('dns');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { URL } = require('url');
const chain = require('./chain');
const auth = require('./auth');
const accounts = require('./accounts');
const ads = require('./ads');
const mailer = require('./mailer');
const messages = require('./messages');
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
const chatbot = require('./chatbot');
const PORT = Number(process.env.PORT || 3000);
const ROOT = __dirname;
const PUBLIC_DIR = path.join(ROOT, 'public');
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
const IS_PROD = process.env.NODE_ENV === 'production';
const SITE_FILE = path.join(DATA_DIR, 'site.json');
const db = require('./db');
fs.mkdirSync(DATA_DIR, { recursive: true });
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume
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)
const visitTokens = new Map(); // email -> verified-visit token (dwell + captcha floor)
// walk the referral chain upward via sponsorRef (code/username/member id)
async function uplineSlides(email, depth = 3) {
const out = [];
let cur = await accounts.byEmail(email);
for (let i = 0; i < depth && cur; i++) {
const ref = String(cur.sponsorRef || '').trim().toLowerCase();
if (!ref) break;
let s = null;
if (/^\d+$/.test(ref)) s = await accounts.byMemberId(Number(ref));
if (!s) s = await accounts.byCode(ref);
if (!s) s = await accounts.byUsername(ref);
if (!s || s.email === cur.email) break;
out.push(s);
cur = s;
}
return out;
}
const chatHits = new Map();
function chatLimited(ip) {
const now = Date.now(), rec = chatHits.get(ip);
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
rec.count += 1;
return rec.count > 10;
}
// 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();
// 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']];
// ── frame-breaking check ─────────────────────────────────
// Surf views frame the advertiser's URL full screen, so a target that refuses
// framing (X-Frame-Options / CSP frame-ancestors) would burn members' views on
// a blank frame. Catch it the moment the campaign is submitted. The lookup
// also refuses private/internal addresses so member URLs can't probe our LAN.
const PRIVATE_IP = /^(127\.|10\.|192\.168\.|169\.254\.|0\.|172\.(1[6-9]|2\d|3[01])\.|::1$|::$|f[cd])/i;
function frameFetch(url, depth) {
return new Promise(resolve => {
let u;
try { u = new URL(String(url || '')); } catch (e) { return resolve({ error: 'that is not a valid URL' }); }
if (!/^https?:$/.test(u.protocol)) return resolve({ error: 'only http(s) URLs work' });
if (u.port && u.port !== '80' && u.port !== '443') return resolve({ error: 'custom ports are not allowed' });
if (u.hostname === 'localhost' || u.hostname.endsWith('.local')) return resolve({ error: 'that address is not reachable from here' });
dns.lookup(u.hostname, (de, addr) => {
if (de) return resolve({ error: 'that domain does not resolve' });
if (PRIVATE_IP.test(addr)) return resolve({ error: 'that address is not reachable from here' });
const mod = u.protocol === 'https:' ? https : http;
const rq = mod.get(u.href, { timeout: 8000,
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; InstantAdPay-FrameCheck/1.0)', Accept: 'text/html' } }, r => {
const loc = r.headers.location;
r.resume(); // headers are all we need
if ([301, 302, 303, 307, 308].includes(r.statusCode) && loc && depth < 4) {
rq.destroy();
let next; try { next = new URL(loc, u.href).href; } catch (e2) { return resolve({ error: 'it redirects somewhere invalid' }); }
return resolve(frameFetch(next, depth + 1)); // every hop re-runs the private-IP guard
}
resolve({ status: r.statusCode, xfo: r.headers['x-frame-options'] || '', csp: r.headers['content-security-policy'] || '' });
rq.destroy();
});
rq.on('timeout', () => { rq.destroy(); resolve({ error: 'it did not answer within 8 seconds' }); });
rq.on('error', e2 => resolve({ error: 'it did not answer (' + (e2.code || 'connection failed') + ')' }));
});
});
}
async function frameCheck(url) {
const h = await frameFetch(url, 0);
if (h.error) return { ok: false, reason: 'We checked your URL and ' + h.error + '. Fix the URL and try again.' };
if (h.status >= 400) return { ok: false, reason: 'Your URL answers with HTTP ' + h.status + '. Point the campaign at a working page.' };
if (/deny|sameorigin/i.test(String(h.xfo)))
return { ok: false, reason: 'That site blocks framing (X-Frame-Options), so it would show members a blank page in the ad viewer. Use a landing page that allows framing.' };
const fa = /frame-ancestors\s+([^;]+)/i.exec(String(h.csp));
if (fa && !fa[1].split(/\s+/).some(x => {
const v = x.replace(/['"]/g, '').toLowerCase();
return v === '*' || v === 'https:' || v.includes('instantadpay.com');
}))
return { ok: false, reason: 'That site blocks framing (CSP frame-ancestors), so it would show members a blank page in the ad viewer. Use a landing page that allows framing.' };
return { ok: true };
}
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)) });
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
accounts.init({ dataDir: DATA_DIR });
ads.init({ dataDir: DATA_DIR, chain });
mailer.init({ dataDir: DATA_DIR });
messages.init({ dataDir: DATA_DIR });
chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
// NAS reconcile: pull syndicated delivery into the unified credit pool
// (inert unless NAS_DB_* is set). Every 5 min after a short warm-up.
if (ads.nasEnabled()) {
console.log('NAS syndication enabled');
setTimeout(() => ads.reconcileNas().catch(e => console.error('nas reconcile', e.message)), 90 * 1000);
setInterval(() => ads.reconcileNas().catch(e => console.error('nas reconcile', e.message)), 5 * 60 * 1000);
}
}
function siteConfig() {
let saved = {};
try { saved = JSON.parse(fs.readFileSync(SITE_FILE, 'utf8')); } catch (e) {}
return Object.assign({
siteName: 'InstantAdPay',
tagline: 'Advertise and earn. Locked in code, not promises.',
rehearsal: true // shows the testnet banner; flipped off at mainnet launch
}, saved);
}
// ---- helpers ----
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',
'.gif': 'image/gif', '.webm': 'video/webm', '.txt': 'text/plain; charset=utf-8', '.xml': 'application/xml; charset=utf-8' };
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 || {});
}
function json(res, code, obj, extra) {
const body = JSON.stringify(obj);
res.writeHead(code, baseHeaders(Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, extra)));
res.end(body);
}
function sendFile(res, file) {
fs.readFile(file, (err, data) => {
if (err) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
const ext = path.extname(file).toLowerCase();
res.writeHead(200, baseHeaders({ 'Content-Type': MIME[ext] || 'application/octet-stream',
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' }));
res.end(data);
});
}
function readRaw(req, maxBytes) {
return new Promise((resolve, reject) => {
const chunks = [];
let n = 0;
req.on('data', c => {
n += c.length;
if (n > maxBytes) { req.destroy(); reject(new Error('too big')); return; }
chunks.push(c);
});
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', reject);
});
}
function readBody(req) {
return new Promise((resolve, reject) => {
let d = ''; let n = 0;
req.on('data', c => { n += c.length; if (n > 64 * 1024) { req.destroy(); reject(new Error('too big')); } d += c; });
req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { reject(e); } });
req.on('error', reject);
});
}
function parseCookies(req) {
const out = {};
for (const p of (req.headers.cookie || '').split(';')) {
const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
}
return out;
}
function isAdmin(req) {
const h = req.headers.authorization || '';
return h === 'Bearer ' + ADMIN_PASSWORD;
}
// attach a memberId->username map to events so activity shows real people
async function attachNames(evts) {
try {
const ids = [];
for (const ev of evts)
for (const k of ['id', 'buyerId', 'recipientId', 'skippedId', 'sponsorId', 'newBuyerId', 'toId', 'memberId'])
if (ev[k]) ids.push(ev[k]);
const names = await accounts.namesForMembers(ids);
if (!Object.keys(names).length) return evts;
return evts.map(ev => Object.assign({}, ev, { names }));
} catch (e) { return evts; }
}
// A sponsor token is a numeric chain id or a site share code. Codes resolve
// to the referrer's CURRENT chain id, so activation any time before the
// referral's first purchase still locks the line to them.
async function resolveSponsorToken(tok) {
const t = String(tok || '').trim().toLowerCase();
if (!t) return 0;
if (/^\d+$/.test(t)) return Number(t);
let acct = await accounts.byCode(t);
if (!acct) acct = await accounts.byUsername(t); // vanity links: /join/<username>
if (!acct || !acct.address) return 0;
try { return await chain.memberIdByAccount(acct.address); } catch (e) { return 0; }
}
// The moment someone joins through a code, nudge its owner to activate.
async function nudgeReferrer(ref) {
try {
const t = String(ref || '').trim().toLowerCase();
if (!t || /^\d+$/.test(t) || !mailer.hasKey()) return;
const owner = await accounts.byCode(t);
if (!owner || !owner.email || owner.address) return; // already activated-ready
mailer.send(owner.email, 'Someone just joined through your InstantAdPay link',
'Good news: a new member just signed up through your share link.\n\n'
+ 'One thing to do so you never miss a payment: sign in and switch on payouts '
+ '(one free wallet step). The contract locks each buyer to their sponsor at their '
+ 'first purchase, so have payouts on before your people start buying.\n\n'
+ 'https://instantadpay.com/my\n\nInstantAdPay').catch(e => console.error('nudge failed', e.message));
} catch (e) {}
}
// ---- live feed (SSE) ----
const feedClients = new Set();
function pushFeed(ev) {
const line = 'data: ' + JSON.stringify(ev) + '\n\n';
for (const res of feedClients) { try { res.write(line); } catch (e) { feedClients.delete(res); } }
}
// ---- server ----
const server = http.createServer(async (req, res) => {
try {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
// -- join links: /join/<memberId or share code> — first-touch cookie.
// Codes resolve LATE (at buy time) to whatever chain id the referrer
// has by then, so free members refer from day one.
let m = /^\/join\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m && req.method === 'GET') {
const tok = m[1].toLowerCase();
const cookies = parseCookies(req);
const headers = { Location: '/' };
if (!cookies['iap.sponsor']) {
headers['Set-Cookie'] = `iap.sponsor=${tok}; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
}
res.writeHead(302, baseHeaders(headers));
return res.end();
}
// -- public API
if (p === '/api/config' && req.method === 'GET') {
const c = chain.getConfig();
return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId,
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0],
emailAuth: mailer.hasKey() || !IS_PROD }, siteConfig()));
}
if (p === '/api/catalog' && req.method === 'GET') {
return json(res, 200, { products: await chain.catalog() });
}
if (p === '/api/feed' && req.method === 'GET') {
return json(res, 200, { events: await attachNames(chain.recentEvents(Number(u.searchParams.get('n')) || 100)) });
}
if (p === '/api/feed/live' && req.method === 'GET') {
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' }));
res.write(': connected\n\n');
feedClients.add(res);
req.on('close', () => feedClients.delete(res));
return;
}
m = /^\/api\/tx\/(0x[0-9a-fA-F]{64})$/.exec(p);
if (m && req.method === 'GET') {
// tx relay so the browser never talks to the RPC directly (CSP stays 'self').
// Serves both the wallet's waitTx poll (found/status) and the built-in
// /tx/<hash> viewer (full details + decoded events).
try {
const r = await chain.rpc('eth_getTransactionReceipt', [m[1]]);
if (!r) return json(res, 200, { found: false });
const out = { found: true, status: r.status, blockNumber: r.blockNumber, gasUsed: r.gasUsed };
try {
const t = await chain.rpc('eth_getTransactionByHash', [m[1]]);
if (t) { out.from = t.from; out.to = t.to; out.valueWei = BigInt(t.value || '0x0').toString(); }
} catch (e) {}
try {
const blk = await chain.rpc('eth_getBlockByNumber', [r.blockNumber, false]);
if (blk) out.ts = blk.timestamp;
} catch (e) {}
try {
out.events = await attachNames((r.logs || []).map(chain.decodeLog).filter(Boolean)
.map(ev => Object.assign(ev, { tx: m[1] })));
} catch (e) { out.events = []; }
return json(res, 200, out);
} catch (e) { return json(res, 200, { found: false, rpcError: true }); }
}
if (p === '/api/sponsor' && req.method === 'GET') {
const tok = parseCookies(req)['iap.sponsor'] || '';
const sponsorId = await resolveSponsorToken(tok);
return json(res, 200, { ref: tok, sponsorId, invited: !!tok });
}
if (p === '/api/stats' && req.method === 'GET') {
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
return json(res, 200, Object.assign({ onchainMembers: members, siteAccounts: await accounts.count() }, chain.totals()));
}
// -- 24/7 assistant
if (p === '/api/chat' && req.method === 'POST') {
const ip = req.socket.remoteAddress || 'x';
if (chatLimited(ip)) return json(res, 429, { error: 'Give it a minute, then ask again.' });
const b = await readBody(req);
const r = await chatbot.answer(b.message, b.history);
return json(res, r.error ? 400 : 200, r);
}
// -- accounts: email + password is the normal join path (wallet comes
// out only at purchase / payout-activation time and gets linked then)
if (p === '/api/signup' && req.method === 'POST') {
const b = await readBody(req);
const ref = parseCookies(req)['iap.sponsor'] || ''; // first-touch attribution
const r = await accounts.signup(b.email, b.password, ref);
if (r.error) return json(res, 400, r);
nudgeReferrer(ref).catch(() => {});
const token = await auth.mintSession({ email: r.account.email });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
if (p === '/api/login' && req.method === 'POST') {
const b = await readBody(req);
const r = await accounts.login(b.email, b.password);
if (r.error) return json(res, 400, r);
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} }
const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
// -- passwordless: email code sign-in (signup and login are the same act)
if (p === '/api/auth/email/start' && req.method === 'POST') {
const b = await readBody(req);
const e = String(b.email || '').trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
const prev = emailCodes.get(e);
if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' });
const code = String(Math.floor(100000 + Math.random() * 900000));
emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 });
if (mailer.hasKey()) {
try { await mailer.sendCode(e, code); } catch (err) {
console.error('sendCode failed', err.message);
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
}
return json(res, 200, { ok: true, sent: true });
}
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code });
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
}
if (p === '/api/auth/email/verify' && req.method === 'POST') {
const b = await readBody(req);
const e = String(b.email || '').trim().toLowerCase();
const rec = emailCodes.get(e);
if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' });
rec.tries += 1;
if (rec.tries > 6) { emailCodes.delete(e); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); }
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
emailCodes.delete(e);
const ref = parseCookies(req)['iap.sponsor'] || '';
const r = await accounts.ensure(e, ref); // first touch wins; existing accounts unchanged
if (r.error) return json(res, 400, r);
if (r.created) nudgeReferrer(ref).catch(() => {});
let memberId = 0;
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
}
// -- wallet auth: link-to-account when an email session exists, or
// wallet-first sign-in for crypto-native users
if (p === '/api/auth/challenge' && req.method === 'POST') {
const b = await readBody(req);
const r = auth.makeChallenge(b.address);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/auth/verify' && req.method === 'POST') {
const b = await readBody(req);
const r = await auth.verifyChallenge(b.address, b.signature);
if (r.error) return json(res, 400, r);
let memberId = 0;
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
const s = await auth.fromRequest(req);
if (s && s.email) {
const lr = await accounts.linkWallet(s.email, r.address);
if (lr.error) return json(res, 400, lr);
await auth.updateSession(s.token, { address: r.address, memberId });
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
}
const acct = await accounts.byAddress(r.address);
const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
return json(res, 200, { ok: true, address: r.address, memberId },
{ 'Set-Cookie': auth.sessionCookie(token) });
}
if (p === '/api/auth/logout' && req.method === 'POST') {
await auth.logout(req);
return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() });
}
if (p === '/api/me' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s) return json(res, 200, { signedIn: false });
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
const sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null, memberId,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, sponsorId };
if (memberId) {
try {
const mm = await chain.member(memberId);
out.buyerCount = mm.buyerCount;
out.onchainSponsorId = mm.sponsorId;
out.credits = await chain.creditBalance(memberId, 0);
} catch (e) { out.chainReadError = true; }
}
return json(res, 200, out);
}
if (p === '/api/my/dashboard' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
if (memberId && acct && acct.memberId !== memberId) accounts.setMemberId(acct.email, memberId).catch(() => {});
const out = { memberId, email: s.email || (acct && acct.email) || null,
address: s.address || (acct && acct.address) || null,
username: (acct && acct.username) || null,
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
if (out.email) {
// welcome credits unlock via the welcome tour when an upline with a
// line banner exists; members with no tour to walk get them instantly
const welcomed = await ads.welcomeGranted(out.email);
const tour = welcomed ? [] : (await uplineSlides(out.email)).filter(a => a.lineTargetUrl);
if (welcomed || !tour.length) out.welcomeCredits = await ads.grantWelcome(out.email);
else { out.welcomeCredits = 0; out.gauntletPending = true; }
}
if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too
if (out.email) { // sponsor chat: presence heartbeat + unread + my availability + direct sponsor
accounts.touchSeen(out.email).catch(() => {});
out.chatUnread = await messages.chatUnread(out.email);
out.chatAvailable = (acct && acct.chatAvailable !== false);
const spon = await accounts.sponsorOf(out.email);
if (spon && spon.email) out.sponsor = {
email: spon.email,
name: spon.username ? '@' + spon.username : (spon.memberId ? 'member #' + spon.memberId : 'your sponsor'),
online: (Date.now() - (spon.lastSeen || 0)) < 60000,
available: spon.chatAvailable !== false };
}
// achievement milestones (same ladder as the Overview stepper) + one-time credit bonuses
{
const bc = out.buyerCount || 0;
const reached = [];
if (out.memberId) reached.push('payouts');
if (bc >= 1) reached.push('firstBuyer');
if (bc >= 2) reached.push('level2');
if (bc >= 5) reached.push('level3');
out.milestonesReached = reached;
if (out.email && reached.length) out.milestonesGranted = await ads.grantMilestones(out.email, reached);
}
if (out.email) { // unmissable login modal when the upline sent a message
const un = await messages.newestUnread(out.email);
if (un) {
const nm = un.fromMember ? await accounts.namesForMembers([un.fromMember]) : {};
out.sponsorMsg = { id: un.id, subject: un.subject, body: un.body,
fromName: (un.fromMember && nm[un.fromMember]) ? '@' + nm[un.fromMember] : (un.fromMember ? 'member #' + un.fromMember : 'your sponsor') };
}
}
if (memberId) {
try {
const mm = await chain.member(memberId);
out.buyerCount = mm.buyerCount;
out.credits = await ads.availableCredits(memberId);
} catch (e) { out.chainReadError = true; }
let earned = 0n, n = 0;
for (const ev of chain.recentEvents(600)) {
if ((ev.type === 'TierPaid' && ev.recipientId === memberId) || (ev.type === 'AwardPaid' && ev.toId === memberId)) {
earned += BigInt(ev.amountWei); n += 1;
}
}
out.earnedWei = earned.toString();
out.earnCount = n;
}
// who joined through this member (code and, once on-chain, numeric id)
const refs = [];
if (acct && acct.code) refs.push(acct.code);
if (memberId) refs.push(String(memberId));
const joined = await accounts.listByReferrer(refs);
out.referrals = joined.map(r => ({
name: r.username || r.email.replace(/^(.).*(@.*)$/, '$1***$2'), // username, else privacy mask
joined: r.created,
status: r.address ? 'wallet linked' : 'joined free'
}));
return json(res, 200, out);
}
if (p === '/api/my/profile' && 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 r = await accounts.setUsername(s.email, b.username);
return json(res, r.error ? 400 : 200, r);
}
// -- earn credits by viewing ads (attention-gated daily claim)
if (p === '/api/my/earn' && req.method === 'GET') {
const s = await auth.fromRequest(req);
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');
// members never see (or earn from) their own campaigns in the viewer
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');
// 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);
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.' }); }
// 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));
}
if (p === '/api/my/claim' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.claimDaily(s.email);
return json(res, r.error ? 400 : 200, r);
}
// -- downline lineage: 3 levels, usernames+IDs; email only for directs
if (p === '/api/my/line' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const levels = await accounts.downline(s.email, 3);
const out = levels.map(L => ({ level: L.level, members: L.members.map(m => ({
memberId: m.memberId || 0,
name: m.username ? '@' + m.username : m.memberId ? 'member #' + m.memberId : 'member',
email: L.level === 1 ? m.email : null, // directs only
joined: m.created })) }));
return json(res, 200, { levels: out, counts: out.map(L => L.members.length) });
}
// -- broadcast a message to your downline (1/day), on-site inbox + email
if (p === '/api/my/broadcast' && 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 subject = String(b.subject || '').trim().slice(0, 160);
const body = ads.sanitizeRich(b.body);
const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (!subject) return json(res, 400, { error: 'Give your message a subject.' });
if (plain.length < 10) return json(res, 400, { error: 'Write a message first.' });
const last = await messages.lastBroadcastAt(s.email);
if (Date.now() - last < 24 * 3600 * 1000)
return json(res, 429, { error: 'You can send one broadcast a day. Try again in ' + Math.ceil((24 * 3600 * 1000 - (Date.now() - last)) / 3600000) + 'h.' });
const depth = b.scope === 'direct' ? 1 : 3;
const levels = await accounts.downline(s.email, depth);
const recipients = [...new Set(levels.flatMap(L => L.members.map(m => m.email)).filter(Boolean))];
if (!recipients.length) return json(res, 400, { error: 'No one in your line to message yet.' });
const memberId = s.memberId || await auth.refreshMemberId(s);
await messages.deliver(memberId, s.email, recipients, subject, body);
// email each recipient too (best-effort; never blocks the on-site delivery)
if (mailer.hasKey()) {
const who = (await accounts.byEmail(s.email));
const from = who && who.username ? '@' + who.username : 'your sponsor';
for (const to of recipients) {
mailer.send(to, 'Message from ' + from + ': ' + subject,
plain + '\n\n— sent via your InstantAdPay upline. Read it in your dashboard: https://instantadpay.com/my#line')
.catch(() => {});
}
}
return json(res, 200, { ok: true, sent: recipients.length });
}
// -- sponsor messages: this member's inbox from their upline
if (p === '/api/my/messages' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const items = await messages.inbox(s.email);
const names = await accounts.namesForMembers([...new Set(items.map(i => i.fromMember).filter(Boolean))]);
for (const i of items) i.fromName = (i.fromMember && names[i.fromMember]) ? '@' + names[i.fromMember]
: i.fromMember ? 'member #' + i.fromMember : 'your upline';
return json(res, 200, { items, unread: items.filter(i => !i.read).length });
}
m = /^\/api\/my\/messages\/(\d+)\/read$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
return json(res, 200, await messages.markRead(s.email, m[1]));
}
// ── SPONSOR CHAT (two-way): presence-aware 1:1 threads up/down the line ──
const chatOnline = ts => (Date.now() - (ts || 0)) < 60000;
const chatName = a => !a ? 'member' : (a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : 'member'));
// lightweight presence heartbeat (called on a timer while the dashboard is open)
if (p === '/api/my/ping' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (s && s.email) accounts.touchSeen(s.email).catch(() => {});
return json(res, 200, { ok: true });
}
if (p === '/api/my/chat/send' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const me = s.email.toLowerCase();
const b = await readBody(req);
const to = String(b.to || '').trim().toLowerCase();
const text = String(b.body || '').replace(/<[^>]*>/g, '').replace(/\s+$/, '').slice(0, 2000).trim();
if (!to || to === me) return json(res, 400, { error: 'Pick who to message.' });
if (!text) return json(res, 400, { error: 'Write a message first.' });
const target = await accounts.byEmail(to);
if (!target) return json(res, 404, { error: 'No such member.' });
// authorize: existing thread, my direct sponsor, or someone in my downline
let ok = (await messages.thread(me, to, 0, 1)).length > 0;
if (!ok) { const spon = await accounts.sponsorOf(me); ok = !!(spon && spon.email && spon.email.toLowerCase() === to); }
if (!ok) ok = await accounts.isDownlineOf(me, to);
if (!ok) return json(res, 403, { error: 'You can only message your direct sponsor or someone in your line.' });
if ((await accounts.getMutes(to)).map(x => String(x).toLowerCase()).includes(me))
return json(res, 403, { error: 'They are not accepting messages from you right now.' });
const memberId = s.memberId || await auth.refreshMemberId(s);
const msg = await messages.sendChat(memberId, me, to, text);
// email only when they are offline AND I have not messaged them in ~10 min (no mid-chat spam)
try {
if (mailer.hasKey() && !chatOnline(target.lastSeen)) {
const mine = (await messages.thread(me, to, 0, 400)).filter(x => x.id !== msg.id && String(x.fromEmail).toLowerCase() === me);
const lastMineTs = mine.length ? mine[mine.length - 1].sent : 0;
if (Date.now() - lastMineTs > 10 * 60 * 1000) {
const who = await accounts.byEmail(me);
const from = who && who.username ? '@' + who.username : 'someone in your InstantAdPay line';
mailer.send(to, 'New message from ' + from,
text.slice(0, 400) + '\n\n— reply in your dashboard: https://instantadpay.com/my').catch(() => {});
}
}
} catch (e) {}
return json(res, 200, { ok: true, message: { id: msg.id, sent: msg.sent, fromMe: true, body: text } });
}
if (p === '/api/my/chat/thread' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const me = s.email.toLowerCase();
const other = String(u.searchParams.get('with') || '').trim().toLowerCase();
const after = Number(u.searchParams.get('after')) || 0;
if (!other) return json(res, 400, { error: 'Who with?' });
// may view a thread I'm party to (existing), or one I'm allowed to start
let ok = (await messages.thread(me, other, 0, 1)).length > 0;
if (!ok) { const spon = await accounts.sponsorOf(me); ok = !!(spon && spon.email && spon.email.toLowerCase() === other); }
if (!ok) ok = await accounts.isDownlineOf(me, other);
if (!ok) return json(res, 403, { error: 'Not your conversation.' });
const msgs = await messages.thread(me, other, after, 300);
await messages.markChatRead(me, other);
accounts.touchSeen(me).catch(() => {});
const oa = await accounts.byEmail(other);
const iMute = (await accounts.getMutes(me)).map(x => String(x).toLowerCase()).includes(other);
const theyMuteMe = (await accounts.getMutes(other)).map(x => String(x).toLowerCase()).includes(me);
return json(res, 200, {
messages: msgs.map(x => ({ id: x.id, fromMe: String(x.fromEmail).toLowerCase() === me, body: x.body, sent: x.sent })),
otherName: chatName(oa), online: oa ? chatOnline(oa.lastSeen) : false,
available: oa ? oa.chatAvailable !== false : true, iMute, blocked: theyMuteMe,
canMute: await accounts.isDownlineOf(me, other) });
}
if (p === '/api/my/chat/threads' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const list = await messages.threadList(s.email.toLowerCase());
for (const t of list) {
const a = await accounts.byEmail(t.email);
t.name = chatName(a); t.online = a ? chatOnline(a.lastSeen) : false;
}
list.sort((x, y) => (y.last.sent || 0) - (x.last.sent || 0));
const meAcct = await accounts.byEmail(s.email);
return json(res, 200, { threads: list, available: meAcct ? meAcct.chatAvailable !== false : true });
}
if (p === '/api/my/chat/available' && 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);
return json(res, 200, await accounts.setChatAvailable(s.email, !!b.available));
}
if (p === '/api/my/chat/mute' && 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 target = String(b.email || '').trim().toLowerCase();
if (!target) return json(res, 400, { error: 'Who?' });
const allowed = (await accounts.isDownlineOf(s.email, target)) || (await messages.thread(s.email.toLowerCase(), target, 0, 1)).length > 0;
if (!allowed) return json(res, 403, { error: 'You can only mute someone in your line.' });
return json(res, 200, await accounts.setMute(s.email, target, !!b.muted));
}
// -- featured rotation: the live featured links + dilution stats
if (p === '/api/featured' && req.method === 'GET') {
const items = await ads.serveFeatured();
const names = await accounts.namesForMembers([...new Set(items.map(i => i.memberId).filter(Boolean))]);
for (const i of items) i.by = (i.memberId && names[i.memberId]) ? '@' + names[i.memberId] : (i.memberId ? 'member #' + i.memberId : null);
return json(res, 200, { items });
}
if (p === '/api/featured/stats' && req.method === 'GET') {
return json(res, 200, await ads.featuredStats());
}
// -- QR code (SVG) for any InstantAdPay URL — used on bio/wall pages
if (p === '/api/qr' && req.method === 'GET') {
const data = String(u.searchParams.get('d') || '').slice(0, 300);
if (!QR || !data) { res.writeHead(404, baseHeaders()); return res.end(); }
try {
const svg = await QR.toString(data, { type: 'svg', margin: 1, width: 240,
color: { dark: '#0b1512', light: '#eef7f3' } });
res.writeHead(200, baseHeaders({ 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' }));
return res.end(svg);
} catch (e) { res.writeHead(500, baseHeaders()); return res.end(); }
}
// -- profile: avatar + bio
if (p === '/api/my/profile-details' && 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 avatar = b.avatarUrl === undefined ? undefined : String(b.avatarUrl || '').trim();
if (avatar && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(avatar))
return json(res, 400, { error: 'Avatar must be an uploaded image or an https image URL.' });
const bio = b.bio === undefined ? undefined : String(b.bio || '').trim().slice(0, 600);
// socials: {platform: url}; keep only known platforms with valid https urls
let socials;
if (b.socials !== undefined) {
const PLAT = ['facebook', 'twitter', 'youtube', 'instagram', 'tiktok', 'telegram', 'linkedin', 'website'];
const clean = {};
for (const p of PLAT) {
const v = String((b.socials && b.socials[p]) || '').trim();
if (v && /^https:\/\/[^\s]+$/i.test(v) && v.length <= 200) clean[p] = v;
}
socials = Object.keys(clean).length ? JSON.stringify(clean) : null;
}
const r = await accounts.setProfile(s.email, avatar, bio, socials);
return json(res, r.error ? 400 : 200, r);
}
// -- line banner: the member's viral slot on welcome tours + their wall
if (p === '/api/my/linebanner' && 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 target = String(b.targetUrl || '').trim();
if (!/^https?:\/\/[^\s]+$/i.test(target)) return json(res, 400, { error: 'Destination URL must start with http(s)://' });
const fc = await frameCheck(target); // welcome tours frame it full screen
if (!fc.ok) return json(res, 400, { error: fc.reason });
const banner = String(b.bannerUrl || '').trim();
if (banner && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(banner))
return json(res, 400, { error: 'Banner must be an uploaded image or an https image URL.' });
const r = await accounts.setLineBanner(s.email, banner || null, target);
return json(res, r.error ? 400 : 200, r);
}
// -- welcome tour (gauntlet): meet the 3-level upline, then unlock welcome credits
if (p === '/api/my/gauntlet' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
if (await ads.welcomeGranted(s.email)) return json(res, 200, { pending: false });
const slides = (await uplineSlides(s.email)).filter(a => a.lineTargetUrl)
.map((a, i) => ({ name: a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : 'a member',
bannerUrl: a.lineBannerUrl || null, targetUrl: a.lineTargetUrl }));
if (!slides.length) return json(res, 200, { pending: false });
const token = crypto.randomBytes(16).toString('hex');
gauntletTokens.set(s.email, { token, ts: Date.now(), n: slides.length });
return json(res, 200, { pending: true, slides, dwell: 10, token });
}
if (p === '/api/my/gauntlet/complete' && 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 = gauntletTokens.get(s.email);
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That tour is no longer open. Reload and try again.' });
if (Date.now() - t.ts < t.n * 10 * 1000 - 1500) return json(res, 400, { error: 'Give each site its ten seconds first.' });
gauntletTokens.delete(s.email);
await ads.grantWelcome(s.email);
return json(res, 200, { ok: true, credited: ads.rates().welcomeCredits || 0 });
}
// -- public banner wall
m = /^\/api\/wall\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m && req.method === 'GET') {
const tok = m[1].toLowerCase();
let a = await accounts.byUsername(tok);
if (!a) a = await accounts.byCode(tok);
if (!a) return json(res, 404, { error: 'No wall under that name.' });
const ladder = [a, ...await uplineSlides(a.email, 2)].slice(0, 3)
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl || null }));
const joinPath = '/join/' + (a.username || a.code);
let socials = null; try { socials = a.socials ? JSON.parse(a.socials) : null; } catch (e) {}
return json(res, 200, { name: a.username ? '@' + a.username : 'member #' + (a.memberId || 0),
avatarUrl: a.avatarUrl || null, bio: a.bio || null, socials,
joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), 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 });
}
// -- verified visits: view a member's site (new tab) for the dwell, pass a
// human check, and it counts as one guaranteed unique visit for the pack
if (p === '/api/my/visits' && 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.visitStatus(s.email);
if (status.count >= status.cap) return json(res, 200, { ad: null, status });
const ad = await ads.serveVisit(s.email);
if (!ad) return json(res, 200, { ad: null, status });
const token = crypto.randomBytes(16).toString('hex');
visitTokens.set(s.email, { token, ts: Date.now(), id: ad.id });
return json(res, 200, { ad, token, status });
}
if (p === '/api/my/visitchallenge' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const t = visitTokens.get(s.email);
if (!t || t.token !== String(u.searchParams.get('token') || '')) return json(res, 400, { error: 'That visit is no longer open.' });
const dwellMs = (ads.rates().visitDwellSeconds || 8) * 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) { visitTokens.delete(s.email); return json(res, 400, { error: 'That visit 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/visitdone' && 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 = visitTokens.get(s.email);
if (!t || t.token !== String(b.token || '')) return json(res, 400, { error: 'That visit did not check out. Load the next one.' });
const dwellMs = (ads.rates().visitDwellSeconds || 8) * 1000;
if (Date.now() - t.ts < dwellMs - 400) return json(res, 400, { error: 'Give the site the full visit first.' });
if (!t.challenge) return json(res, 400, { error: 'Finish the quick check first.', retry: true });
if (Number(b.answer) !== t.challenge.answer) { t.challenge = null; return json(res, 400, { error: 'Wrong pick.', retry: true }); }
visitTokens.delete(s.email);
const r = await ads.completeVisit(s.email, t.id);
if (r.error) return json(res, 400, r);
return json(res, 200, Object.assign(r, { status: await ads.visitStatus(s.email) }));
}
// -- onsite solo ads: member inbox with read rewards
if (p === '/api/my/inbox' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.inboxList(s.email);
const names = await accounts.namesForMembers([...new Set(r.items.map(i => i.fromMemberId).filter(Boolean))]);
for (const i of r.items) i.fromName = (i.fromMemberId && names[i.fromMemberId]) ? '@' + names[i.fromMemberId]
: i.fromMemberId ? 'member #' + i.fromMemberId : 'a member';
return json(res, 200, r);
}
m = /^\/api\/my\/inbox\/(\d+)$/.exec(p);
if (m && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.inboxOpen(s.email, m[1]);
if (!r.error) {
const names = r.fromMemberId ? await accounts.namesForMembers([r.fromMemberId]) : {};
r.fromName = (r.fromMemberId && names[r.fromMemberId]) ? '@' + names[r.fromMemberId]
: r.fromMemberId ? 'member #' + r.fromMemberId : 'a member';
}
return json(res, r.error ? 404 : 200, r);
}
// media upload for solo ads: raw body, size-capped, magic-byte verified
if (p === '/api/my/upload' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
'video/mp4': 'mp4', 'video/webm': 'webm' };
if (!EXT[ct]) return json(res, 400, { error: 'Use a PNG, JPG, WebP, GIF, MP4 or WebM file.' });
const isVideo = ct.startsWith('video/');
const key = s.email + ':' + new Date().toISOString().slice(0, 10);
if ((uploadCounts.get(key) || 0) >= 10) return json(res, 400, { error: 'Upload limit for today reached (10 files).' });
let buf;
try { buf = await readRaw(req, isVideo ? 25 * 1024 * 1024 : 3 * 1024 * 1024); }
catch (e) { return json(res, 400, { error: 'File too large. Images up to 3MB, video up to 25MB.' }); }
const magicOk = buf.length > 16 && (
(ct === 'image/png' && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) ||
(ct === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) ||
(ct === 'image/webp' && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') ||
(ct === 'image/gif' && buf.slice(0, 4).toString() === 'GIF8') ||
(ct === 'video/mp4' && buf.slice(4, 8).toString() === 'ftyp') ||
(ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3));
if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' });
const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct];
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
// video goes to DO Spaces when configured (keeps big files off the volume);
// images stay local. Falls back to the volume if Spaces isn't set or errors.
if (isVideo && spaces.enabled()) {
try {
const url = await spaces.put('iap-uploads/' + name, buf, ct);
return json(res, 200, { url, type: 'video' });
} catch (e) { console.error('spaces put', e.message); /* fall through to volume */ }
}
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
}
m = /^\/api\/my\/inbox\/(\d+)\/visit$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.markSoloVisit(s.email, m[1]);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/inbox\/(\d+)\/claim$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.claimSoloRead(s.email, m[1]);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/activity' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s) return json(res, 401, { error: 'Sign in first.' });
const id = s.memberId || await auth.refreshMemberId(s);
if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] });
const evs = chain.recentEvents(600);
return json(res, 200, {
memberId: id,
earnings: await attachNames(evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id))),
purchases: await attachNames(evs.filter(e => e.type === 'Purchase' && e.buyerId === id)),
referrals: await attachNames(evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id)))
});
}
// -- ad engine (spec §8b v1: banners, text, login ads)
if (p === '/api/ads/slot' && req.method === 'GET') {
const t = String(u.searchParams.get('type') || 'banner');
const ad = await ads.serve(t);
// login ads: the member clicks "Open Ad" (a real, counted click into a
// new tab) while the countdown runs on our interstitial page
if (ad && t === 'login') ad.dwell = ads.rates().loginDwellSeconds || 10;
return json(res, 200, { ad });
}
m = /^\/api\/ads\/click\/(\d+)$/.exec(p);
if (m && req.method === 'GET') {
const target = await ads.click(m[1]);
if (!target) { res.writeHead(404, baseHeaders()); return res.end(); }
res.writeHead(302, baseHeaders({ Location: target }));
return res.end();
}
if (p === '/api/my/campaigns' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
out.purchasedCredits = memberId ? await ads.availableCredits(memberId) : 0;
out.earnedCredits = await ads.earnedBalance(s.email);
out.availableCredits = out.purchasedCredits + out.earnedCredits;
return json(res, 200, out);
}
if (p === '/api/my/campaigns' && req.method === 'POST') {
const s = await auth.fromRequest(req);
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', 'video', 'featured'].includes(String(b.type || ''))) { // banner/text surf views frame the target; login/video/solo/featured 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 });
}
const r = await ads.createCampaign(s.email, memberId, b);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/topup$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const b = await readBody(req);
const r = await ads.topUpCampaign(s.email, memberId, m[1], b.credits);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.setStatus(s.email, m[1], m[2] === 'pause' ? 'paused' : 'active');
return json(res, r.error ? 400 : 200, r);
}
// -- admin (Bearer ADMIN_PASSWORD)
if (p === '/api/admin/burns' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { pending: await ads.pendingBurns() });
}
if (p === '/api/admin/burns/mark' && req.method === 'POST') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const r = await ads.markBurned(b.id, b.tx);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/admin/rates' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
return json(res, 200, { ok: true, rates: ads.setRates(b) });
}
if (p === '/api/admin/site' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const cur = siteConfig();
fs.writeFileSync(SITE_FILE, JSON.stringify(Object.assign(cur, b), null, 2));
return json(res, 200, { ok: true, site: siteConfig() });
}
if (p === '/api/admin/chain' && req.method === 'PATCH') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
const b = await readBody(req);
const file = path.join(DATA_DIR, 'config.json');
let cur = {}; try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
fs.writeFileSync(file, JSON.stringify(Object.assign(cur, b), null, 2));
chain.reloadConfig();
return json(res, 200, { ok: true, config: chain.getConfig() });
}
// -- pages
if (req.method === 'GET') {
if (p === '/') return sendFile(res, path.join(PUBLIC_DIR, 'index.html'));
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 (p === '/shorts') return sendFile(res, path.join(PUBLIC_DIR, 'shorts.html'));
if (/^\/view\/[a-f0-9]{32}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'view.html'));
m = /^\/uploads\/([a-z0-9]{24}\.(?:png|jpg|webp|gif|mp4|webm))$/.exec(p);
if (m) return sendFile(res, path.join(UPLOADS_DIR, m[1]));
if (/^\/tx\/0x[0-9a-fA-F]{64}$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, 'tx.html'));
m = /^\/wall\/([A-Za-z0-9_]{1,20})$/.exec(p);
if (m) { // server-inject per-member OG tags so shared bio links preview correctly (crawlers don't run JS)
try {
const tok = m[1].toLowerCase();
let a = await accounts.byUsername(tok); if (!a) a = await accounts.byCode(tok);
let html = fs.readFileSync(path.join(PUBLIC_DIR, 'wall.html'), 'utf8');
if (a) {
const nm = a.username ? '@' + a.username : 'member #' + (a.memberId || 0);
const esc = t => String(t || '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const title = nm + ' on InstantAdPay';
const desc = a.bio ? esc(a.bio).slice(0, 200) : 'Join ' + nm + '’s line on InstantAdPay — instant on-chain ad payouts, free to join.';
const img = a.avatarUrl && /^https:/.test(a.avatarUrl) ? a.avatarUrl : 'https://instantadpay.com/banners/iap-hero-1200x630.png';
const url = 'https://instantadpay.com/wall/' + (a.username || a.code);
const og = [
'<meta property="og:type" content="profile">',
'<meta property="og:site_name" content="InstantAdPay">',
'<meta property="og:url" content="' + url + '">',
'<meta property="og:title" content="' + esc(title) + '">',
'<meta property="og:description" content="' + desc + '">',
'<meta property="og:image" content="' + esc(img) + '">',
'<meta name="twitter:card" content="summary_large_image">',
'<meta name="twitter:title" content="' + esc(title) + '">',
'<meta name="twitter:description" content="' + desc + '">',
'<meta name="twitter:image" content="' + esc(img) + '">',
'<meta name="description" content="' + desc + '">',
'<link rel="canonical" href="' + url + '">'
].join('\n');
html = html.replace('<title>Banner wall | InstantAdPay</title>', '<title>' + esc(title) + '</title>').replace('<!--OG-->', og);
}
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' }));
return res.end(html);
} catch (e) { return sendFile(res, path.join(PUBLIC_DIR, 'wall.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);
}
res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' }));
res.end('Not found');
} catch (e) {
console.error('request error', req.url, e.message);
try { json(res, 500, { error: 'server error' }); } catch (_) {}
}
});
boot().then(() => {
server.listen(PORT, () => console.log(`InstantAdPay site on :${PORT} — chain: ${chain.getConfig().chainName} — store: ${db.enabled() ? 'MySQL' : 'volume JSON'}`));
}).catch(e => { console.error('boot failed:', e.message); process.exit(1); });