Admin portal (/admin) + free house ads; POL amounts show two decimals

- /admin: email magic-code sign-in allowlisted to ADMIN_EMAIL, 12h admin
  session (cookie iap.adm, persisted in the volume). Bearer ADMIN_PASSWORD
  API access still works. Member area shows an Admin link for that email.
- House ads: admin places banner/text/login/solo/video/featured/visits
  campaigns owned by house@instantadpay.com that cost nothing; budget is
  only a delivery cap, spend is never charged or burned.
- Admin APIs: overview, all campaigns (+pause/resume any), members
  (+re-point sponsor), reports (+resolve), pending burns, rates/site
  config get+patch, creative upload.
- fmtPol rounds to two decimals everywhere (dashboard, toasts, prices).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 05:15:19 -05:00
parent b6a0729335
commit 438b1921e9
18 changed files with 725 additions and 47 deletions
+18 -1
View File
@@ -159,6 +159,13 @@ const J = {
this.save();
return { ok: true, account: pub(acct) };
},
async listAll(limit) { return Object.values(this.db.byEmail).sort((a, b) => (b.created || 0) - (a.created || 0)).slice(0, limit).map(pub); },
async setSponsorRef(e, ref) {
const acct = this.db.byEmail[e];
if (!acct) return { error: 'No such account.' };
acct.sponsorRef = ref; this.save();
return { ok: true, account: pub(acct) };
},
async count() { return Object.keys(this.db.byEmail).length; }
};
@@ -268,6 +275,12 @@ const D = {
}
return { ok: true, account: await this.byEmail(e) };
},
async listAll(limit) { const rows = await db.q('SELECT * FROM accounts ORDER BY created DESC LIMIT ?', [Number(limit) || 500]); return rows.map(rowPub); },
async setSponsorRef(e, ref) {
const r = await db.q('UPDATE accounts SET sponsor_ref=? WHERE email=?', [ref, e]);
if (!r.affectedRows) return { error: 'No such account.' };
return { ok: true, account: await this.byEmail(e) };
},
async count() { const r = await db.q('SELECT COUNT(*) n FROM accounts'); return Number(r[0].n); }
};
@@ -332,6 +345,10 @@ async function linkWallet(email, address) {
return impl().linkWallet(normEmail(email), a);
}
async function count() { return impl().count(); }
// admin: newest-first account list, and re-pointing a member's sponsor (a
// username, share code, or numeric member id: the same tokens join links use)
async function listAll(limit = 500) { return impl().listAll(limit); }
async function setSponsorRef(email, ref) { return impl().setSponsorRef(normEmail(email), String(ref || '').trim().toLowerCase().slice(0, 40)); }
// resolve a member's DIRECT sponsor account (the token they joined under)
async function sponsorOf(email) {
@@ -355,7 +372,7 @@ async function getChatSettings(email) {
return { available: a ? a.chatAvailable !== false : true, mutes: await impl().getMutes(String(email || '').toLowerCase()) };
}
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername,
module.exports = { init, signup, login, ensure, byEmail, byAddress, byCode, byUsername, listAll, setSponsorRef,
setUsername, setMemberId, namesForMembers, listByReferrer, downline, linkWallet, count,
setLineBanner: (e, b, t) => impl().setLineBanner(String(e || '').toLowerCase(), b, t),
setProfile: (e, a, bio, socials) => impl().setProfile(String(e || '').toLowerCase(), a, bio, socials),
BIN
View File
Binary file not shown.
+1
View File
@@ -119,6 +119,7 @@ async function bootstrap() {
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day)
await alterSafe('ALTER TABLE campaigns ADD COLUMN house TINYINT NOT NULL DEFAULT 0'); // admin house ad: free, never charged
await q(`CREATE TABLE IF NOT EXISTS visit_seen (
campaign_id INT NOT NULL,
email VARCHAR(190) NOT NULL,
+224
View File
@@ -0,0 +1,224 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>Admin | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="icon" type="image/png" href="/logo-icon.png">
<link rel="stylesheet" href="/assets/site.css?v=20260908f">
<style>
.adm-table th,.adm-table td{padding:8px 10px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line);font-size:13.5px}
.adm-table th{color:var(--muted);font-size:11.5px;text-transform:uppercase;letter-spacing:.08em}
.adm-table .act{white-space:nowrap}
.adm-table .act button{margin-right:6px}
.st{display:inline-block;border-radius:999px;padding:2px 9px;font-size:11.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase}
.st.active{background:rgba(67,232,195,.14);color:var(--mint)}
.st.paused{background:rgba(255,178,56,.14);color:var(--amber)}
.st.out{background:rgba(255,255,255,.08);color:var(--muted)}
.house-tag{display:inline-block;border:1px solid rgba(157,125,255,.5);color:var(--violet);border-radius:6px;padding:1px 6px;font-size:10.5px;font-weight:700;margin-left:6px;vertical-align:middle}
.adm-form p{margin:0 0 10px}
.adm-form label.small{display:block;margin-bottom:4px}
.trunc{max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom}
textarea.json{font-family:var(--mono);font-size:12.5px;width:100%;min-height:260px}
</style>
</head>
<body class="bo-body">
<!-- signed-out: admin sign-in -->
<div id="authArea">
<div class="wrap" style="max-width:520px">
<section class="hero" style="padding:64px 0 10px;text-align:left">
<p class="eyebrow">InstantAdPay</p>
<h1 style="font-size:34px">Admin <em>sign in</em></h1>
<p class="lead" style="font-size:16px">Only the admin address can sign in here. A one-time code goes to that inbox.</p>
</section>
<div class="card">
<p><input id="adEmail" type="email" placeholder="Admin email" autocomplete="email" style="width:100%"></p>
<p id="adCodeRow" hidden><input id="adCode" inputmode="numeric" placeholder="6-digit code from your email" style="width:100%"></p>
<p id="adErr" class="small" style="color:var(--bad)" hidden></p>
<p style="margin:0;display:flex;gap:10px">
<button class="btn" id="adSend" type="button">Send code</button>
<button class="btn" id="adVerify" type="button" hidden>Sign in</button>
</p>
<p class="small muted" style="margin:14px 0 0"><a href="/my">Back to the member area</a></p>
</div>
</div>
</div>
<!-- signed-in: admin shell -->
<div id="adminArea" class="bo" hidden>
<aside class="bo-side" id="boSide">
<a class="logo" href="/"><img src="/logo.png" alt="InstantAdPay" style="height:32px;display:block"></a>
<nav class="bo-menu" aria-label="Admin menu">
<button data-pane="overview" class="on" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="8" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/><rect x="13" y="13" width="8" height="8" rx="2"/></svg>Overview</button>
<button data-pane="house" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>House ads</button>
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h10"/></svg>All campaigns</button>
<button data-pane="members" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>Members</button>
<button data-pane="reports" type="button"><svg viewBox="0 0 24 24"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17v.5"/></svg>Reports<span class="pill" id="repBadge" hidden></span></button>
<button data-pane="settings" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></svg>Settings</button>
</nav>
<div class="bo-links">
<span class="bo-cap">Site</span>
<a href="/my">Member area</a>
<a href="/ledger" target="_blank" rel="noopener">Live ledger</a>
<a href="/" target="_blank" rel="noopener">Home page</a>
</div>
<div class="bo-foot">
<span id="adWho" class="small muted"></span>
<a href="#" id="adLogout" class="small">Log out</a>
</div>
</aside>
<div class="bo-main">
<header class="bo-top">
<button id="boBurger" aria-label="Menu" type="button"></button>
<h2 id="boTitle" style="margin:0">Overview</h2>
<span class="small muted" id="chainLine"></span>
</header>
<main class="bo-content">
<div class="pane" id="pane-overview">
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));margin-top:0">
<div class="statx"><div><div class="nv" id="ovAccounts">0</div><div class="lb">Accounts</div></div></div>
<div class="statx c-cyan"><div><div class="nv" id="ovMembers">0</div><div class="lb">On-chain members</div></div></div>
<div class="statx c-violet"><div><div class="nv" id="ovActive">0</div><div class="lb">Active campaigns</div><span class="chip flat" id="ovCampSub"></span></div></div>
<div class="statx c-amber"><div><div class="nv" id="ovReports">0</div><div class="lb">Open reports</div><span class="chip flat" id="ovBurnSub"></span></div></div>
</div>
<div class="grid c2" style="margin-top:16px">
<div class="card">
<div class="card-head"><h3>Campaigns by format</h3><span class="sub">all owners</span></div>
<div id="ovByType" class="small muted"></div>
</div>
<div class="card">
<div class="card-head"><h3>Chain</h3><span class="sub">live config</span></div>
<div id="ovChain" class="small"></div>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Quick actions</h3></div>
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:0">
<button class="btn small" type="button" data-goto="house">Place a house ad</button>
<button class="btn small sec" type="button" data-goto="campaigns">Review campaigns</button>
<button class="btn small sec" type="button" data-goto="members">Members</button>
</p>
</div>
</div>
<div class="pane" id="pane-house" hidden>
<div class="card adm-form">
<div class="card-head"><h3>New house ad</h3><span class="sub">free: nothing is charged, the budget is only a delivery cap</span></div>
<div class="grid c3">
<p><select id="hType" style="width:100%">
<option value="banner">Banner (per impression)</option>
<option value="text">Text ad (per impression)</option>
<option value="login">Login ad (full-screen after sign-in)</option>
<option value="solo">Solo ad (inbox delivery)</option>
<option value="video">Video ad (watch to earn)</option>
<option value="featured">Featured link (rotation)</option>
<option value="visits">Verified visits</option>
</select></p>
<p><input id="hName" placeholder="Campaign name" style="width:100%"></p>
<p><input id="hBudget" type="number" min="10" placeholder="Delivery cap in credits (default 100,000)" style="width:100%"></p>
</div>
<p><input id="hTarget" placeholder="Target URL (https://…)" autocomplete="off" style="width:100%"></p>
<p class="small muted" style="margin-top:-4px">Banner and text targets are framed in the ad viewer, so they must allow framing (checked on submit).</p>
<div id="hBannerRow">
<p><input id="hImage" placeholder="Image URL (https://… or /uploads/…)" autocomplete="off" style="width:100%"></p>
<p><input type="file" id="hImageFile" accept="image/png,image/jpeg,image/webp,image/gif" hidden>
<button type="button" class="btn small sec" id="hImageUploadBtn">Upload image</button>
<span id="hImageInfo" class="small muted"></span></p>
<p><select id="hSize" style="width:100%"></select></p>
</div>
<div id="hTextRow" hidden>
<p><input id="hTitle" maxlength="60" placeholder="Headline (max 60)" style="width:100%"></p>
<p><input id="hBody" maxlength="140" placeholder="Ad text (max 140)" style="width:100%"></p>
</div>
<div id="hSoloRow" hidden>
<p><input id="hSoloTitle" maxlength="80" placeholder="Subject line (max 80)" style="width:100%"></p>
<p><textarea id="hSoloBody" rows="8" placeholder="Message body. Simple HTML is fine (bold, lists, links, images)." style="width:100%"></textarea></p>
<p><input id="hSoloCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
<p class="small muted" id="hSoloHint"></p>
</div>
<div id="hVideoRow" hidden>
<p><input id="hVideoUrl" placeholder="Video URL (direct https link ending .mp4 or .webm, or /uploads/…)" autocomplete="off" style="width:100%"></p>
<p><input type="file" id="hVideoFile" accept="video/mp4,video/webm" hidden>
<button type="button" class="btn small sec" id="hVideoUploadBtn">Upload video</button>
<span id="hVideoInfo" class="small muted"></span></p>
<p><input id="hVideoTitle" maxlength="80" placeholder="Video title (optional)" style="width:100%"></p>
<p><select id="hWatchSecs" style="width:100%"></select></p>
<p><input id="hVideoCta" maxlength="30" placeholder="Call-to-action label (default: Learn more)" style="width:100%"></p>
</div>
<div id="hFeatRow" hidden>
<p><input id="hFeatTitle" maxlength="70" placeholder="Headline for the featured link" style="width:100%"></p>
<div class="grid c2">
<p><select id="hFeatDays" style="width:100%"></select></p>
<p><input id="hFeatStart" type="number" min="0" value="0" placeholder="Start in N days (0 = today)" style="width:100%"></p>
</div>
<p class="small muted" id="hFeatHint"></p>
</div>
<div id="hVisitsRow" hidden>
<p><input id="hVisitTitle" maxlength="80" placeholder="Headline (what members see before visiting)" style="width:100%"></p>
<p><input id="hVisitCount" type="number" min="20" placeholder="How many verified visits?" style="width:100%"></p>
<p class="small muted" id="hVisitHint"></p>
</div>
<p id="hErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn" id="hCreate" type="button">Place house ad</button>
</div>
<div class="card">
<div class="card-head"><h3>House ads</h3><span class="sub" id="houseSub">running free</span></div>
<div class="tablewrap"><table class="adm-table" id="houseTable"></table></div>
</div>
</div>
<div class="pane" id="pane-campaigns" hidden>
<div class="card">
<div class="card-head"><h3>All campaigns</h3><span class="sub" id="campSub">newest first</span></div>
<p style="margin:0 0 10px"><input id="campFilter" placeholder="Filter by owner, name, type or status" style="width:100%"></p>
<div class="tablewrap"><table class="adm-table" id="campTable"></table></div>
</div>
</div>
<div class="pane" id="pane-members" hidden>
<div class="card">
<div class="card-head"><h3>Members</h3><span class="sub" id="memSub">newest first</span></div>
<p style="margin:0 0 10px"><input id="memFilter" placeholder="Filter by email, username, member # or sponsor" style="width:100%"></p>
<p class="small muted">Sponsor = the token the account joined under (username, share code, or member #). Editing it re-points free referrals and future purchases. On-chain sponsorship is permanent once activated.</p>
<div class="tablewrap"><table class="adm-table" id="memTable"></table></div>
</div>
</div>
<div class="pane" id="pane-reports" hidden>
<div class="card">
<div class="card-head"><h3>Ad reports</h3><span class="sub">members flagging ads</span></div>
<div class="tablewrap"><table class="adm-table" id="repTable"></table></div>
</div>
<div class="card">
<div class="card-head"><h3>Pending burns</h3><span class="sub">on-chain credit burns waiting to be executed</span></div>
<div class="tablewrap"><table class="adm-table" id="burnTable"></table></div>
</div>
</div>
<div class="pane" id="pane-settings" hidden>
<div class="card">
<div class="card-head"><h3>Rates</h3><span class="sub">credit prices and rewards (JSON, merged on save)</span></div>
<p><textarea id="ratesJson" class="json"></textarea></p>
<p id="ratesErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn small" id="ratesSave" type="button">Save rates</button>
</div>
<div class="card">
<div class="card-head"><h3>Site config</h3><span class="sub">public: everything here is served by /api/config, never put secrets in it</span></div>
<p><textarea id="siteJson" class="json"></textarea></p>
<p id="siteErr" class="small" style="color:var(--bad)" hidden></p>
<button class="btn small" id="siteSave" type="button">Save site config</button>
</div>
</div>
</main>
</div>
</div>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/admin.js?v=20260909a"></script>
</body>
</html>
+292
View File
@@ -0,0 +1,292 @@
// Admin portal: email-code sign-in (allowlisted to ADMIN_EMAIL on the server),
// house ads that cost nothing, every campaign, members, reports, settings.
(function () {
const $ = IAP.$;
const esc = s => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
async function api(path, body, method) {
const opts = { method: method || (body === undefined ? 'GET' : 'POST'), headers: {} };
if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
const r = await (await fetch(path, opts)).json();
if (r.error) throw new Error(r.error === 'auth' ? 'Session expired. Sign in again.' : r.error);
return r;
}
function busy(btn, fn) {
return async (...a) => {
if (btn.disabled) return;
btn.disabled = true;
try { await fn(...a); } catch (e) { IAP.status(e.message || 'Something went wrong.', 'bad'); }
finally { btn.disabled = false; }
};
}
const when = ts => ts ? new Date(Number(ts)).toLocaleString() : '';
let rates = {}, sizes = [], houseOwner = 'house@instantadpay.com';
// ── sign-in ──
$('adSend').addEventListener('click', busy($('adSend'), async () => {
$('adErr').hidden = true;
const r = await api('/api/admin/auth/start', { email: $('adEmail').value });
$('adCodeRow').hidden = false; $('adVerify').hidden = false;
if (r.devCode) $('adCode').value = r.devCode;
IAP.status(r.sent ? 'Code sent. Check your inbox.' : 'Dev mode: code filled in.', 'ok');
$('adCode').focus();
}));
$('adVerify').addEventListener('click', busy($('adVerify'), async () => {
$('adErr').hidden = true;
await api('/api/admin/auth/verify', { email: $('adEmail').value, code: $('adCode').value });
await render();
}));
$('adCode').addEventListener('keydown', e => { if (e.key === 'Enter') $('adVerify').click(); });
$('adEmail').addEventListener('keydown', e => { if (e.key === 'Enter') ($('adVerify').hidden ? $('adSend') : $('adVerify')).click(); });
$('adLogout').addEventListener('click', async e => {
e.preventDefault();
try { await api('/api/admin/auth/logout', {}); } catch (err) {}
location.reload();
});
// ── panes ──
const TITLES = { overview: 'Overview', house: 'House ads', campaigns: 'All campaigns', members: 'Members', reports: 'Reports', settings: 'Settings' };
const loaders = { overview: loadOverview, house: loadHouse, campaigns: loadCampaigns, members: loadMembers, reports: loadReports, settings: loadSettings };
function setPane(name) {
if (!TITLES[name]) name = 'overview';
document.querySelectorAll('.pane').forEach(p => { p.hidden = p.id !== 'pane-' + name; });
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.classList.toggle('on', b.dataset.pane === name));
$('boTitle').textContent = TITLES[name];
if (location.hash.slice(1) !== name) history.replaceState(null, '', '#' + name);
$('adminArea').classList.remove('side-open');
loaders[name]().catch(e => IAP.status(e.message, 'bad'));
}
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => b.addEventListener('click', () => setPane(b.dataset.pane)));
document.addEventListener('click', e => { const g = e.target.closest('[data-goto]'); if (g) setPane(g.dataset.goto); });
window.addEventListener('hashchange', () => setPane(location.hash.slice(1)));
$('boBurger').addEventListener('click', () => $('adminArea').classList.toggle('side-open'));
async function render() {
let me = { admin: false };
try { me = await api('/api/admin/me'); } catch (e) {}
$('authArea').hidden = !!me.admin;
$('adminArea').hidden = !me.admin;
if (!me.admin) return;
$('adWho').textContent = me.email || 'admin';
try {
const c = await IAP.getConfig();
$('chainLine').textContent = c.chainName + (c.rehearsal ? ' · rehearsal' : '');
} catch (e) {}
setPane(location.hash.slice(1) || 'overview');
}
// ── overview ──
async function loadOverview() {
const o = await api('/api/admin/overview');
rates = o.rates || rates;
$('ovAccounts').textContent = (o.accounts || 0).toLocaleString();
$('ovMembers').textContent = o.memberCount == null ? '?' : Number(o.memberCount).toLocaleString();
$('ovActive').textContent = (o.byStatus && o.byStatus.active) || 0;
$('ovCampSub').textContent = o.campaigns + ' total · ' + o.house + ' house';
$('ovReports').textContent = o.openReports || 0;
$('ovBurnSub').textContent = (o.pendingBurns || 0) + ' pending burns';
$('repBadge').hidden = !o.openReports; $('repBadge').textContent = o.openReports || '';
const bt = Object.entries(o.byType || {}).sort((a, b) => b[1] - a[1]);
$('ovByType').innerHTML = bt.length ? bt.map(([t, n]) => '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--line)"><span>' + esc(t) + '</span><b>' + n + '</b></div>').join('') : 'No campaigns yet.';
const ch = o.chain || {};
$('ovChain').innerHTML = '<div>' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')</div>'
+ '<div class="mono" style="word-break:break-all;margin:6px 0">' + esc(ch.contract) + '</div>'
+ (ch.explorer ? '<a href="' + esc(ch.explorer) + '/address/' + esc(ch.contract) + '" target="_blank" rel="noopener">Open in explorer →</a>' : '');
}
// ── house ads ──
const HROWS = { banner: ['hBannerRow'], text: ['hTextRow'], login: [], solo: ['hSoloRow'], video: ['hVideoRow'], featured: ['hFeatRow'], visits: ['hVisitsRow'] };
function showHouseRows() {
const t = $('hType').value;
['hBannerRow', 'hTextRow', 'hSoloRow', 'hVideoRow', 'hFeatRow', 'hVisitsRow'].forEach(id => { $(id).hidden = !(HROWS[t] || []).includes(id); });
$('hBudget').hidden = t === 'featured' || t === 'visits';
houseHints();
}
function houseHints() {
const r = rates || {};
const soloCost = r.soloCostPerRecipient || 5, soloMin = r.soloMinRecipients || 10;
const cap = Number($('hBudget').value) || 100000;
$('hSoloHint').textContent = 'Delivers to one inbox per ' + soloCost + ' credits of cap (minimum ' + soloMin + ' recipients). A cap of ' + cap.toLocaleString() + ' reaches up to ' + Math.floor(cap / soloCost).toLocaleString() + ' members.';
const days = Number($('hFeatDays').value) || 0;
$('hFeatHint').textContent = days ? days + '-day run in the featured strip (' + (r.featuredPerDay || 40) + ' credits/day, free here). Book up to ' + (r.featuredWindowDays || 7) + ' days ahead.' : '';
const n = Number($('hVisitCount').value) || 0;
$('hVisitHint').textContent = 'Packs start at ' + (r.visitMinPack || 20) + ' visits.' + (n ? ' ' + n + ' verified visits, delivered one per member.' : '');
}
$('hType').addEventListener('change', showHouseRows);
['hBudget', 'hFeatDays', 'hVisitCount'].forEach(id => $(id).addEventListener('input', houseHints));
$('hFeatDays').addEventListener('change', houseHints);
$('hImageUploadBtn').addEventListener('click', () => $('hImageFile').click());
$('hVideoUploadBtn').addEventListener('click', () => $('hVideoFile').click());
async function upload(fileInput, info, target, kind) {
const f = fileInput.files[0]; if (!f) return;
info.textContent = 'Uploading ' + f.name + '…';
try {
const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
if (r.error) { info.textContent = r.error; }
else { target.value = r.url; info.textContent = f.name + ' uploaded'; }
} catch (e) { info.textContent = 'Upload failed. Try again.'; }
fileInput.value = '';
}
$('hImageFile').addEventListener('change', () => upload($('hImageFile'), $('hImageInfo'), $('hImage')));
$('hVideoFile').addEventListener('change', () => upload($('hVideoFile'), $('hVideoInfo'), $('hVideoUrl')));
let hVidDims = null;
function probeVideoDims(url) {
return new Promise(resolve => {
const v = document.createElement('video'); v.preload = 'metadata'; v.muted = true;
const done = d => { v.src = ''; resolve(d); };
v.onloadedmetadata = () => done(v.videoWidth && v.videoHeight ? { w: v.videoWidth, h: v.videoHeight } : null);
v.onerror = () => done(null);
setTimeout(() => done(null), 12000);
v.src = url;
});
}
$('hCreate').addEventListener('click', busy($('hCreate'), async () => {
$('hErr').hidden = true;
const t = $('hType').value;
if (t === 'video' && $('hVideoUrl').value) hVidDims = await probeVideoDims($('hVideoUrl').value);
const days = Number($('hFeatDays').value), count = Number($('hVisitCount').value);
const body = { type: t, name: $('hName').value, targetUrl: $('hTarget').value,
imageUrl: $('hImage').value, size: $('hSize').value,
title: t === 'video' ? $('hVideoTitle').value : t === 'featured' ? $('hFeatTitle').value : t === 'visits' ? $('hVisitTitle').value : t === 'solo' ? $('hSoloTitle').value : $('hTitle').value,
body: t === 'solo' ? $('hSoloBody').value : $('hBody').value,
ctaLabel: t === 'video' ? $('hVideoCta').value : $('hSoloCta').value,
videoUrl: $('hVideoUrl').value, watchSecs: Number($('hWatchSecs').value),
videoW: hVidDims ? hVidDims.w : null, videoH: hVidDims ? hVidDims.h : null,
days, startDay: Number($('hFeatStart').value) || 0, count,
budget: t === 'featured' ? days * (rates.featuredPerDay || 40)
: t === 'visits' ? count * (rates.visitCostPerVisit || 3)
: (Number($('hBudget').value) || 0) };
try {
await api('/api/admin/campaigns', body);
} catch (e) { $('hErr').textContent = e.message; $('hErr').hidden = false; throw e; }
IAP.status('House ad is live. It serves right away at no cost.', 'ok');
['hName', 'hBudget', 'hTarget', 'hImage', 'hTitle', 'hBody', 'hSoloTitle', 'hSoloBody', 'hSoloCta',
'hVideoUrl', 'hVideoTitle', 'hVideoCta', 'hFeatTitle', 'hVisitTitle', 'hVisitCount'].forEach(id => { $(id).value = ''; });
$('hImageInfo').textContent = ''; $('hVideoInfo').textContent = ''; hVidDims = null;
await loadHouse();
}));
function campRow(c, showOwner) {
const left = Math.max(0, (c.budget || 0) - (c.spent || 0));
const creative = c.type === 'banner' && c.imageUrl ? '<img src="' + esc(c.imageUrl) + '" alt="" style="max-height:34px;max-width:120px;border-radius:4px">' : esc(c.title || c.name);
const act = c.status === 'active' ? '<button class="btn small sec" data-act="pause" data-id="' + c.id + '">Pause</button>'
: c.status === 'paused' ? '<button class="btn small" data-act="resume" data-id="' + c.id + '">Resume</button>' : '';
return '<tr><td>#' + c.id + (c.house ? '<span class="house-tag">HOUSE</span>' : '') + '</td>'
+ (showOwner ? '<td><span class="trunc" title="' + esc(c.owner) + '">' + esc(c.house ? 'house' : c.owner) + '</span></td>' : '')
+ '<td>' + esc(c.type) + '</td>'
+ '<td>' + esc(c.name) + '<div class="small muted">' + creative + '</div><a class="small trunc" href="' + esc(c.targetUrl) + '" target="_blank" rel="noopener">' + esc(c.targetUrl) + '</a></td>'
+ '<td><span class="st ' + esc(c.status) + '">' + esc(c.status) + '</span></td>'
+ '<td class="mono small">' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '<div class="muted">' + left.toLocaleString() + ' left</div></td>'
+ '<td class="mono small">' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '<div class="muted">' + (c.clicks || 0) + ' clicks</div></td>'
+ '<td class="small muted">' + when(c.created) + '</td>'
+ '<td class="act">' + act + '</td></tr>';
}
function campHead(showOwner) {
return '<tr><th>ID</th>' + (showOwner ? '<th>Owner</th>' : '') + '<th>Type</th><th>Campaign</th><th>Status</th><th>Spent / cap</th><th>Delivery</th><th>Created</th><th></th></tr>';
}
async function loadHouse() {
const r = await api('/api/admin/campaigns');
rates = r.rates || rates; sizes = r.bannerSizes || sizes; houseOwner = r.houseOwner || houseOwner;
if (!$('hSize').options.length) $('hSize').innerHTML = sizes.map(s => '<option value="' + esc(s.id) + '">' + esc(s.label || s.id) + ' (' + s.w + '×' + s.h + ')</option>').join('');
if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '<option value="' + t.secs + '">Watch ' + t.secs + 's (viewer earns ' + t.reward + ')</option>').join('');
if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '<option value="' + d + '">' + d + ' day' + (d > 1 ? 's' : '') + '</option>').join('');
showHouseRows();
const house = (r.campaigns || []).filter(c => c.house);
$('houseSub').textContent = house.filter(c => c.status === 'active').length + ' active · ' + house.length + ' total';
$('houseTable').innerHTML = house.length ? campHead(false) + house.map(c => campRow(c, false)).join('') : '<tr><td class="muted">No house ads yet. Place one above.</td></tr>';
}
document.addEventListener('click', async e => {
const b = e.target.closest('[data-act][data-id]'); if (!b) return;
b.disabled = true;
try {
await api('/api/admin/campaigns/' + b.dataset.id + '/' + b.dataset.act, {});
IAP.status('Campaign #' + b.dataset.id + ' ' + (b.dataset.act === 'pause' ? 'paused' : 'resumed') + '.', 'ok');
await Promise.all([loadHouse(), loadCampaigns()]);
} catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
});
// ── all campaigns ──
let allCamps = [];
async function loadCampaigns() {
const r = await api('/api/admin/campaigns');
allCamps = r.campaigns || [];
drawCamps();
}
function drawCamps() {
const q = ($('campFilter').value || '').trim().toLowerCase();
const list = allCamps.filter(c => !q || [c.owner, c.name, c.type, c.status, c.targetUrl, String(c.id)].join(' ').toLowerCase().includes(q));
$('campSub').textContent = list.length + ' of ' + allCamps.length;
$('campTable').innerHTML = list.length ? campHead(true) + list.map(c => campRow(c, true)).join('') : '<tr><td class="muted">Nothing matches.</td></tr>';
}
$('campFilter').addEventListener('input', drawCamps);
// ── members ──
let allMembers = [];
async function loadMembers() {
const r = await api('/api/admin/members');
allMembers = r.members || [];
drawMembers();
}
function drawMembers() {
const q = ($('memFilter').value || '').trim().toLowerCase();
const list = allMembers.filter(a => !q || [a.email, a.username, a.memberId, a.sponsorRef, a.address, a.code].join(' ').toLowerCase().includes(q));
$('memSub').textContent = list.length + ' of ' + allMembers.length;
$('memTable').innerHTML = '<tr><th>Email</th><th>Username</th><th>Member #</th><th>Wallet</th><th>Sponsor</th><th>Code</th><th>Joined</th><th></th></tr>'
+ list.map(a => '<tr><td>' + esc(a.email) + '</td><td>' + (a.username ? '@' + esc(a.username) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + (a.memberId ? '#' + a.memberId : '<span class="muted">free</span>') + '</td>'
+ '<td class="mono small">' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : '<span class="muted">none</span>') + '</td>'
+ '<td>' + esc(a.sponsorRef || '') + '</td><td class="mono small">' + esc(a.code || '') + '</td>'
+ '<td class="small muted">' + when(a.created) + '</td>'
+ '<td class="act"><button class="btn small sec" data-spon="' + esc(a.email) + '" data-cur="' + esc(a.sponsorRef || '') + '">Sponsor</button></td></tr>').join('');
}
$('memFilter').addEventListener('input', drawMembers);
document.addEventListener('click', async e => {
const b = e.target.closest('[data-spon]'); if (!b) return;
const v = prompt('Sponsor for ' + b.dataset.spon + ' (username, share code, or member #). Leave blank to clear.', b.dataset.cur);
if (v === null) return;
try {
await api('/api/admin/members', { email: b.dataset.spon, sponsorRef: v.trim() }, 'PATCH');
IAP.status('Sponsor updated.', 'ok');
await loadMembers();
} catch (err) { IAP.status(err.message, 'bad'); }
});
// ── reports + burns ──
async function loadReports() {
const [r, b] = await Promise.all([api('/api/admin/reports'), api('/api/admin/burns')]);
const reps = r.reports || [];
$('repTable').innerHTML = reps.length ? '<tr><th>When</th><th>Campaign</th><th>Reason</th><th>Note</th><th>By</th><th></th></tr>'
+ reps.map(x => '<tr' + (x.resolved ? ' style="opacity:.5"' : '') + '><td class="small muted">' + when(x.ts) + '</td><td>#' + x.campaignId + '</td><td>' + esc(x.reason) + '</td><td>' + esc(x.note || '') + '</td><td class="small">' + esc(x.reporter || 'anon') + '</td>'
+ '<td class="act">' + (x.resolved ? 'resolved' : '<button class="btn small sec" data-act="pause" data-id="' + x.campaignId + '">Pause ad</button><button class="btn small" data-resolve="' + x.id + '">Resolve</button>') + '</td></tr>').join('')
: '<tr><td class="muted">No reports.</td></tr>';
const burns = b.pending || [];
$('burnTable').innerHTML = burns.length ? '<tr><th>When</th><th>Member</th><th>Credits</th><th>Ref</th><th>Burn id</th></tr>'
+ burns.map(x => '<tr><td class="small muted">' + when(x.ts) + '</td><td>#' + x.memberId + '</td><td class="mono">' + x.amount + '</td><td>' + esc(x.ref) + '</td><td class="mono small">' + esc(x.id) + '</td></tr>').join('')
: '<tr><td class="muted">Nothing pending.</td></tr>';
}
document.addEventListener('click', async e => {
const b = e.target.closest('[data-resolve]'); if (!b) return;
b.disabled = true;
try { await api('/api/admin/reports/' + b.dataset.resolve + '/resolve', {}); IAP.status('Report resolved.', 'ok'); await Promise.all([loadReports(), loadOverview()]); }
catch (err) { IAP.status(err.message, 'bad'); b.disabled = false; }
});
// ── settings ──
async function loadSettings() {
const [r, s] = await Promise.all([api('/api/admin/rates'), api('/api/admin/site')]);
$('ratesJson').value = JSON.stringify(r.rates || {}, null, 2);
$('siteJson').value = JSON.stringify(s.site || {}, null, 2);
}
function saveJson(btnId, taId, errId, path, key) {
$(btnId).addEventListener('click', busy($(btnId), async () => {
$(errId).hidden = true;
let obj;
try { obj = JSON.parse($(taId).value); } catch (e) { $(errId).textContent = 'That is not valid JSON: ' + e.message; $(errId).hidden = false; return; }
const r = await api(path, obj, 'PATCH');
$(taId).value = JSON.stringify(r[key] || obj, null, 2);
IAP.status('Saved.', 'ok');
}));
}
saveJson('ratesSave', 'ratesJson', 'ratesErr', '/api/admin/rates', 'rates');
saveJson('siteSave', 'siteJson', 'siteErr', '/api/admin/site', 'site');
render();
})();
+4 -4
View File
@@ -7,11 +7,11 @@ window.IAP = (function () {
if (!config) config = await (await fetch('/api/config')).json();
return config;
}
// POL amounts display with two decimals (rounded half-up), e.g. 523.39
function fmtPol(wei) {
const s = BigInt(wei).toString().padStart(19, '0');
const whole = s.slice(0, -18) || '0';
const frac = s.slice(-18, -12).replace(/0+$/, '');
return whole + (frac ? '.' + frac : '');
const cents = (BigInt(wei) + 5000000000000000n) / 10000000000000000n; // wei -> hundredths of a POL
const s = cents.toString().padStart(3, '0');
return s.slice(0, -2) + '.' + s.slice(-2);
}
const fmtUsd = cents => '$' + (cents / 100).toFixed(2);
+1
View File
@@ -471,6 +471,7 @@
$('authArea').hidden = !!signedIn;
$('memberArea').hidden = !signedIn;
if (!signedIn) return;
if ($('adminLink')) $('adminLink').hidden = !me.isAdmin; // admin portal link, only for ADMIN_EMAIL
setPane(location.hash.slice(1) || 'overview');
$('campGate').hidden = !!me.memberId;
$('earnGate').hidden = !!me.memberId;
+1 -1
View File
@@ -141,7 +141,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/contract.js?v=20260908p"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+1 -1
View File
@@ -26,7 +26,7 @@
<p class="muted small">You decide whether, and how much, to spend. Never spend more than you can afford to lose.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1 -1
View File
@@ -438,7 +438,7 @@
</div>
</section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/wallet.js?v=20260908t"></script>
<script src="/assets/home.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
+1 -1
View File
@@ -37,7 +37,7 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
</div>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/ledger.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+3 -2
View File
@@ -139,6 +139,7 @@
<div class="bo-links">
<span class="bo-cap">Team</span>
<a href="#" id="chatMenuBtn" class="bo-chat"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align:-2px;margin-right:7px"><path d="M5 5h14a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H9l-4 4V6a1 1 0 0 1 1-1z"/></svg>Messages<span class="pill" id="chatNavBadge" hidden></span></a>
<a href="/admin" id="adminLink" hidden>Admin</a>
</div>
<div class="bo-links">
<span class="bo-cap">Legal</span>
@@ -694,9 +695,9 @@
</div>
</div>
</div>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/wallet.js?v=20260908t"></script>
<script src="/assets/my.js?v=20260908t"></script>
<script src="/assets/my.js?v=20260909a"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>
+1 -1
View File
@@ -28,7 +28,7 @@
<p class="muted small">We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1
View File
@@ -1,5 +1,6 @@
User-agent: *
Allow: /
Disallow: /my
Disallow: /admin
Disallow: /api/
Sitemap: https://instantadpay.com/sitemap.xml
+1 -1
View File
@@ -36,7 +36,7 @@
<p class="muted small" style="margin-top:18px">See also the <a href="/disclaimer">Disclaimer</a> and <a href="/privacy">Privacy Policy</a>.</p>
</div>
</div></section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/legal.js?v=20260908a"></script>
</body>
</html>
+1 -1
View File
@@ -34,7 +34,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div>
</section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/tx.js?v=20260906m"></script>
</body>
</html>
+1 -1
View File
@@ -39,7 +39,7 @@
</div>
</div>
</section>
<script src="/assets/common.js?v=20260908c"></script>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/wall.js?v=20260908d"></script>
</body>
</html>
+173 -32
View File
@@ -28,6 +28,30 @@ 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');
@@ -112,6 +136,40 @@ function frameFetch(url, depth) {
});
});
}
// 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' });
}
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.' };
@@ -214,7 +272,8 @@ function parseCookies(req) {
}
function isAdmin(req) {
const h = req.headers.authorization || '';
return h === 'Bearer ' + ADMIN_PASSWORD;
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) {
@@ -657,6 +716,7 @@ const server = http.createServer(async (req, res) => {
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
return json(res, 200, out);
}
if (p === '/api/my/profile' && req.method === 'POST') {
@@ -1175,36 +1235,7 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/my/upload' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const ct = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif',
'video/mp4': 'mp4', 'video/webm': 'webm' };
if (!EXT[ct]) return json(res, 400, { error: 'Use a PNG, JPG, WebP, GIF, MP4 or WebM file.' });
const isVideo = ct.startsWith('video/');
const key = s.email + ':' + new Date().toISOString().slice(0, 10);
if ((uploadCounts.get(key) || 0) >= 10) return json(res, 400, { error: 'Upload limit for today reached (10 files).' });
let buf;
try { buf = await readRaw(req, isVideo ? 25 * 1024 * 1024 : 3 * 1024 * 1024); }
catch (e) { return json(res, 400, { error: 'File too large. Images up to 3MB, video up to 25MB.' }); }
const magicOk = buf.length > 16 && (
(ct === 'image/png' && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) ||
(ct === 'image/jpeg' && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) ||
(ct === 'image/webp' && buf.slice(0, 4).toString() === 'RIFF' && buf.slice(8, 12).toString() === 'WEBP') ||
(ct === 'image/gif' && buf.slice(0, 4).toString() === 'GIF8') ||
(ct === 'video/mp4' && buf.slice(4, 8).toString() === 'ftyp') ||
(ct === 'video/webm' && buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3));
if (!magicOk) return json(res, 400, { error: 'That file does not look like a real ' + EXT[ct].toUpperCase() + '.' });
const name = crypto.randomBytes(12).toString('hex') + '.' + EXT[ct];
uploadCounts.set(key, (uploadCounts.get(key) || 0) + 1);
// video goes to DO Spaces when configured (keeps big files off the volume);
// images stay local. Falls back to the volume if Spaces isn't set or errors.
if (isVideo && spaces.enabled()) {
try {
const url = await spaces.put('iap-uploads/' + name, buf, ct);
return json(res, 200, { url, type: 'video' });
} catch (e) { console.error('spaces put', e.message); /* fall through to volume */ }
}
fs.writeFileSync(path.join(UPLOADS_DIR, name), buf);
return json(res, 200, { url: '/uploads/' + name, type: isVideo ? 'video' : 'image' });
return handleUpload(req, res, s.email);
}
m = /^\/api\/my\/inbox\/(\d+)\/visit$/.exec(p);
if (m && req.method === 'POST') {
@@ -1289,7 +1320,116 @@ const server = http.createServer(async (req, res) => {
return json(res, r.error ? 400 : 200, r);
}
// -- admin (Bearer ADMIN_PASSWORD)
// -- 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,
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' });
return json(res, 200, { members: await accounts.listAll(500) });
}
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/site' && req.method === 'GET') {
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
return json(res, 200, { site: siteConfig() });
}
// -- 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() });
@@ -1332,6 +1472,7 @@ const server = http.createServer(async (req, res) => {
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 (/^\/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);