diff --git a/accounts.js b/accounts.js index e74ecb9..d2f513b 100644 --- a/accounts.js +++ b/accounts.js @@ -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), diff --git a/ads.js b/ads.js index 0d14ba5..22324e7 100644 Binary files a/ads.js and b/ads.js differ diff --git a/db.js b/db.js index 96af0c5..eca2d31 100644 --- a/db.js +++ b/db.js @@ -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, diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..db04643 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,224 @@ + + + + + +Admin | InstantAdPay + + + + + + + + +
+
+
+

InstantAdPay

+

Admin sign in

+

Only the admin address can sign in here. A one-time code goes to that inbox.

+
+
+

+ + +

+ + +

+

Back to the member area

+
+
+
+ + + + + + + + diff --git a/public/assets/admin.js b/public/assets/admin.js new file mode 100644 index 0000000..6c8fe60 --- /dev/null +++ b/public/assets/admin.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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]) => '
' + esc(t) + '' + n + '
').join('') : 'No campaigns yet.'; + const ch = o.chain || {}; + $('ovChain').innerHTML = '
' + esc(ch.chainName) + ' (chain ' + esc(ch.chainId) + ')
' + + '
' + esc(ch.contract) + '
' + + (ch.explorer ? 'Open in explorer →' : ''); + } + + // ── 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 ? '' : esc(c.title || c.name); + const act = c.status === 'active' ? '' + : c.status === 'paused' ? '' : ''; + return '#' + c.id + (c.house ? 'HOUSE' : '') + '' + + (showOwner ? '' + esc(c.house ? 'house' : c.owner) + '' : '') + + '' + esc(c.type) + '' + + '' + esc(c.name) + '
' + creative + '
' + esc(c.targetUrl) + '' + + '' + esc(c.status) + '' + + '' + (c.spent || 0).toLocaleString() + ' / ' + (c.budget || 0).toLocaleString() + '
' + left.toLocaleString() + ' left
' + + '' + (c.imps || 0).toLocaleString() + (c.impsNas ? ' +' + c.impsNas + ' nas' : '') + '
' + (c.clicks || 0) + ' clicks
' + + '' + when(c.created) + '' + + '' + act + ''; + } + function campHead(showOwner) { + return 'ID' + (showOwner ? 'Owner' : '') + 'TypeCampaignStatusSpent / capDeliveryCreated'; + } + 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 => '').join(''); + if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '').join(''); + if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '').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('') : 'No house ads yet. Place one above.'; + } + 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('') : 'Nothing matches.'; + } + $('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 = 'EmailUsernameMember #WalletSponsorCodeJoined' + + list.map(a => '' + esc(a.email) + '' + (a.username ? '@' + esc(a.username) : 'none') + '' + + '' + (a.memberId ? '#' + a.memberId : 'free') + '' + + '' + (a.address ? esc(a.address.slice(0, 8) + '…' + a.address.slice(-6)) : 'none') + '' + + '' + esc(a.sponsorRef || '') + '' + esc(a.code || '') + '' + + '' + when(a.created) + '' + + '').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 ? 'WhenCampaignReasonNoteBy' + + reps.map(x => '' + when(x.ts) + '#' + x.campaignId + '' + esc(x.reason) + '' + esc(x.note || '') + '' + esc(x.reporter || 'anon') + '' + + '' + (x.resolved ? 'resolved' : '') + '').join('') + : 'No reports.'; + const burns = b.pending || []; + $('burnTable').innerHTML = burns.length ? 'WhenMemberCreditsRefBurn id' + + burns.map(x => '' + when(x.ts) + '#' + x.memberId + '' + x.amount + '' + esc(x.ref) + '' + esc(x.id) + '').join('') + : 'Nothing pending.'; + } + 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(); +})(); diff --git a/public/assets/common.js b/public/assets/common.js index e7cc605..a1010bd 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -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); diff --git a/public/assets/my.js b/public/assets/my.js index b6e5b4d..d1b850e 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -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; diff --git a/public/contract.html b/public/contract.html index 8dd465c..4e10436 100644 --- a/public/contract.html +++ b/public/contract.html @@ -141,7 +141,7 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
- + diff --git a/public/disclaimer.html b/public/disclaimer.html index 2364db7..41abdb2 100644 --- a/public/disclaimer.html +++ b/public/disclaimer.html @@ -26,7 +26,7 @@

You decide whether, and how much, to spend. Never spend more than you can afford to lose.

- + diff --git a/public/index.html b/public/index.html index 19e7114..d1499f7 100644 --- a/public/index.html +++ b/public/index.html @@ -438,7 +438,7 @@ - + diff --git a/public/ledger.html b/public/ledger.html index c0130de..75c357d 100644 --- a/public/ledger.html +++ b/public/ledger.html @@ -37,7 +37,7 @@
InstantAdPay · how it works · contract source ↗
- + diff --git a/public/my.html b/public/my.html index a0c8570..5c27005 100644 --- a/public/my.html +++ b/public/my.html @@ -139,6 +139,7 @@ - + - + diff --git a/public/privacy.html b/public/privacy.html index efe7135..37ca060 100644 --- a/public/privacy.html +++ b/public/privacy.html @@ -28,7 +28,7 @@

We use reasonable safeguards, but no system is perfectly secure. Protect your email and your wallet.

- + diff --git a/public/robots.txt b/public/robots.txt index d399d23..3fc9f01 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -1,5 +1,6 @@ User-agent: * Allow: / Disallow: /my +Disallow: /admin Disallow: /api/ Sitemap: https://instantadpay.com/sitemap.xml diff --git a/public/terms.html b/public/terms.html index 5dbbc09..74d96ff 100644 --- a/public/terms.html +++ b/public/terms.html @@ -36,7 +36,7 @@

See also the Disclaimer and Privacy Policy.

- + diff --git a/public/tx.html b/public/tx.html index 4e20cff..c48c659 100644 --- a/public/tx.html +++ b/public/tx.html @@ -34,7 +34,7 @@

← Back to the live ledger · Read the contract review

- + diff --git a/public/wall.html b/public/wall.html index b0931eb..ee05077 100644 --- a/public/wall.html +++ b/public/wall.html @@ -39,7 +39,7 @@ - + diff --git a/server.js b/server.js index 9b7ce9c..bbb7f38 100644 --- a/server.js +++ b/server.js @@ -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);