Ad engine v1: banner/text/login campaigns against on-chain credits
Spec 8b types 1-3. Spend accrues per campaign in batches; burns queue for the engine signer (admin runs consume() on-chain, /api/admin/burns). Rates are volume config (adrates.json), rehearsal placeholders until Marty sets the real card. Public slots serve on the ledger page; campaign manager in the members area. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
|||||||
|
// Ad engine v1 (spec §8b): banners, text ads, login ads served against
|
||||||
|
// PURCHASED on-chain credits. The chain is the money truth: spend accrues
|
||||||
|
// here, and burns are queued for the engine signer to consume() on-chain
|
||||||
|
// (admin runs the burner; see /api/admin/burns). Earned-credit pool and the
|
||||||
|
// richer §8b types (inbox, surf, rotation, directory) come later.
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
let DATA_DIR = null;
|
||||||
|
let chain = null;
|
||||||
|
const FILE = () => path.join(DATA_DIR, 'campaigns.json');
|
||||||
|
const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json');
|
||||||
|
|
||||||
|
let db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
|
||||||
|
|
||||||
|
// REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates.
|
||||||
|
// Integer math: impressions accrue per campaign; credits deduct per BATCH.
|
||||||
|
function rates() {
|
||||||
|
let saved = {};
|
||||||
|
try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {}
|
||||||
|
return Object.assign({
|
||||||
|
bannerBatch: 10, bannerCreditsPerBatch: 2, // $2.00 CPM equivalent
|
||||||
|
textBatch: 10, textCreditsPerBatch: 1, // $1.00 CPM equivalent
|
||||||
|
loginCreditsPerDay: 100, // $1.00/day
|
||||||
|
burnBatchMin: 50 // queue a burn every 50cr of spend
|
||||||
|
}, saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
try { db = JSON.parse(fs.readFileSync(FILE(), 'utf8')); } catch (e) {}
|
||||||
|
if (!db || db.v !== 1) db = { v: 1, nextId: 1, campaigns: [], burnsPending: [] };
|
||||||
|
}
|
||||||
|
function save() {
|
||||||
|
try {
|
||||||
|
const tmp = FILE() + '.tmp';
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(db));
|
||||||
|
fs.renameSync(tmp, FILE());
|
||||||
|
} catch (e) { console.error('ads save failed', e.message); }
|
||||||
|
}
|
||||||
|
function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; load(); }
|
||||||
|
|
||||||
|
const TYPES = ['banner', 'text', 'login'];
|
||||||
|
const URL_RE = /^https?:\/\/[^\s]+$/i;
|
||||||
|
|
||||||
|
// unburned spend per member = credits owed to the burn queue + campaign accruals
|
||||||
|
function unburnedSpend(memberId) {
|
||||||
|
let s = 0;
|
||||||
|
for (const b of db.burnsPending) if (b.memberId === memberId && !b.burnedTx) s += b.amount;
|
||||||
|
for (const c of db.campaigns) if (c.memberId === memberId) s += c.accrued || 0;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
async function availableCredits(memberId) {
|
||||||
|
const onchain = await chain.creditBalance(memberId, 0);
|
||||||
|
return Math.max(0, onchain - unburnedSpend(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createCampaign(owner, memberId, input) {
|
||||||
|
const type = String(input.type || '');
|
||||||
|
if (!TYPES.includes(type)) return { error: 'Unknown ad type.' };
|
||||||
|
const name = String(input.name || '').trim().slice(0, 60);
|
||||||
|
if (!name) return { error: 'Give the campaign a name.' };
|
||||||
|
const targetUrl = String(input.targetUrl || '').trim();
|
||||||
|
if (!URL_RE.test(targetUrl)) return { error: 'Target URL must start with http(s)://' };
|
||||||
|
const budget = Math.floor(Number(input.budget) || 0);
|
||||||
|
if (budget < 10) return { error: 'Minimum budget is 10 credits.' };
|
||||||
|
const avail = await availableCredits(memberId);
|
||||||
|
if (budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' };
|
||||||
|
|
||||||
|
const c = { id: db.nextId++, owner, memberId, type, name, targetUrl,
|
||||||
|
budget, spent: 0, accrued: 0, imps: 0, clicks: 0, batchImps: 0,
|
||||||
|
status: 'active', created: Date.now() };
|
||||||
|
if (type === 'banner') {
|
||||||
|
const imageUrl = String(input.imageUrl || '').trim();
|
||||||
|
if (!URL_RE.test(imageUrl)) return { error: 'Banner image URL must start with http(s)://' };
|
||||||
|
c.imageUrl = imageUrl;
|
||||||
|
}
|
||||||
|
if (type === 'text') {
|
||||||
|
c.title = String(input.title || '').trim().slice(0, 60);
|
||||||
|
c.body = String(input.body || '').trim().slice(0, 140);
|
||||||
|
if (!c.title) return { error: 'Text ads need a headline.' };
|
||||||
|
}
|
||||||
|
if (type === 'login') {
|
||||||
|
c.imageUrl = String(input.imageUrl || '').trim();
|
||||||
|
if (!URL_RE.test(c.imageUrl)) return { error: 'Login ads need an image URL.' };
|
||||||
|
}
|
||||||
|
db.campaigns.push(c);
|
||||||
|
save();
|
||||||
|
return { ok: true, campaign: pub(c) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function listCampaigns(owner) {
|
||||||
|
return db.campaigns.filter(c => c.owner === owner).map(pub);
|
||||||
|
}
|
||||||
|
function setStatus(owner, id, status) {
|
||||||
|
const c = db.campaigns.find(x => x.id === Number(id) && x.owner === owner);
|
||||||
|
if (!c) return { error: 'No such campaign.' };
|
||||||
|
if (!['active', 'paused'].includes(status)) return { error: 'Bad status.' };
|
||||||
|
if (c.status === 'out' && status === 'active' && c.spent + c.accrued >= c.budget) return { error: 'Budget exhausted. Raise it first.' };
|
||||||
|
c.status = status;
|
||||||
|
save();
|
||||||
|
return { ok: true, campaign: pub(c) };
|
||||||
|
}
|
||||||
|
function pub(c) {
|
||||||
|
return { id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null,
|
||||||
|
title: c.title || null, body: c.body || null, budget: c.budget, spent: c.spent + (c.accrued || 0),
|
||||||
|
imps: c.imps, clicks: c.clicks, status: c.status, created: c.created };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- serving ----
|
||||||
|
function chargeBatch(c, batch, credits) {
|
||||||
|
c.batchImps += 1;
|
||||||
|
if (c.batchImps >= batch) {
|
||||||
|
c.batchImps = 0;
|
||||||
|
c.accrued = (c.accrued || 0) + credits;
|
||||||
|
// roll accruals into the burn queue in chunks
|
||||||
|
const r = rates();
|
||||||
|
if (c.accrued >= r.burnBatchMin) {
|
||||||
|
db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId,
|
||||||
|
amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
|
||||||
|
c.spent += c.accrued;
|
||||||
|
c.accrued = 0;
|
||||||
|
}
|
||||||
|
if (c.spent + c.accrued >= c.budget) c.status = 'out';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function serve(type) {
|
||||||
|
const r = rates();
|
||||||
|
const pool = db.campaigns.filter(c => c.type === type && c.status === 'active');
|
||||||
|
if (!pool.length) return null;
|
||||||
|
const c = pool[Math.floor(Math.random() * pool.length)];
|
||||||
|
c.imps += 1;
|
||||||
|
if (type === 'banner') chargeBatch(c, r.bannerBatch, r.bannerCreditsPerBatch);
|
||||||
|
if (type === 'text') chargeBatch(c, r.textBatch, r.textCreditsPerBatch);
|
||||||
|
// login ads are per-day; impressions tracked, charged by the daily sweep
|
||||||
|
save();
|
||||||
|
return { id: c.id, type: c.type, targetUrl: '/api/ads/click/' + c.id,
|
||||||
|
imageUrl: c.imageUrl || null, title: c.title || null, body: c.body || null };
|
||||||
|
}
|
||||||
|
function click(id) {
|
||||||
|
const c = db.campaigns.find(x => x.id === Number(id));
|
||||||
|
if (!c) return null;
|
||||||
|
c.clicks += 1;
|
||||||
|
save();
|
||||||
|
return c.targetUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// daily charge for login ads (called by a boot + interval sweep)
|
||||||
|
function dailySweep() {
|
||||||
|
const r = rates();
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
let charged = 0;
|
||||||
|
for (const c of db.campaigns) {
|
||||||
|
if (c.type !== 'login' || c.status !== 'active' || c.lastDayCharged === today) continue;
|
||||||
|
c.lastDayCharged = today;
|
||||||
|
c.accrued = (c.accrued || 0) + r.loginCreditsPerDay;
|
||||||
|
if (c.accrued >= r.burnBatchMin) {
|
||||||
|
db.burnsPending.push({ id: crypto.randomBytes(8).toString('hex'), memberId: c.memberId,
|
||||||
|
amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
|
||||||
|
c.spent += c.accrued; c.accrued = 0;
|
||||||
|
}
|
||||||
|
if (c.spent + c.accrued >= c.budget) c.status = 'out';
|
||||||
|
charged += 1;
|
||||||
|
}
|
||||||
|
if (charged) save();
|
||||||
|
return charged;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- burn queue (admin/engine) ----
|
||||||
|
function pendingBurns() { return db.burnsPending.filter(b => !b.burnedTx); }
|
||||||
|
function markBurned(id, tx) {
|
||||||
|
const b = db.burnsPending.find(x => x.id === id);
|
||||||
|
if (!b) return { error: 'No such burn.' };
|
||||||
|
b.burnedTx = tx; b.burnedAt = Date.now();
|
||||||
|
save();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
function setRates(patch) {
|
||||||
|
const cur = rates();
|
||||||
|
fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(cur, patch), null, 2));
|
||||||
|
return rates();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
|
||||||
|
serve, click, dailySweep, availableCredits, pendingBurns, markBurned };
|
||||||
+18
-1
@@ -84,5 +84,22 @@ window.IAP = (function () {
|
|||||||
+ '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>';
|
+ '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>';
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, $ };
|
// Render one served ad into #<elId>. Silent if no inventory.
|
||||||
|
async function adSlot(type, elId) {
|
||||||
|
try {
|
||||||
|
const { ad } = await (await fetch('/api/ads/slot?type=' + type)).json();
|
||||||
|
const el = $(elId);
|
||||||
|
if (!ad || !el) return;
|
||||||
|
if (ad.imageUrl) {
|
||||||
|
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow">'
|
||||||
|
+ '<img src="' + ad.imageUrl + '" alt="advertisement" style="max-width:100%;border-radius:8px"></a>'
|
||||||
|
+ '<div class="small muted">member ad</div>';
|
||||||
|
} else {
|
||||||
|
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + ad.title + '</b>'
|
||||||
|
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad</span>';
|
||||||
|
}
|
||||||
|
el.hidden = false;
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, $ };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)';
|
IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)';
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
|
|
||||||
|
IAP.adSlot('banner', 'adSlotBanner');
|
||||||
|
IAP.adSlot('text', 'adSlotText');
|
||||||
|
|
||||||
const es = new EventSource('/api/feed/live');
|
const es = new EventSource('/api/feed/live');
|
||||||
es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; };
|
es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; };
|
||||||
es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; };
|
es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; };
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
$('linkCard').hidden = !!me.address;
|
$('linkCard').hidden = !!me.address;
|
||||||
$('activateCard').hidden = !(me.address && !me.memberId);
|
$('activateCard').hidden = !(me.address && !me.memberId);
|
||||||
$('activityArea').hidden = !me.memberId;
|
$('activityArea').hidden = !me.memberId;
|
||||||
|
$('campaignCard').hidden = !me.memberId;
|
||||||
|
if (me.memberId) loadCampaigns();
|
||||||
|
|
||||||
if (me.memberId) {
|
if (me.memberId) {
|
||||||
const bc = me.buyerCount || 0;
|
const bc = me.buyerCount || 0;
|
||||||
@@ -66,6 +68,52 @@
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCampaigns() {
|
||||||
|
try {
|
||||||
|
const r = await (await fetch('/api/my/campaigns')).json();
|
||||||
|
if (r.error) return;
|
||||||
|
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
|
||||||
|
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
|
||||||
|
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
|
||||||
|
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day';
|
||||||
|
const el = $('campList');
|
||||||
|
el.innerHTML = '';
|
||||||
|
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
|
||||||
|
const tbl = document.createElement('div');
|
||||||
|
tbl.className = 'tablewrap';
|
||||||
|
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views</th><th class="num">Clicks</th>'
|
||||||
|
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
|
||||||
|
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b></td><td>' + c.type + '</td>'
|
||||||
|
+ '<td class="num">' + c.imps.toLocaleString() + '</td><td class="num">' + c.clicks + '</td>'
|
||||||
|
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
|
||||||
|
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>'
|
||||||
|
+ '<td>' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
|
||||||
|
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
|
||||||
|
+ '</td></tr>').join('') + '</tbody></table>';
|
||||||
|
el.appendChild(tbl);
|
||||||
|
el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
|
||||||
|
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
|
||||||
|
catch (e) { IAP.status(e.message, 'bad'); }
|
||||||
|
}));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
$('cType').addEventListener('change', () => {
|
||||||
|
const t = $('cType').value;
|
||||||
|
$('cImageRow').hidden = t === 'text';
|
||||||
|
$('cTitleRow').hidden = t !== 'text';
|
||||||
|
$('cBodyRow').hidden = t !== 'text';
|
||||||
|
});
|
||||||
|
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
||||||
|
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
|
||||||
|
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
|
||||||
|
title: $('cTitle').value, body: $('cBody').value, budget: Number($('cBudget').value) });
|
||||||
|
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||||||
|
$('cName').value = ''; $('cBudget').value = '';
|
||||||
|
await loadCampaigns();
|
||||||
|
}));
|
||||||
|
// defers the busy() lookup to click time (busy is declared below)
|
||||||
|
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
|
||||||
|
|
||||||
const busy = (btn, fn) => async () => {
|
const busy = (btn, fn) => async () => {
|
||||||
try { btn.disabled = true; await fn(); }
|
try { btn.disabled = true; await fn(); }
|
||||||
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
|
||||||
|
|||||||
@@ -15,9 +15,11 @@
|
|||||||
<p><span class="badge" id="liveBadge">connecting…</span>
|
<p><span class="badge" id="liveBadge">connecting…</span>
|
||||||
<span class="small muted" id="statLine"></span></p>
|
<span class="small muted" id="statLine"></span></p>
|
||||||
</section>
|
</section>
|
||||||
|
<div class="card" id="adSlotBanner" hidden></div>
|
||||||
<div class="card" style="padding:0">
|
<div class="card" style="padding:0">
|
||||||
<div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div>
|
<div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card small" id="adSlotText" hidden></div>
|
||||||
<footer>
|
<footer>
|
||||||
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -73,6 +73,27 @@
|
|||||||
Purchases from your linked wallet automatically credit this account.</p>
|
Purchases from your linked wallet automatically credit this account.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="campaignCard" hidden>
|
||||||
|
<h3>Your ad campaigns</h3>
|
||||||
|
<p class="muted small" id="rateLine">…</p>
|
||||||
|
<div id="campList"></div>
|
||||||
|
<h3 style="margin-top:18px">New campaign</h3>
|
||||||
|
<div class="grid c3">
|
||||||
|
<p><select id="cType" style="width:100%">
|
||||||
|
<option value="banner">Banner (per impression)</option>
|
||||||
|
<option value="text">Text ad (per impression)</option>
|
||||||
|
<option value="login">Login ad (per day)</option>
|
||||||
|
</select></p>
|
||||||
|
<p><input id="cName" placeholder="Campaign name" style="width:100%"></p>
|
||||||
|
<p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
|
||||||
|
</div>
|
||||||
|
<p><input id="cTarget" placeholder="Target URL (https://…)" style="width:100%"></p>
|
||||||
|
<p id="cImageRow"><input id="cImage" placeholder="Image URL (banner/login ads)" style="width:100%"></p>
|
||||||
|
<p id="cTitleRow" hidden><input id="cTitle" placeholder="Headline (max 60)" style="width:100%"></p>
|
||||||
|
<p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
|
||||||
|
<button class="btn" id="createCampBtn">Launch campaign</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="activityArea" hidden>
|
<div id="activityArea" hidden>
|
||||||
<h2>Your activity, straight from the chain</h2>
|
<h2>Your activity, straight from the chain</h2>
|
||||||
<div class="grid c2">
|
<div class="grid c2">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { URL } = require('url');
|
|||||||
const chain = require('./chain');
|
const chain = require('./chain');
|
||||||
const auth = require('./auth');
|
const auth = require('./auth');
|
||||||
const accounts = require('./accounts');
|
const accounts = require('./accounts');
|
||||||
|
const ads = require('./ads');
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000);
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
const ROOT = __dirname;
|
const ROOT = __dirname;
|
||||||
@@ -24,6 +25,9 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
|
|||||||
chain.init({ onEvent: ev => pushFeed(ev) });
|
chain.init({ onEvent: ev => pushFeed(ev) });
|
||||||
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
||||||
accounts.init({ dataDir: DATA_DIR });
|
accounts.init({ dataDir: DATA_DIR });
|
||||||
|
ads.init({ dataDir: DATA_DIR, chain });
|
||||||
|
setTimeout(() => ads.dailySweep(), 60 * 1000);
|
||||||
|
setInterval(() => ads.dailySweep(), 60 * 60 * 1000); // login-ad daily charges
|
||||||
|
|
||||||
function siteConfig() {
|
function siteConfig() {
|
||||||
let saved = {};
|
let saved = {};
|
||||||
@@ -216,7 +220,60 @@ const server = http.createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- ad engine (spec §8b v1: banners, text, login ads)
|
||||||
|
if (p === '/api/ads/slot' && req.method === 'GET') {
|
||||||
|
const ad = ads.serve(String(u.searchParams.get('type') || 'banner'));
|
||||||
|
return json(res, 200, { ad });
|
||||||
|
}
|
||||||
|
m = /^\/api\/ads\/click\/(\d+)$/.exec(p);
|
||||||
|
if (m && req.method === 'GET') {
|
||||||
|
const target = ads.click(m[1]);
|
||||||
|
if (!target) { res.writeHead(404, baseHeaders()); return res.end(); }
|
||||||
|
res.writeHead(302, baseHeaders({ Location: target }));
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
if (p === '/api/my/campaigns' && req.method === 'GET') {
|
||||||
|
const s = auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
const memberId = await auth.refreshMemberId(s);
|
||||||
|
const out = { campaigns: ads.listCampaigns(s.email), rates: ads.rates() };
|
||||||
|
out.availableCredits = memberId ? await ads.availableCredits(memberId) : 0;
|
||||||
|
return json(res, 200, out);
|
||||||
|
}
|
||||||
|
if (p === '/api/my/campaigns' && req.method === 'POST') {
|
||||||
|
const s = auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
const memberId = await auth.refreshMemberId(s);
|
||||||
|
if (!memberId) return json(res, 400, { error: 'Buy an ad package first. Campaigns spend the on-chain credits it mints.' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
const r = await ads.createCampaign(s.email, memberId, b);
|
||||||
|
return json(res, r.error ? 400 : 200, r);
|
||||||
|
}
|
||||||
|
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
|
||||||
|
if (m && req.method === 'POST') {
|
||||||
|
const s = auth.fromRequest(req);
|
||||||
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
||||||
|
const r = ads.setStatus(s.email, m[1], m[2] === 'pause' ? 'paused' : 'active');
|
||||||
|
return json(res, r.error ? 400 : 200, r);
|
||||||
|
}
|
||||||
|
|
||||||
// -- admin (Bearer ADMIN_PASSWORD)
|
// -- admin (Bearer ADMIN_PASSWORD)
|
||||||
|
if (p === '/api/admin/burns' && req.method === 'GET') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
return json(res, 200, { pending: ads.pendingBurns() });
|
||||||
|
}
|
||||||
|
if (p === '/api/admin/burns/mark' && req.method === 'POST') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
const r = ads.markBurned(b.id, b.tx);
|
||||||
|
return json(res, r.error ? 400 : 200, r);
|
||||||
|
}
|
||||||
|
if (p === '/api/admin/rates' && req.method === 'PATCH') {
|
||||||
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
|
const b = await readBody(req);
|
||||||
|
return json(res, 200, { ok: true, rates: ads.setRates(b) });
|
||||||
|
}
|
||||||
|
|
||||||
if (p === '/api/admin/site' && req.method === 'PATCH') {
|
if (p === '/api/admin/site' && req.method === 'PATCH') {
|
||||||
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
|
|||||||
Reference in New Issue
Block a user