78e4c76a89
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2024 lines
128 KiB
JavaScript
2024 lines
128 KiB
JavaScript
// 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 sendy = require('./sendy');
|
||
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 reports = require('./reports');
|
||
const drip = require('./drip');
|
||
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 coach = require('./coach'); // coaching view, nudges, digest, prospects, link stats
|
||
const tank = require('./tank'); // holding tank: unsponsored free members, adoptions, pay-it-forward
|
||
const burner = require('./burner'); // automatic on-chain credit burns (inert without ENGINE_KEY)
|
||
|
||
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 ADMIN_EMAIL = String(process.env.ADMIN_EMAIL || '').trim().toLowerCase();
|
||
// Admin portal sessions: email-code sign-in allowlisted to ADMIN_EMAIL, kept
|
||
// in the volume so a restart doesn't log the admin out. Separate cookie and
|
||
// store from member sessions; the Bearer ADMIN_PASSWORD API path still works.
|
||
const ADMIN_SESS_FILE = path.join(DATA_DIR, 'admin-sessions.json');
|
||
const ADMIN_TTL = 12 * 60 * 60 * 1000;
|
||
let adminSessions = {};
|
||
try { adminSessions = JSON.parse(fs.readFileSync(ADMIN_SESS_FILE, 'utf8')) || {}; } catch (e) { adminSessions = {}; }
|
||
function saveAdminSessions() {
|
||
const now = Date.now();
|
||
for (const k of Object.keys(adminSessions)) if (!adminSessions[k] || adminSessions[k].expires < now) delete adminSessions[k];
|
||
try { fs.writeFileSync(ADMIN_SESS_FILE, JSON.stringify(adminSessions), { mode: 0o600 }); } catch (e) {}
|
||
}
|
||
function mintAdminSession(email) {
|
||
const t = crypto.randomBytes(32).toString('hex');
|
||
adminSessions[t] = { email, expires: Date.now() + ADMIN_TTL };
|
||
saveAdminSessions();
|
||
return t;
|
||
}
|
||
function adminTokenOf(req) { const m = /(?:^|;\s*)iap\.adm=([^;]+)/.exec(req.headers.cookie || ''); return m ? decodeURIComponent(m[1]) : null; }
|
||
function adminFromRequest(req) { const t = adminTokenOf(req); const s = t && adminSessions[t]; return (s && s.expires > Date.now()) ? s : null; }
|
||
function dropAdminSession(req) { const t = adminTokenOf(req); if (t && adminSessions[t]) { delete adminSessions[t]; saveAdminSessions(); } }
|
||
function adminCookie(t) { return 'iap.adm=' + encodeURIComponent(t) + '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' + (ADMIN_TTL / 1000) + (IS_PROD ? '; Secure' : ''); }
|
||
function clearAdminCookie() { return 'iap.adm=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; }
|
||
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 faucetHits = new Map(); // address -> last faucet ts (rehearsal test-POL faucet rate limit)
|
||
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;
|
||
}
|
||
// Wall ownership ladder: position 1 is always the member's own line banner;
|
||
// positions 2 and 3 become theirs at 2 and 5 qualifying buyers (the same
|
||
// thresholds that open payout levels 2 and 3). Until then, or while an unlocked
|
||
// slot is empty, the slot shows an upline's banner, then a house ad.
|
||
const catalogCache = { at: 0, products: null };
|
||
const wallUnlockedFor = bc => (bc >= 5 ? 3 : bc >= 2 ? 2 : 1);
|
||
// every on-chain member id this session controls: the main wallet plus linked
|
||
// positions (Qualified Start). Credits pool across them on the dashboard.
|
||
async function myMemberIds(s) {
|
||
const main = await auth.refreshMemberId(s);
|
||
const ids = main ? [main] : [];
|
||
if (s && s.email) for (const p of await accounts.positions(s.email)) if (p.memberId && !ids.includes(p.memberId)) ids.push(p.memberId);
|
||
return { main, ids };
|
||
}
|
||
function parseWallOffers(a) {
|
||
try { const v = JSON.parse((a && a.wallOffers) || '[]'); return Array.isArray(v) ? v.slice(0, 2) : []; } catch (e) { return []; }
|
||
}
|
||
// admin fallback ads for wall positions 2 & 3 when a member has no upline.
|
||
// Configurable by dropping data/admin-wall-ads.json ([{name,targetUrl,bannerUrl}]).
|
||
function getAdminWallAds() {
|
||
try { const j = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'admin-wall-ads.json'), 'utf8')); if (Array.isArray(j) && j.length) return j; } catch (e) {}
|
||
return [{ name: 'InstantAdPay', targetUrl: 'https://instantadpay.com/', bannerUrl: null }];
|
||
}
|
||
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();
|
||
// ── sign-up code guard (Marty, 2026-09-11): the email box is one field and one
|
||
// tap, so nothing visible stands in a human's way. Bots hit four invisible walls:
|
||
// a honeypot field, a minimum form age, per-IP + global send limits, and, only
|
||
// once an IP trips a limit, the same icon check the ad viewer uses.
|
||
const CODE_LIMITS = { per10m: 5, perDay: 20, globalPerMin: 60, passMs: 5 * 60 * 1000, minFormMs: 2000 };
|
||
const codeHits = new Map(); // ip -> { t: [send timestamps, 24h], passUntil, chal: { answer, exp } }
|
||
const codeGlobal = { minute: 0, n: 0 };
|
||
const codeAlert = { last: 0, trips: 0, ips: new Set() };
|
||
function clientIp(req) { return String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim() || 'unknown'; }
|
||
function codeChallenge(rec) {
|
||
const pick = CAPTCHA.slice().sort(() => Math.random() - 0.5).slice(0, 5);
|
||
const answer = Math.floor(Math.random() * pick.length);
|
||
rec.chal = { answer: pick[answer][0], exp: Date.now() + 5 * 60 * 1000 };
|
||
return { prompt: pick[answer][1], options: pick.map(x => x[0]) };
|
||
}
|
||
// returns null to allow the send, or { status, body } to answer with instead
|
||
function codeGuard(req, b) {
|
||
const now = Date.now();
|
||
if (b.website) return { status: 200, body: { ok: true, sent: true } }; // honeypot: bots fill it, humans never see it
|
||
const fts = Number(b.fts) || 0;
|
||
if (!fts || now - fts < CODE_LIMITS.minFormMs || now - fts > 12 * 3600 * 1000) return { status: 400, body: { error: 'Give the page a second, then tap again.' } };
|
||
const minute = Math.floor(now / 60000);
|
||
if (codeGlobal.minute !== minute) { codeGlobal.minute = minute; codeGlobal.n = 0; }
|
||
if (codeGlobal.n >= CODE_LIMITS.globalPerMin) { codeTrip(req, 'global'); return { status: 429, body: { error: 'Busy right now. Try again in a minute.' } }; }
|
||
const ip = clientIp(req);
|
||
const rec = codeHits.get(ip) || { t: [], passUntil: 0, chal: null };
|
||
rec.t = rec.t.filter(ts => now - ts < 24 * 3600 * 1000);
|
||
const n10 = rec.t.filter(ts => now - ts < 10 * 60 * 1000).length;
|
||
const limited = n10 >= CODE_LIMITS.per10m || rec.t.length >= CODE_LIMITS.perDay;
|
||
if (limited && now >= rec.passUntil) {
|
||
const pick = String(b.pick || '');
|
||
if (pick && rec.chal && rec.chal.exp > now && pick === rec.chal.answer) { rec.passUntil = now + CODE_LIMITS.passMs; rec.chal = null; }
|
||
else {
|
||
codeTrip(req, ip);
|
||
const challenge = codeChallenge(rec); codeHits.set(ip, rec);
|
||
return { status: 429, body: { error: pick ? 'That was not it. Try once more.' : 'Quick check before we send another code.', challenge } };
|
||
}
|
||
}
|
||
rec.t.push(now); codeHits.set(ip, rec); codeGlobal.n += 1;
|
||
if (codeHits.size > 5000) for (const [k, v] of codeHits) { if (!v.t.length || now - v.t[v.t.length - 1] > 24 * 3600 * 1000) codeHits.delete(k); }
|
||
return null;
|
||
}
|
||
// burst alert: at most one message per 10 minutes, to the admin Telegram chat if set, else the admin email
|
||
function codeTrip(req, ip) {
|
||
codeAlert.trips += 1; codeAlert.ips.add(ip);
|
||
if (Date.now() - codeAlert.last < 10 * 60 * 1000) return;
|
||
codeAlert.last = Date.now();
|
||
const text = '\u26A0\uFE0F InstantAdPay sign-up guard: ' + codeAlert.trips + ' blocked code request' + (codeAlert.trips === 1 ? '' : 's') + ' from ' + codeAlert.ips.size + ' source' + (codeAlert.ips.size === 1 ? '' : 's') + ' (' + [...codeAlert.ips].slice(0, 5).join(', ') + ') in the last window.';
|
||
codeAlert.trips = 0; codeAlert.ips = new Set();
|
||
const sc = siteConfig();
|
||
if (sc.telegramBotToken && sc.telegramAdminChatId) telegramSend(sc.telegramAdminChatId, text).catch(() => {});
|
||
else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: sign-up guard tripped', text).catch(() => {});
|
||
}
|
||
// 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') + ')' }));
|
||
});
|
||
});
|
||
}
|
||
// shared upload path for member creatives (/api/my/upload) and admin house-ad
|
||
// creatives (/api/admin/upload): `who` keys the per-day upload counter
|
||
async function handleUpload(req, res, who) {
|
||
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 = who + ':' + 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' });
|
||
}
|
||
// lead-capture page hooks (og tags + copy live in public/assets/join.js too)
|
||
const JOIN_ANGLES = {
|
||
instant: { t: 'Paid before the page reloads.', d: 'A smart contract on Polygon splits every ad package the moment it sells. Same transaction, real wallets, public ledger. Join free by email.' },
|
||
adspend: { t: 'You were buying ads anyway.', d: 'Here the ad spend in your line pays you, in the same transaction, on a public ledger. Seven formats, packages from $5. Join free.' },
|
||
free: { t: 'Watch first. Spend never.', d: 'Join free, view a few ads, earn credits, run your first campaign for zero dollars. Every payout public on Polygon.' },
|
||
ledger: { t: 'No back office. No payday.', d: 'Every payout is a public transaction on Polygon you can read yourself. Nothing is ever held. Join free by email.' },
|
||
two: { t: 'Two buyers open level two.', d: 'Every direct buyer pays you 50 percent from their first package. Two qualifying buyers open level two, five open level three. Written in a verified contract.' }
|
||
};
|
||
function serveJoinPage(res, tok, angle, ang, setCookies) {
|
||
let html;
|
||
try { html = fs.readFileSync(path.join(PUBLIC_DIR, 'join.html'), 'utf8'); } catch (e) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
|
||
const base = 'https://instantadpay.com';
|
||
const url = base + '/join/' + tok + (angle ? '?v=' + angle : '');
|
||
const title = ang ? ang.t : 'Advertise and earn. Paid on-chain, instantly.';
|
||
const desc = ang ? ang.d : 'You are invited to InstantAdPay: real ad packages with same-transaction payouts on Polygon, every payment public. Join free by email.';
|
||
const escA = t => String(t).replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||
const og = '<meta property="og:type" content="website"><meta property="og:site_name" content="InstantAdPay">'
|
||
+ '<meta property="og:title" content="' + escA(title) + '"><meta property="og:description" content="' + escA(desc) + '">'
|
||
+ '<meta property="og:url" content="' + escA(url) + '">' // keeps ?v= so shares stay on the angle
|
||
+ '<meta property="og:image" content="' + base + '/banners/iap-hero-1200x630.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">'
|
||
+ '<meta name="twitter:card" content="summary_large_image"><meta name="twitter:title" content="' + escA(title) + '"><meta name="twitter:description" content="' + escA(desc) + '"><meta name="twitter:image" content="' + base + '/banners/iap-hero-1200x630.png">';
|
||
html = html.replace(/<title>[^<]*<\/title>/, '<title>' + escA(title) + ' | InstantAdPay</title>' + og);
|
||
if (ang) html = html.replace('<body>', '<body class="squeeze">'); // angle pages: squeeze layout from the first paint
|
||
const headers = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' };
|
||
if (setCookies && setCookies.length) headers['Set-Cookie'] = setCookies;
|
||
res.writeHead(200, baseHeaders(headers));
|
||
res.end(html);
|
||
}
|
||
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)); emailOnEvent(ev).catch(() => {}); telegramOnEvent(ev).catch(() => {}); } });
|
||
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 });
|
||
reports.init({ dataDir: DATA_DIR });
|
||
drip.init({ dataDir: DATA_DIR, mailer, accounts, chain, site: 'https://instantadpay.com' });
|
||
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);
|
||
setInterval(() => ads.scheduleSweep().catch(e => console.error('schedule sweep', e.message)), 5 * 60 * 1000); // scheduled starts/ends
|
||
// follow-up email sequence: send whatever came due (every 10 min, first pass shortly after boot)
|
||
coach.init({ dataDir: DATA_DIR, chain, accounts, mailer });
|
||
tank.init({ dataDir: DATA_DIR, chain, accounts, messages, mailer, site: 'https://instantadpay.com' });
|
||
setInterval(() => tank.sweep().catch(e => console.error('tank sweep', e.message)), 60 * 60 * 1000); // adoptions past their 7-day window
|
||
burner.init({ chain, ads });
|
||
setTimeout(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 90 * 1000);
|
||
setInterval(() => coach.nudgeTick().catch(e => console.error('coach', e.message)), 60 * 60 * 1000);
|
||
setTimeout(() => burner.tick().catch(e => console.error('burner', e.message)), 45 * 1000);
|
||
setInterval(() => burner.tick().catch(e => console.error('burner', e.message)), 5 * 60 * 1000);
|
||
setTimeout(() => drip.tick().catch(e => console.error('drip', e.message)), 30 * 1000);
|
||
setInterval(() => drip.tick().catch(e => console.error('drip', e.message)), 10 * 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
|
||
// payment-proof Telegram feed (blank = off) and the P&L pane's fixed monthly cost
|
||
telegramBotToken: '', telegramChatId: '', telegramTopicId: '', telegramEvents: 'payouts', telegramCtaUrl: 'https://instantadpay.com/',
|
||
telegramAdminChatId: '', // private chat for admin alerts (sign-up guard bursts); falls back to ADMIN_EMAIL
|
||
launchAt: '', // public launch moment, ISO 8601 with offset (e.g. 2026-09-18T19:00:00-05:00): countdown on /launch + dashboard mark
|
||
pnlFixedMonthlyUsd: 0
|
||
}, 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' https://cdn.jsdelivr.net 'sha256-NzvNrqk5jB9YZATwo5BF4JoRlJ02HsnFikbKXgEPdaQ='; worker-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: blob: https:; media-src 'self' https: blob:; connect-src 'self' https://*.walletconnect.com wss://*.walletconnect.com https://*.walletconnect.org wss://*.walletconnect.org https://*.reown.com wss://*.reown.com https://*.reown.org wss://*.reown.org https://*.web3modal.org https://*.drpc.org https://*.publicnode.com https://*.coinbase.com; font-src 'self' data: https://fonts.gstatic.com https://fonts.reown.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',
|
||
// HTML is never stored (so a fresh load always gets the current asset
|
||
// versions — aggressive in-app wallet browsers were serving stale pages
|
||
// that pointed at old, since-fixed JS); versioned assets cache for an hour.
|
||
'Cache-Control': ext === '.html' ? 'no-store, must-revalidate' : '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 || '';
|
||
if (h === 'Bearer ' + ADMIN_PASSWORD) return true;
|
||
return !!adminFromRequest(req); // /admin portal session
|
||
}
|
||
// 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.
|
||
// Email a member's sponsor the moment they get a new referral (free OR paid).
|
||
// Resolves the sponsor from the join token by member id, share code, or username,
|
||
// and notifies EVERY sponsor — activated or not (an active sponsor still wants to
|
||
// know their team grew). A referral is on the line from signup; it only counts
|
||
// toward qualification once it makes a $20+ purchase.
|
||
async function notifyNewReferral(ref, newAcct) {
|
||
try {
|
||
if (!mailer.hasKey()) return;
|
||
const t = String(ref || '').trim().toLowerCase();
|
||
if (!t) return;
|
||
let owner = null;
|
||
if (/^\d+$/.test(t)) { try { owner = await accounts.byMemberId(Number(t)); } catch (e) {} }
|
||
if (!owner) { try { owner = await accounts.byCode(t); } catch (e) {} }
|
||
if (!owner) { try { owner = await accounts.byUsername(t); } catch (e) {} }
|
||
if (!owner || !owner.email) return;
|
||
const who = newAcct && newAcct.username ? '@' + newAcct.username : 'A new member';
|
||
let body = who + ' just joined InstantAdPay through your link — they are on your team from today.\n\n'
|
||
+ 'They count toward your qualification once they make a $20+ purchase.\n\n';
|
||
if (!owner.address) body += 'Make sure payouts are switched on (one free wallet step) so you never miss a commission — '
|
||
+ 'the contract locks each buyer to their sponsor at their first purchase.\n\n';
|
||
body += 'See your team: https://instantadpay.com/my\n\nInstantAdPay';
|
||
mailer.send(owner.email, 'You have a new referral on InstantAdPay', body)
|
||
.catch(e => console.error('referral notify failed', e.message));
|
||
} catch (e) {}
|
||
}
|
||
// welcome email on a new account: onboarding steps + who their sponsor is
|
||
async function sendWelcome(email, ref) {
|
||
try {
|
||
if (!mailer.hasKey()) return;
|
||
const spon = await accounts.sponsorOf(email);
|
||
const who = spon ? (spon.username ? '@' + spon.username : 'member #' + (spon.memberId || 0)) : '';
|
||
const sponsorLine = who ? ('You joined through ' + who + ', your sponsor. They are there to help you get started, and you can message them anytime from your dashboard.\n\n') : '';
|
||
mailer.send(email, 'Welcome to InstantAdPay',
|
||
'Your free InstantAdPay account is ready.\n\n' + sponsorLine
|
||
+ 'Getting started:\n'
|
||
+ '1. Pick your username and fill out your profile.\n'
|
||
+ '2. Grab your invite link and start sharing to build your line.\n'
|
||
+ '3. Explore the ad packages when you are ready. Every payout settles on-chain, straight to your wallet.\n\n'
|
||
+ 'Sign in anytime: https://instantadpay.com/my\n\nInstantAdPay').catch(() => {});
|
||
} catch (e) {}
|
||
}
|
||
// on-chain event emails: a payout received, or a payout that passed you by
|
||
const weiToPol = w => { try { return (Number(BigInt(w) / (10n ** 14n)) / 10000).toString(); } catch (e) { return '?'; } };
|
||
async function emailOnEvent(ev) {
|
||
if (!mailer.hasKey() || !ev) return;
|
||
const notify = async (memberId, subject, body) => {
|
||
if (!memberId) return;
|
||
const a = await accounts.byMemberId(memberId);
|
||
if (a && a.email) mailer.send(a.email, subject, body + '\n\nSee it on the live ledger: https://instantadpay.com/ledger\n\nInstantAdPay').catch(() => {});
|
||
};
|
||
if (ev.type === 'Purchase') {
|
||
const cc = chain.getConfig();
|
||
const txUrl = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx;
|
||
let bal = ev.creditAmount;
|
||
try { bal = await chain.creditBalance(ev.buyerId, ev.creditType); } catch (e) {}
|
||
await notify(ev.buyerId, 'Your InstantAdPay purchase is confirmed',
|
||
'Your purchase is complete and settled on-chain.\n\n' +
|
||
'Ad credits added: ' + ev.creditAmount + '\n' +
|
||
'Your ad-credit balance is now: ' + bal + '\n' +
|
||
'Amount paid: ' + weiToPol(ev.paidWei) + ' POL\n\n' +
|
||
'View your transaction on the blockchain:\n' + txUrl);
|
||
// tell the buyer's DIRECT sponsor their referral just bought (upline earners
|
||
// are separately notified by the TierPaid payout email when they earn)
|
||
try {
|
||
const buyer = await chain.member(ev.buyerId);
|
||
if (buyer && buyer.sponsorId) {
|
||
const sp = await accounts.byMemberId(buyer.sponsorId);
|
||
if (sp && sp.email) {
|
||
const ba = await accounts.byMemberId(ev.buyerId);
|
||
const bn = ba && ba.username ? '@' + ba.username : 'One of your referrals';
|
||
const usd = ('$' + (ev.priceCents / 100).toFixed(2)).replace(/\.00$/, '');
|
||
const qual = ev.priceCents >= 2000
|
||
? ' This is a $20+ purchase, so it counts toward your qualification.'
|
||
: ' (Purchases under $20 do not count toward qualification.)';
|
||
mailer.send(sp.email, 'Your referral just bought an ad package',
|
||
bn + ' just purchased a package (' + usd + ' — ' + ev.creditAmount + ' credits).' + qual + '\n\n' +
|
||
'See your team and the live ledger: https://instantadpay.com/my\n\nInstantAdPay').catch(() => {});
|
||
}
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
else if (ev.type === 'TierPaid') await notify(ev.recipientId, 'You just got paid on InstantAdPay', 'A level-' + ev.tier + ' payout of ' + weiToPol(ev.amountWei) + ' POL just landed in your wallet.');
|
||
else if (ev.type === 'AwardPaid') await notify(ev.toId, 'You just got paid on InstantAdPay', weiToPol(ev.amountWei) + ' POL just landed in your wallet.');
|
||
else if (ev.type === 'PassedUp' && ev.reason === 'send-failed') {
|
||
// the member WAS qualified but their wallet rejected the POL (usually a smart-contract
|
||
// wallet that needs more than the capped gas): tell them and the admin, loudly
|
||
await notify(ev.skippedId, 'Your wallet rejected a payout on InstantAdPay', 'A level-' + ev.tier + ' payout tried to reach your linked wallet and the wallet refused the transfer, so it passed to the next qualified member. This happens with some smart-contract wallets. Link a regular wallet address (MetaMask, SafePal, Phantom) on the Wallet tab so the next payout lands.');
|
||
if (ADMIN_EMAIL) mailer.send(ADMIN_EMAIL, 'InstantAdPay: payout send-failed for member #' + ev.skippedId, 'A level-' + ev.tier + ' payout to member #' + ev.skippedId + ' failed at the wallet (send-failed) and passed up. Tx: ' + ev.tx + '\n\nThe member has been emailed to link a regular wallet.').catch(() => {});
|
||
}
|
||
else if (ev.type === 'PassedUp') await notify(ev.skippedId, 'A payout passed you by on InstantAdPay', 'A level-' + ev.tier + ' payout passed you by because you were not qualified yet. Get qualified so you catch the next one.');
|
||
}
|
||
// payment-proof Telegram feed (same pattern as the RM Circle proof channel): one compact
|
||
// line per event, admin-configured under Settings > Site (telegramBotToken, telegramChatId,
|
||
// optional telegramTopicId, telegramEvents = payouts | payouts+purchases | all, telegramCtaUrl)
|
||
async function telegramOnEvent(ev) {
|
||
const sc = siteConfig();
|
||
if (!sc.telegramBotToken || !sc.telegramChatId || !ev) return;
|
||
const mode = String(sc.telegramEvents || 'payouts');
|
||
const names = await accounts.namesForMembers([ev.recipientId, ev.buyerId, ev.sponsorId, ev.newBuyerId, ev.id].filter(Boolean)).catch(() => ({}));
|
||
const who = id => '#' + id + (names[id] ? ' @' + names[id] : '');
|
||
const cc = chain.getConfig();
|
||
const tx = (cc.explorer ? cc.explorer.replace(/\/+$/, '') : 'https://polygonscan.com') + '/tx/' + ev.tx;
|
||
let line = null;
|
||
if (ev.type === 'TierPaid') line = '\u{1F4B8} Level ' + ev.tier + ' payout: <b>' + weiToPol(ev.amountWei) + ' POL</b> \u2192 ' + who(ev.recipientId);
|
||
else if (ev.type === 'BuyerCounted' && mode !== 'payouts') line = '\u2B50 ' + who(ev.sponsorId) + ' now has <b>' + ev.newCount + '</b> qualifying buyer' + (ev.newCount === 1 ? '' : 's') + (ev.newCount === 2 ? ' \u00b7 level 2 open' : ev.newCount === 5 ? ' \u00b7 level 3 open' : '');
|
||
else if (ev.type === 'Purchase' && mode !== 'payouts') line = '\u{1F9FE} ' + who(ev.buyerId) + ' bought a $' + Math.round(ev.priceCents / 100) + ' package';
|
||
else if (ev.type === 'MemberActivated' && mode === 'all') line = '\u{1F91D} ' + who(ev.id) + ' switched on payouts';
|
||
if (!line) return;
|
||
const text = line + ' \u00b7 <a href="' + tx + '">verify</a>' + (sc.telegramCtaUrl ? '\n<a href="' + sc.telegramCtaUrl + '">Join free</a>' : '');
|
||
await telegramSend(sc.telegramChatId, text, sc.telegramTopicId);
|
||
}
|
||
// one sendMessage call; never throws, never logs the token
|
||
async function telegramSend(chatId, text, threadId) {
|
||
const sc = siteConfig();
|
||
if (!sc.telegramBotToken || !chatId) return;
|
||
const body = JSON.stringify(Object.assign({ chat_id: chatId, text, parse_mode: 'HTML', disable_web_page_preview: true }, threadId ? { message_thread_id: Number(threadId) } : {}));
|
||
await new Promise((resolve) => {
|
||
const rq = https.request({ hostname: 'api.telegram.org', path: '/bot' + sc.telegramBotToken + '/sendMessage', method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 10000 }, r => { r.resume(); r.on('end', resolve); });
|
||
rq.on('error', () => resolve()); rq.on('timeout', () => { rq.destroy(); resolve(); }); rq.end(body);
|
||
});
|
||
}
|
||
|
||
// ---- 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> — LAST-touch cookie (Marty,
|
||
// 2026-09-10): the link a visitor opened most recently is the sponsor shown
|
||
// and used, and it locks the moment the account is created (accounts.ensure
|
||
// never changes an existing account's sponsor; the contract binds the buyer at
|
||
// their first purchase). 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' || req.method === 'HEAD')) {
|
||
// lead-capture page: email first, wallet later. ?v=<angle> picks the hook
|
||
// copy and is remembered so the account records which angle converted.
|
||
const tok = m[1].toLowerCase();
|
||
const cookies = parseCookies(req);
|
||
const angle = String(u.searchParams.get('v') || '').toLowerCase();
|
||
const ang = JOIN_ANGLES[angle] || null;
|
||
if (req.method === 'GET') coach.recordView(tok, ang ? angle : '', req.headers.referer); // link stats per angle + source
|
||
const cookieTail = `; Path=/; SameSite=Lax; Max-Age=${30 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`; // 30 days: whoever brings them back gets the credit
|
||
const set = [];
|
||
set.push('iap.sponsor=' + tok + cookieTail); // last touch wins
|
||
if (ang) set.push('iap.angle=' + angle + cookieTail);
|
||
if (!cookies['iap.ref']) set.push('iap.ref=' + encodeURIComponent(coach.refHost(req.headers.referer)) + cookieTail); // first-touch source
|
||
return serveJoinPage(res, tok, ang ? angle : '', ang, set);
|
||
}
|
||
if (p === '/unsubscribe' && req.method === 'GET') {
|
||
const r = await drip.unsubscribe(u.searchParams.get('e'), u.searchParams.get('t'));
|
||
const msg = r.error ? r.error : 'Done. You will not get any more follow-up emails from InstantAdPay. Your account is unchanged.';
|
||
res.writeHead(r.error ? 400 : 200, baseHeaders({ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }));
|
||
return res.end('<!doctype html><html><head><meta charset="utf-8"><title>InstantAdPay</title><link rel="stylesheet" href="/assets/site.css?v=20260909a"></head><body><div class="wrap" style="max-width:560px;padding:80px 22px"><a href="/"><img src="/logo.png" alt="InstantAdPay" style="height:34px"></a><h1 style="font-size:30px;margin:26px 0 12px">' + (r.error ? 'Hmm.' : 'Unsubscribed.') + '</h1><p>' + msg + '</p><p><a class="btn small sec" href="/my">Member area</a></p></div></body></html>');
|
||
}
|
||
|
||
// -- public API
|
||
if (p === '/api/config' && req.method === 'GET') {
|
||
const c = chain.getConfig();
|
||
// public copy of the site settings: never anything that looks like a credential
|
||
const pubSite = {};
|
||
for (const [k, v] of Object.entries(siteConfig())) if (!/secret|token|password|private|apikey|api_key/i.test(k)) pubSite[k] = v;
|
||
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 }, pubSite));
|
||
}
|
||
if (p === '/api/moonpay-url' && req.method === 'GET') {
|
||
// Card on-ramp deep link. With MoonPay keys set — PUBLIC key via
|
||
// MOONPAY_PUBLIC_KEY env or site config, SECRET key via MOONPAY_SECRET_KEY
|
||
// env ONLY (never site config, since /api/config exposes siteConfig) —
|
||
// returns a SIGNED checkout URL prefilled with the buyer's own wallet and
|
||
// a POL amount; otherwise a generic MoonPay buy page. Zero custody either
|
||
// way: MoonPay is merchant of record and the crypto goes straight to the
|
||
// buyer's wallet — this site never touches or holds anyone's money.
|
||
const addr = (u.searchParams.get('address') || '').trim();
|
||
let pol = Math.round(Number(u.searchParams.get('pol')) || 0);
|
||
if (!pol || pol < 30) pol = 30;
|
||
if (pol > 100000) pol = 100000;
|
||
const pk = (process.env.MOONPAY_PUBLIC_KEY || siteConfig().moonpayPublicKey || '').trim();
|
||
const sk = (process.env.MOONPAY_SECRET_KEY || '').trim();
|
||
if (pk && sk && /^0x[0-9a-fA-F]{40}$/.test(addr)) {
|
||
const qs = '?apiKey=' + encodeURIComponent(pk) + '¤cyCode=pol_polygon&walletAddress=' + encodeURIComponent(addr) + '"eCurrencyAmount=' + pol;
|
||
const sig = crypto.createHmac('sha256', sk).update(qs).digest('base64');
|
||
return json(res, 200, { url: 'https://buy.moonpay.com/' + qs + '&signature=' + encodeURIComponent(sig), signed: true, pol });
|
||
}
|
||
return json(res, 200, { url: 'https://www.moonpay.com/buy/matic', signed: false, pol }); // MoonPay's Polygon page still lives at the old slug; /buy/pol renders their 404 (same fix as RM Circle)
|
||
}
|
||
if (p === '/api/catalog' && req.method === 'GET') {
|
||
// five live quotes per call: serve a 20-second cache so pages that load the
|
||
// ladder (join, home, buy) are not waiting on the RPC every time
|
||
if (!catalogCache.at || Date.now() - catalogCache.at > 20000) {
|
||
try { catalogCache.products = await chain.catalog(); catalogCache.at = Date.now(); }
|
||
catch (e) { if (!catalogCache.products) throw e; }
|
||
}
|
||
return json(res, 200, { products: catalogCache.products });
|
||
}
|
||
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') {
|
||
// The account's stored sponsor is authoritative — it persists across
|
||
// devices, cleared cookies, and return visits. Fall back to the first-touch
|
||
// cookie only for anonymous visitors with no account sponsor yet. (Reading
|
||
// the cookie alone was orphaning buyers to root when the cookie was absent.)
|
||
const s = await auth.fromRequest(req);
|
||
const acct = s && s.email ? await accounts.byEmail(s.email) : null;
|
||
const tok = (acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor'] || '';
|
||
let sponsorId = await resolveSponsorToken(tok);
|
||
// orphan fallback: an unresolvable/absent sponsor (dead link, no link) lands
|
||
// the new member under the configured catch position (#1) instead of root
|
||
if (!sponsorId && (!acct || acct.memberId !== (Number(siteConfig().defaultSponsorId) || 1))) sponsorId = Number(siteConfig().defaultSponsorId) || 1;
|
||
let name = null, avatarUrl = null;
|
||
if (tok) {
|
||
const t = tok.toLowerCase();
|
||
let a = await accounts.byCode(t); if (!a) a = await accounts.byUsername(t);
|
||
if (!a && /^\d+$/.test(tok)) a = await accounts.byMemberId(Number(tok));
|
||
if (a) { name = a.username ? '@' + a.username : (a.memberId ? 'member #' + a.memberId : null); avatarUrl = a.avatarUrl || null; }
|
||
}
|
||
return json(res, 200, { ref: tok, sponsorId, invited: !!tok, name, avatarUrl });
|
||
}
|
||
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'] || ''; // last-touch attribution, locked at account creation
|
||
const r = await accounts.signup(b.email, b.password, ref);
|
||
if (r.error) return json(res, 400, r);
|
||
// sponsor is notified once the new member picks a username (onboarding),
|
||
// so the email can name them — see /api/my/profile
|
||
if (b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent
|
||
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 guard = codeGuard(req, b); // honeypot, form age, per-IP + global limits, icon check once limited
|
||
if (guard) return json(res, guard.status, guard.body);
|
||
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 via = parseCookies(req)['iap.angle'] || '';
|
||
const joinedRef = decodeURIComponent(parseCookies(req)['iap.ref'] || '') || null;
|
||
const r = await accounts.ensure(e, ref, via, joinedRef); // first touch wins; existing accounts unchanged
|
||
if (r.error) return json(res, 400, r);
|
||
// the lead is in the door: queue the getting-started sequence (opt-in box is pre-checked on both forms)
|
||
if (r.created && (b.followups || b.newsletter)) drip.enqueue(e, ref, via).catch(() => {});
|
||
// a wallet-only session (signed with a wallet, no account) finishing setup:
|
||
// adopt that wallet into the email account so member #, purchases and
|
||
// payouts stay attached, then retire the wallet-only session
|
||
const prior = await auth.fromRequest(req);
|
||
if (prior && prior.address && !prior.email) {
|
||
const lr = await accounts.linkWallet(e, prior.address);
|
||
if (lr.error) return json(res, 400, lr);
|
||
r.account = lr.account || await accounts.byEmail(e);
|
||
await auth.logout(req);
|
||
}
|
||
if (r.created) { sendWelcome(e, ref).catch(() => {}); } // sponsor notified at username set (/api/my/profile)
|
||
if (r.created && b.newsletter) sendy.subscribe(r.account.email, r.account.username || '').catch(() => {}); // pre-checked opt-in, silent, new joins only
|
||
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, created: !!r.created }, { 'Set-Cookie': auth.sessionCookie(token) });
|
||
}
|
||
|
||
// -- wallet auth: link-to-account when an email session exists, or
|
||
// wallet-first sign-in (no UI door since 2026-09-09; a wallet-only session
|
||
// is walked to the email card, which adopts the wallet on verify)
|
||
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 && b.asPosition) {
|
||
// Qualified Start: a second (third…) wallet on the same account. It becomes
|
||
// its own on-chain member under this member's id when it buys; the session
|
||
// stays on the main wallet.
|
||
// policy (Marty, 2026-09-10): positions exist to qualify, so at most five per account, and a
|
||
// wallet that is already registered on-chain under anything other than this account's main
|
||
// member is refused (a position-under-position chain would recapture 70% of a self-buy)
|
||
const MAX_POS = 5;
|
||
const have = await accounts.positions(s.email);
|
||
if (!have.find(p => p.address === r.address.toLowerCase()) && have.length >= MAX_POS)
|
||
return json(res, 400, { error: 'You can link up to ' + MAX_POS + ' positions, which is everything Qualified Start needs. Once qualified, buy from your main wallet so your sponsor is paid in full.' });
|
||
if (memberId) {
|
||
const mainId = await auth.refreshMemberId(s);
|
||
let mm = null; try { mm = await chain.member(memberId); } catch (e) {}
|
||
if (mm && mainId && mm.sponsorId !== mainId)
|
||
return json(res, 400, { error: 'That wallet is already registered on-chain under ' + (mm.sponsorId ? 'member #' + mm.sponsorId : 'no sponsor') + ', not under your main member #' + mainId + '. Only positions registered directly under you can be linked.' });
|
||
}
|
||
const pr = await accounts.addPosition(s.email, r.address);
|
||
if (pr.error) return json(res, 400, pr);
|
||
if (memberId) await accounts.setPositionMember(r.address, memberId);
|
||
return json(res, 200, { ok: true, position: true, address: r.address, memberId });
|
||
}
|
||
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);
|
||
if (!acct && await accounts.positionOwner(r.address))
|
||
return json(res, 400, { error: 'That wallet is a linked position on an account. Sign in with that account\'s email instead.' });
|
||
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/gas' && req.method === 'GET') {
|
||
try { return json(res, 200, await chain.suggestedFees()); }
|
||
catch (e) { return json(res, 200, {}); }
|
||
}
|
||
|
||
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;
|
||
let sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
|
||
const _defSpon = Number(siteConfig().defaultSponsorId) || 1;
|
||
if (!sponsorId && memberId !== _defSpon) sponsorId = _defSpon; // orphan fallback → #1 (never self-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,
|
||
// profile + line-banner fields so the Profile pane repopulates on reload (were being saved but not returned)
|
||
avatarUrl: (acct && acct.avatarUrl) || null, bio: (acct && acct.bio) || null,
|
||
socials: (acct && acct.socials) || null,
|
||
wallOffers: parseWallOffers(acct),
|
||
lineBannerUrl: (acct && acct.lineBannerUrl) || null, lineTargetUrl: (acct && acct.lineTargetUrl) || null };
|
||
if (memberId) {
|
||
try {
|
||
const mm = await chain.member(memberId);
|
||
out.buyerCount = mm.buyerCount;
|
||
out.onchainSponsorId = mm.sponsorId;
|
||
const bal = await ads.balances((await myMemberIds(s)).ids, s.email);
|
||
out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available;
|
||
} 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) { await ads.grantWelcome(out.email); out.welcomeCredits = ads.rates().welcomeCredits || 0; } // the welcome amount itself; the rest of the earned pool is viewing rewards, milestones and credits we add
|
||
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 };
|
||
}
|
||
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;
|
||
const bal = await ads.balances((await myMemberIds(s)).ids, s.email);
|
||
out.credits = bal.total; out.creditedCredits = bal.credited; out.earnedCredits = bal.earned; out.inCampaigns = bal.inCampaigns; out.availableCredits = bal.available;
|
||
} 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;
|
||
}
|
||
// achievement milestones (same ladder as the Overview stepper) + one-time credit
|
||
// bonuses — computed AFTER buyerCount is read from chain above (else always 0)
|
||
{
|
||
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);
|
||
}
|
||
// who joined through this member: their invite link uses username when set,
|
||
// else code, and the numeric id once on-chain — match all three
|
||
const refs = [];
|
||
if (acct && acct.code) refs.push(acct.code);
|
||
if (acct && acct.username) refs.push(acct.username);
|
||
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'
|
||
}));
|
||
out.isAdmin = !!(ADMIN_EMAIL && out.email && String(out.email).toLowerCase() === ADMIN_EMAIL); // shows the Admin link
|
||
out.wallUnlocked = wallUnlockedFor(out.buyerCount || 0); // how many wall positions are the member's own
|
||
return json(res, 200, out);
|
||
}
|
||
// -- linked positions (Qualified Start): list, refresh from chain, unlink
|
||
if (p === '/api/my/positions' && req.method === 'GET') {
|
||
const s = await auth.fromRequest(req);
|
||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||
const acct = await accounts.byEmail(s.email);
|
||
const mainId = await auth.refreshMemberId(s);
|
||
const list = await accounts.positions(s.email);
|
||
const out = [];
|
||
const posIds = [mainId, ...list.map(p => p.memberId)].filter(Boolean);
|
||
let balPer = {}, credited = 0; try { const bb = await ads.balances(posIds, s.email); credited = bb.credited; for (const p of bb.per) balPer[p.memberId] = p.avail; } catch (e) {}
|
||
for (const pos of list) {
|
||
let id = pos.memberId;
|
||
if (!id) { try { id = await chain.memberIdByAccount(pos.address); if (id) await accounts.setPositionMember(pos.address, id); } catch (e) {} }
|
||
const row = { address: pos.address, memberId: id || 0, buyerCount: 0, counted: false, credits: 0, created: pos.created };
|
||
if (id) {
|
||
try { const mm = await chain.member(id); row.buyerCount = mm.buyerCount; row.counted = mm.countedAsBuyer; row.sponsorId = mm.sponsorId; } catch (e) {}
|
||
row.credits = balPer[id] != null ? balPer[id] : 0;
|
||
}
|
||
out.push(row);
|
||
}
|
||
const main = { address: (acct && acct.address) || null, memberId: mainId, credits: 0, buyerCount: 0 };
|
||
if (mainId) {
|
||
main.credits = balPer[mainId] != null ? balPer[mainId] : 0;
|
||
try { main.buyerCount = (await chain.member(mainId)).buyerCount; } catch (e) {}
|
||
}
|
||
// live POL balances (the link signature proved the wallet is theirs) + a POL/USD rate from the catalog
|
||
const bal = async a => { try { return BigInt(await chain.rpc('eth_getBalance', [a, 'latest'])).toString(); } catch (e) { return null; } };
|
||
if (main.address) main.balanceWei = await bal(main.address);
|
||
for (const row of out) row.balanceWei = await bal(row.address);
|
||
let polUsd = 0; try { const cat = await chain.catalog(); const pk = cat.find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {}
|
||
return json(res, 200, { main, positions: out, polUsd, totalCredits: main.credits + out.reduce((n, r) => n + r.credits, 0), credited });
|
||
}
|
||
if (p === '/api/my/positions/remove' && 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.removePosition(s.email, b.address);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
// -- coaching: every direct's ladder rung, stalled flag, and what to say
|
||
// -- holding tank: waiting members, my adoptions, adopt, release (pay it forward)
|
||
if (p === '/api/my/tank' && 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 tank.view(s.email));
|
||
}
|
||
if (p === '/api/my/tank/adopt' && 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 tank.adopt(s.email, b.who, b.note);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/my/tank/release' && 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 tank.release(s.email, b.email);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/my/tank/contacted' && req.method === 'POST') { // sponsor reached the lead off-site: reset the rescue clock
|
||
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 tank.markContacted(s.email, b.email);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/my/gift' && req.method === 'POST') { // PIF: log a wallet-to-wallet POL gift and tell the recipient
|
||
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 tank.recordGift(s.email, b.email, b.tx, b.pol);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/admin/tank' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, await tank.adminView());
|
||
}
|
||
if (p === '/api/my/coach' && req.method === 'GET') {
|
||
const s = await auth.fromRequest(req);
|
||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||
const cv = await coach.coachView(s.email);
|
||
for (const d of cv.directs) if (d.free) { try { d.rescue = await tank.rescueInfo(s.email, await accounts.byEmail(d.email)); } catch (e) {} } // dormant-lead clock
|
||
return json(res, 200, cv);
|
||
}
|
||
// -- link stats: views, joins and buyers per angle link
|
||
if (p === '/api/my/linkstats' && 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 coach.linkStats(s.email));
|
||
}
|
||
// -- prospects: the member's own follow-up list
|
||
if (p === '/api/my/prospects' && 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, { prospects: await coach.prospects(s.email), statuses: coach.STATUSES });
|
||
}
|
||
if (p === '/api/my/prospects' && req.method === 'POST') {
|
||
const s = await auth.fromRequest(req);
|
||
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||
const r = await coach.saveProspect(s.email, await readBody(req));
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/my/prospects/remove' && 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 coach.removeProspect(s.email, b.id);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
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 before = await accounts.byEmail(s.email);
|
||
// a username is permanent once set: the invite link, the public wall and every
|
||
// banner already printed carry it (Marty, 2026-09-10)
|
||
if (before && before.username && String(b.username || '').trim().toLowerCase() !== String(before.username).toLowerCase())
|
||
return json(res, 400, { error: 'Your username is locked. Your invite link, your public page and any banners you shared all carry @' + before.username + '. Contact support if it truly has to change.' });
|
||
const r = await accounts.setUsername(s.email, b.username);
|
||
// First time a username is set (onboarding): now there's a real name to
|
||
// show, so notify the sponsor here rather than at signup (where it'd just
|
||
// say "a new member"). Fires once — only on the empty→set transition.
|
||
if (!r.error && before && !before.username && b.username) {
|
||
const acct = await accounts.byEmail(s.email);
|
||
notifyNewReferral(acct && acct.sponsorRef, acct).catch(() => {});
|
||
}
|
||
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 || 10 });
|
||
}
|
||
// 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 || 10) * 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 || 10) * 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);
|
||
// what each person has paid THIS member so far: sum of TierPaid events where
|
||
// this member is the recipient and that person is the buyer (all indexed events)
|
||
const myId = await auth.refreshMemberId(s);
|
||
const earnedBy = {};
|
||
if (myId) {
|
||
for (const ev of chain.recentEvents(1e9)) {
|
||
if (ev.type === 'TierPaid' && ev.recipientId === myId && ev.buyerId)
|
||
earnedBy[ev.buyerId] = (BigInt(earnedBy[ev.buyerId] || '0') + BigInt(ev.amountWei)).toString();
|
||
}
|
||
}
|
||
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,
|
||
earnedWei: (m.memberId && earnedBy[m.memberId]) || '0' })) }));
|
||
// the member's own linked positions sit on level 1 too, labelled as theirs
|
||
const own = (await accounts.positions(s.email)).filter(p => p.memberId);
|
||
if (own.length) {
|
||
if (!out.find(L => L.level === 1)) out.unshift({ level: 1, members: [] });
|
||
const L1 = out.find(L => L.level === 1);
|
||
own.forEach((p, i) => L1.members.push({ memberId: p.memberId, name: 'You · position ' + (i + 2), own: true,
|
||
email: null, joined: p.created, earnedWei: earnedBy[p.memberId] || '0' }));
|
||
}
|
||
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) return json(res, 200, { ok: true, chatUnread: 0 });
|
||
accounts.touchSeen(s.email).catch(() => {});
|
||
return json(res, 200, { ok: true, chatUnread: await messages.chatUnread(s.email) });
|
||
}
|
||
// training center content (admin-curated via data/training.json)
|
||
if (p === '/api/training' && req.method === 'GET') {
|
||
const s = await auth.fromRequest(req);
|
||
if (!s) return json(res, 401, { error: 'Sign in first.' });
|
||
let items = [];
|
||
try { const j = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'training.json'), 'utf8')); if (Array.isArray(j)) items = j; } catch (e) {}
|
||
if (!items.length) items = [{ title: 'Getting started with InstantAdPay',
|
||
desc: 'How the platform works, how every payout splits on-chain to real wallets, and how to build your line.',
|
||
docUrl: 'https://instantadpay.com/' }];
|
||
return json(res, 200, { items });
|
||
}
|
||
// daily login bonus (once/day, gentle streak) — granted after the login flow
|
||
if (p === '/api/my/login-bonus' && 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 ads.grantLoginBonus(s.email));
|
||
}
|
||
// -- rehearsal test-POL faucet: top a connected wallet up to 10 test-POL
|
||
// (anvil_setBalance, no key needed). Rehearsal-only, rate-limited.
|
||
if (p === '/api/my/faucet' && req.method === 'POST') {
|
||
const s = await auth.fromRequest(req);
|
||
if (!s) return json(res, 401, { error: 'Sign in first.' });
|
||
if (!siteConfig().rehearsal) return json(res, 400, { error: 'The faucet is only open during the rehearsal.' });
|
||
const b = await readBody(req);
|
||
const addr = String(b.address || '').trim().toLowerCase();
|
||
if (!/^0x[0-9a-f]{40}$/.test(addr)) return json(res, 400, { error: 'Connect your wallet first.' });
|
||
// Amoy is a public testnet: testers fund their own connected wallet from
|
||
// the public Amoy faucet (no server-minted balance). We just echo the
|
||
// address + faucet link; the client copies the address and opens it.
|
||
let balHex = '0x0'; try { balHex = await chain.rpc('eth_getBalance', [addr, 'latest']); } catch (e) {}
|
||
return json(res, 200, { ok: true, faucetUrl: 'https://faucet.polygon.technology/', address: addr, balanceWei: BigInt(balHex || '0x0').toString() });
|
||
}
|
||
// -- report an ad (auto-approved ads need a safety valve): store + notify admin
|
||
if (p === '/api/report-ad' && req.method === 'POST') {
|
||
const b = await readBody(req);
|
||
if (!Number(b.campaignId)) return json(res, 400, { error: 'Which ad?' });
|
||
const s = await auth.fromRequest(req);
|
||
const who = (s && s.email) || '';
|
||
const rec = await reports.add(b.campaignId, who, b.reason, b.note);
|
||
try {
|
||
const adminEmail = process.env.ADMIN_EMAIL || ''; // private env only — siteConfig is exposed via /api/config
|
||
if (adminEmail && mailer.hasKey()) {
|
||
mailer.send(adminEmail, 'Ad reported on InstantAdPay (campaign #' + rec.campaignId + ')',
|
||
'A member flagged an ad.\n\nCampaign: #' + rec.campaignId + '\nReason: ' + rec.reason
|
||
+ '\nReported by: ' + (who || 'anonymous') + '\nNote: ' + (String(b.note || '').slice(0, 500) || '(none)')
|
||
+ '\n\nPause or review it from the admin.').catch(() => {});
|
||
}
|
||
} catch (e) {}
|
||
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: '#0e7d5f', light: '#f2fbf8' } }); // mint-green modules, still high-contrast for reliable scanning
|
||
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', 'video'];
|
||
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 <= 300) clean[p] = v;
|
||
}
|
||
// intro video on the public wall: a YouTube, Vimeo or direct .mp4/.webm link only
|
||
if (clean.video && !/^https:\/\/((www\.|m\.)?youtube\.com\/(watch\?|shorts\/|embed\/)|youtu\.be\/|(www\.)?vimeo\.com\/\d+|player\.vimeo\.com\/video\/\d+|[^\s]+\.(mp4|webm)(\?|$))/i.test(clean.video))
|
||
return json(res, 400, { error: 'The intro video needs to be a YouTube link, a Vimeo link, or a direct .mp4 link.' });
|
||
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);
|
||
}
|
||
// -- wall positions 2 & 3: the member's own offers, unlocked at 2 / 5 qualifying buyers
|
||
if (p === '/api/my/wall-offers' && 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 src = Array.isArray(b.offers) ? b.offers.slice(0, 2) : [];
|
||
const out = [];
|
||
for (let i = 0; i < 2; i++) {
|
||
const o = src[i] || {};
|
||
const targetUrl = String(o.targetUrl || '').trim();
|
||
const bannerUrl = String(o.bannerUrl || '').trim();
|
||
const title = String(o.title || '').trim().slice(0, 60);
|
||
if (targetUrl && !/^https:\/\/[^\s]+$/i.test(targetUrl)) return json(res, 400, { error: 'Position ' + (i + 2) + ': the link must start with https://' });
|
||
if (bannerUrl && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(bannerUrl)) return json(res, 400, { error: 'Position ' + (i + 2) + ': banner must be an uploaded image or an https image URL.' });
|
||
out.push(targetUrl ? { title: title || null, bannerUrl: bannerUrl || null, targetUrl } : null);
|
||
}
|
||
const r = await accounts.setWallOffers(s.email, out.some(Boolean) ? JSON.stringify(out) : null);
|
||
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 ownName = a.username ? '@' + a.username : a.memberId ? 'member #' + a.memberId : 'a member';
|
||
let bc = 0;
|
||
if (a.memberId) { try { bc = (await chain.member(a.memberId)).buyerCount || 0; } catch (e) {} }
|
||
const unlocked = wallUnlockedFor(bc);
|
||
const offers = parseWallOffers(a);
|
||
// uplines with a live banner, in order (an upline with nothing set is skipped, not shown empty)
|
||
const ups = (await uplineSlides(a.email, 2)).filter(x => x.lineTargetUrl)
|
||
.map(x => ({ name: x.username ? '@' + x.username : x.memberId ? 'member #' + x.memberId : 'a member',
|
||
bannerUrl: x.lineBannerUrl || null, targetUrl: x.lineTargetUrl, upline: true }));
|
||
const adAds = getAdminWallAds(); let ai = 0;
|
||
const houseAd = () => { if (!adAds.length) return null; const ad = adAds[ai++ % adAds.length]; return { name: ad.name || 'InstantAdPay', bannerUrl: ad.bannerUrl || null, targetUrl: ad.targetUrl || 'https://instantadpay.com/', admin: true }; };
|
||
const ladder = [{ name: ownName, bannerUrl: a.lineBannerUrl || null, targetUrl: a.lineTargetUrl || null, own: true }];
|
||
for (let i = 1; i < 3; i++) {
|
||
const o = offers[i - 1];
|
||
if (i < unlocked && o && o.targetUrl) { ladder.push({ name: (o.title || ownName), bannerUrl: o.bannerUrl || null, targetUrl: o.targetUrl, own: true }); continue; }
|
||
const u = ups.shift(); if (u) { ladder.push(u); continue; }
|
||
const h = houseAd(); if (h) ladder.push(h);
|
||
}
|
||
const finalLadder = ladder.slice(0, 3);
|
||
// the wall owner's achievement badge (their highest reached tier)
|
||
let badge = null;
|
||
if (a.memberId) {
|
||
const t = bc >= 5 ? ['nexus', 'Nexus'] : bc >= 2 ? ['circuit', 'Circuit'] : bc >= 1 ? ['surge', 'Surge'] : ['spark', 'Spark'];
|
||
badge = { img: '/badges/badge-' + t[0] + '.jpg?v=2', label: t[1] };
|
||
}
|
||
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, badge,
|
||
joinUrl: joinPath, qrUrl: '/api/qr?d=' + encodeURIComponent('https://instantadpay.com' + joinPath), ladder: finalLadder, unlocked, buyerCount: bc });
|
||
}
|
||
// -- 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 orientation = String(u.searchParams.get('orientation') || ''); // 'portrait' = Shorts reel, 'landscape' = Watch videos tab
|
||
const ad = await ads.serveVideo({ excludeEmail: s.email, orientation }); // 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
|
||
if (await ads.hasWatchedVideoToday(s.email, t.id)) // once-per-day-per-video: no double earning
|
||
return json(res, 200, { ok: true, credited: 0, status: await ads.videoStatus(s.email), already: true });
|
||
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);
|
||
await ads.markVideoSeen(s.email, t.id);
|
||
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.' });
|
||
return handleUpload(req, res, s.email);
|
||
}
|
||
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, { width: Number(u.searchParams.get('w')) || 0, height: Number(u.searchParams.get('h')) || 0 });
|
||
// 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(); }
|
||
coach.recordClick(m[1], req.headers.referer); // where the click happened
|
||
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.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
|
||
out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days
|
||
const pool = await ads.balances((await myMemberIds(s)).ids, s.email);
|
||
out.purchasedCredits = pool.total;
|
||
out.largestPosition = pool.best.avail; // a single campaign budget has to fit one position
|
||
out.positionCount = pool.per.length;
|
||
out.creditedCredits = pool.credited; // refunded/credited purchased money, free (counts inside purchasedCredits)
|
||
out.earnedCredits = pool.earned; // earned pool minus what live campaigns already hold
|
||
out.earnedReserved = pool.earnedReserved;
|
||
out.inCampaigns = pool.inCampaigns; // budget still to deliver across live campaigns
|
||
out.availableCredits = pool.available;
|
||
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 });
|
||
}
|
||
// charge the best-funded of the member's positions (main + Qualified Start
|
||
// wallets). A campaign burns from one member id, so the budget must fit inside it.
|
||
const ids = (await myMemberIds(s)).ids;
|
||
const pool = await ads.balances(ids, s.email);
|
||
const fundId = pool.best.memberId || memberId;
|
||
const earnedNow = String(b.type) !== 'login' ? pool.earned : 0;
|
||
const budget = Math.floor(Number(b.budget) || 0);
|
||
if (pool.per.length > 1 && budget > pool.best.avail + pool.credited + earnedNow && budget <= pool.total + earnedNow)
|
||
return json(res, 400, { error: 'Your credits are spread across ' + pool.per.length + ' positions and one campaign spends from one of them. The largest single position holds ' + pool.best.avail + ' credits: set the budget to that or less, or run two campaigns.' });
|
||
const r = await ads.createCampaign(s.email, fundId, b, ids);
|
||
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 portal: email magic-code sign-in, allowlisted to ADMIN_EMAIL
|
||
if (p === '/api/admin/auth/start' && req.method === 'POST') {
|
||
const b = await readBody(req);
|
||
const e = String(b.email || '').trim().toLowerCase();
|
||
if (!ADMIN_EMAIL) return json(res, 503, { error: 'ADMIN_EMAIL is not set on the server.' });
|
||
if (!e || e !== ADMIN_EMAIL) return json(res, 403, { error: 'That address is not the admin.' });
|
||
const k = 'admin:' + e;
|
||
const prev = emailCodes.get(k);
|
||
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(k, { 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('admin 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/admin/auth/verify' && req.method === 'POST') {
|
||
const b = await readBody(req);
|
||
const e = String(b.email || '').trim().toLowerCase();
|
||
const k = 'admin:' + e;
|
||
const rec = emailCodes.get(k);
|
||
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(k); 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(k);
|
||
if (e !== ADMIN_EMAIL) return json(res, 403, { error: 'That address is not the admin.' });
|
||
const token = mintAdminSession(e);
|
||
return json(res, 200, { ok: true, email: e }, { 'Set-Cookie': adminCookie(token) });
|
||
}
|
||
if (p === '/api/admin/auth/logout' && req.method === 'POST') {
|
||
dropAdminSession(req);
|
||
return json(res, 200, { ok: true }, { 'Set-Cookie': clearAdminCookie() });
|
||
}
|
||
if (p === '/api/admin/me' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 200, { admin: false });
|
||
return json(res, 200, { admin: true, email: ADMIN_EMAIL });
|
||
}
|
||
if (p === '/api/admin/overview' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const camps = await ads.adminList();
|
||
const byStatus = {}, byType = {};
|
||
for (const c of camps) { byStatus[c.status] = (byStatus[c.status] || 0) + 1; byType[c.type] = (byType[c.type] || 0) + 1; }
|
||
let memberCount = null; try { memberCount = await chain.memberCount(); } catch (e) {}
|
||
const cc = chain.getConfig();
|
||
return json(res, 200, { accounts: await accounts.count(), memberCount, campaigns: camps.length,
|
||
house: camps.filter(c => c.house).length, byStatus, byType,
|
||
openReports: await reports.openCount(), pendingBurns: (await ads.pendingBurns()).length,
|
||
followups: await drip.stats(),
|
||
chain: { contract: cc.contract, chainId: cc.chainId, chainName: cc.chainName, explorer: cc.explorer },
|
||
site: siteConfig(), rates: ads.rates() });
|
||
}
|
||
if (p === '/api/admin/members' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const members = await accounts.listAll(500);
|
||
// resolve each sponsor token (username, share code or member #) to the sponsor's name
|
||
const byTok = {};
|
||
for (const m of members) for (const t of [m.username, m.code, m.memberId ? String(m.memberId) : null]) if (t) byTok[String(t).toLowerCase()] = m;
|
||
for (const m of members) {
|
||
const t = String(m.sponsorRef || '').toLowerCase();
|
||
const sp = t ? byTok[t] : null;
|
||
m.sponsorName = sp ? (sp.username ? '@' + sp.username : (sp.memberId ? 'member #' + sp.memberId : sp.email)) : null;
|
||
m.sponsorVia = sp ? (t === String(sp.username || '').toLowerCase() ? 'username' : t === String(sp.code || '').toLowerCase() ? 'code' : 'member #') : (t ? 'unresolved' : '');
|
||
}
|
||
for (const m of members) { try { const ps = await accounts.positions(m.email); m.positions = ps.length; m.positionIds = ps.map(p => p.memberId).filter(Boolean); } catch (e) { m.positions = 0; } }
|
||
return json(res, 200, { members });
|
||
}
|
||
if (p === '/api/admin/members' && req.method === 'PATCH') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const b = await readBody(req);
|
||
if (!b.email) return json(res, 400, { error: 'Which member?' });
|
||
const r = await accounts.setSponsorRef(b.email, b.sponsorRef);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/admin/campaigns' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, { campaigns: await ads.adminList(), rates: ads.rates(), bannerSizes: ads.bannerSizes(), houseOwner: ads.HOUSE_OWNER });
|
||
}
|
||
if (p === '/api/admin/campaigns' && req.method === 'POST') { // free house ad
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const b = await readBody(req);
|
||
if (!['login', 'solo', 'video', 'featured'].includes(String(b.type || ''))) {
|
||
const fc = await frameCheck(b.targetUrl);
|
||
if (!fc.ok) return json(res, 400, { error: fc.reason });
|
||
}
|
||
const r = await ads.createHouseCampaign(b);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
m = /^\/api\/admin\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
|
||
if (m && req.method === 'POST') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const r = await ads.adminSetStatus(m[1], m[2] === 'pause' ? 'paused' : 'active');
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/admin/reports' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, { reports: await reports.list(200) });
|
||
}
|
||
m = /^\/api\/admin\/reports\/(\d+)\/resolve$/.exec(p);
|
||
if (m && req.method === 'POST') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, await reports.resolve(m[1]));
|
||
}
|
||
if (p === '/api/admin/upload' && req.method === 'POST') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return handleUpload(req, res, 'admin');
|
||
}
|
||
if (p === '/api/admin/rates' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, { rates: ads.rates() });
|
||
}
|
||
if (p === '/api/admin/drip' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, { sequence: drip.sequence(), defaults: drip.DEFAULT_SEQUENCE, stats: await drip.stats(), mailReady: mailer.hasKey() });
|
||
}
|
||
if (p === '/api/admin/drip' && req.method === 'PATCH') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const b = await readBody(req);
|
||
const r = b.reset ? drip.resetSequence() : drip.setSequence(b.sequence);
|
||
return json(res, r.error ? 400 : 200, r);
|
||
}
|
||
if (p === '/api/admin/drip/test' && req.method === 'POST') { // send one step to the admin inbox
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const b = await readBody(req);
|
||
if (!ADMIN_EMAIL) return json(res, 400, { error: 'ADMIN_EMAIL is not set.' });
|
||
if (!mailer.hasKey()) return json(res, 400, { error: 'No mail key on the server.' });
|
||
try { const r = await drip.sendStep(ADMIN_EMAIL, Number(b.step) || 0, ADMIN_EMAIL); return json(res, r.error ? 400 : 200, r); }
|
||
catch (e) { return json(res, 502, { error: 'Send failed: ' + e.message }); }
|
||
}
|
||
// wall fallback ads: shown in wall positions a member has not earned or filled, when no upline banner exists
|
||
if (p === '/api/admin/wall-ads' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
let saved = null; try { saved = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'admin-wall-ads.json'), 'utf8')); } catch (e) {}
|
||
return json(res, 200, { ads: Array.isArray(saved) ? saved : [], defaults: getAdminWallAds(), usingDefaults: !Array.isArray(saved) || !saved.length });
|
||
}
|
||
if (p === '/api/admin/wall-ads' && req.method === 'PATCH') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const b = await readBody(req);
|
||
const src = Array.isArray(b.ads) ? b.ads.slice(0, 20) : [];
|
||
const out = [];
|
||
for (const o of src) {
|
||
const name = String((o && o.name) || '').trim().slice(0, 60);
|
||
const targetUrl = String((o && o.targetUrl) || '').trim();
|
||
const bannerUrl = String((o && o.bannerUrl) || '').trim();
|
||
if (!targetUrl) continue;
|
||
if (!/^https:\/\/[^\s]+$/i.test(targetUrl)) return json(res, 400, { error: 'Every wall ad needs an https:// link (' + (name || targetUrl) + ').' });
|
||
if (bannerUrl && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(bannerUrl)) return json(res, 400, { error: 'Banner must be an uploaded image or an https image URL (' + (name || targetUrl) + ').' });
|
||
out.push({ name: name || 'InstantAdPay', targetUrl, bannerUrl: bannerUrl || null });
|
||
}
|
||
const file = path.join(DATA_DIR, 'admin-wall-ads.json');
|
||
if (out.length) fs.writeFileSync(file, JSON.stringify(out, null, 2)); else { try { fs.unlinkSync(file); } catch (e) {} }
|
||
return json(res, 200, { ok: true, ads: out, usingDefaults: !out.length });
|
||
}
|
||
if (p === '/api/admin/site' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, { site: siteConfig() });
|
||
}
|
||
// -- profit and loss from the chain index: volume, platform fees, member payouts,
|
||
// pass-ups, per period (by block: ~43,200 Polygon blocks a day), plus the fee
|
||
// wallets' live balances and an admin-entered fixed monthly cost
|
||
if (p === '/api/admin/pnl' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
const days = Math.max(0, Number(u.searchParams.get('days') || 30));
|
||
let latest = 0; try { latest = parseInt(await chain.rpc('eth_blockNumber', []), 16); } catch (e) {}
|
||
const fromBlock = days ? latest - Math.round(days * 43200) : 0;
|
||
const evs = chain.recentEvents(1e9).filter(e => !days || e.block >= fromBlock);
|
||
const sum = (list, f) => list.reduce((n, e) => n + BigInt(f(e) || '0'), 0n);
|
||
const purchases = evs.filter(e => e.type === 'Purchase');
|
||
const tier = evs.filter(e => e.type === 'TierPaid');
|
||
const admin = evs.filter(e => e.type === 'AdminPaid');
|
||
const passed = evs.filter(e => e.type === 'PassedUp');
|
||
const byTier = {};
|
||
for (const t of [1, 2, 3]) byTier[t] = sum(tier.filter(e => e.tier === t), e => e.amountWei).toString();
|
||
const byPkg = {};
|
||
for (const e of purchases) { const k = '$' + Math.round(e.priceCents / 100); byPkg[k] = (byPkg[k] || 0) + 1; }
|
||
let polUsd = 0; try { const cat = await chain.catalog(); const pk = (cat.products || cat).find(x => x.costWei); if (pk) polUsd = (pk.priceCents / 100) / (Number(BigInt(pk.costWei)) / 1e18); } catch (e) {}
|
||
const wallets = { feeA: '0x7627fc78876948ac9d95c1c9eb061e7d6d647b70', feeB: '0x8b7d33849a2c4d92c985be31e46dc564ba901ad2', engine: burner.status().address || null };
|
||
const balances = {};
|
||
for (const [k, a] of Object.entries(wallets)) { if (!a) continue; try { balances[k] = BigInt(await chain.rpc('eth_getBalance', [a, 'latest'])).toString(); } catch (e) { balances[k] = null; } }
|
||
return json(res, 200, { days, fromBlock, latest, polUsd,
|
||
purchases: { count: purchases.length, volumeWei: sum(purchases, e => e.paidWei).toString(), usdCents: purchases.reduce((n, e) => n + (e.priceCents || 0), 0), byPackage: byPkg },
|
||
platformWei: sum(admin, e => e.amountWei).toString(), memberPayoutsWei: sum(tier, e => e.amountWei).toString(), byTier,
|
||
passedUp: { count: passed.length, unqualified: passed.filter(e => e.reason === 'unqualified').length, sendFailed: passed.filter(e => e.reason === 'send-failed').length },
|
||
wallets, balances, fixedMonthlyUsd: Number(siteConfig().pnlFixedMonthlyUsd) || 0, burner: burner.status() });
|
||
}
|
||
if (p === '/api/admin/burner' && req.method === 'GET') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, burner.status());
|
||
}
|
||
if (p === '/api/admin/burner/run' && req.method === 'POST') {
|
||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||
return json(res, 200, await burner.tick());
|
||
}
|
||
|
||
// -- admin (Bearer ADMIN_PASSWORD, or the /admin portal session)
|
||
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 (HEAD answered like GET so link previewers and crawlers see 200; Node drops the body)
|
||
if (req.method === 'GET' || req.method === 'HEAD') {
|
||
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 === '/terms') return sendFile(res, path.join(PUBLIC_DIR, 'terms.html'));
|
||
if (p === '/privacy') return sendFile(res, path.join(PUBLIC_DIR, 'privacy.html'));
|
||
if (p === '/disclaimer') return sendFile(res, path.join(PUBLIC_DIR, 'disclaimer.html'));
|
||
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
|
||
if (p === '/admin') return sendFile(res, path.join(PUBLIC_DIR, 'admin.html'));
|
||
if (p === '/shorts') return sendFile(res, path.join(PUBLIC_DIR, 'shorts.html'));
|
||
if (p === '/plays') return sendFile(res, path.join(PUBLIC_DIR, 'plays.html'));
|
||
if (p === '/wallets') return sendFile(res, path.join(PUBLIC_DIR, 'wallets.html'));
|
||
if (p === '/launch') return sendFile(res, path.join(PUBLIC_DIR, 'launch.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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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); });
|