cdeb59ed7f
Members earn credits by attention (spec 8b): daily 5-ad set with dwell timing and a too-fast guard, then a claimable daily batch. Earned credits now FUND campaigns: banner/text budgets draw earned-first (free members can advertise on welcome credits alone), purchased credits and the on-chain burn queue only cover the remainder; earned-only campaigns pause when the pool runs dry. New Earn credits section in the member menu with the viewer. Explorer links degrade gracefully for the private chain (contract page points at the audited Amoy verification). Site flipped to the anvil rehearsal chain at rpc.instantadpay.com: unlimited test POL, no more faucets. E2E: welcome->views->claim->earned-funded campaign->charged serving, all green. Assets v=20260905d. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
472 lines
23 KiB
JavaScript
472 lines
23 KiB
JavaScript
// InstantAdPay — membership advertising with immutable on-chain settlement.
|
|
// Zero-dependency Node server (RM Circle pattern): static pages + JSON API,
|
|
// wallet sign-in (SIWE), live contract ledger, sponsor join links.
|
|
//
|
|
// Chain selection (Amoy rehearsal vs mainnet) lives in data/config.json —
|
|
// see chain.js. Wipe the volume's accounts/sessions + flip config = launch.
|
|
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { URL } = require('url');
|
|
const chain = require('./chain');
|
|
const auth = require('./auth');
|
|
const accounts = require('./accounts');
|
|
const ads = require('./ads');
|
|
const mailer = require('./mailer');
|
|
const chatbot = require('./chatbot');
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const ROOT = __dirname;
|
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
const SITE_FILE = path.join(DATA_DIR, 'site.json');
|
|
|
|
const db = require('./db');
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const chatHits = new Map();
|
|
function chatLimited(ip) {
|
|
const now = Date.now(), rec = chatHits.get(ip);
|
|
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
rec.count += 1;
|
|
return rec.count > 10;
|
|
}
|
|
// magic-code sign-in: emailLower -> {code, exp, tries}
|
|
const emailCodes = new Map();
|
|
async function boot() {
|
|
await db.init({ dataDir: DATA_DIR }); // no-op without DATABASE_URL (JSON mode)
|
|
chain.init({ onEvent: ev => pushFeed(ev) });
|
|
auth.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD, site: 'instantadpay.com' });
|
|
accounts.init({ dataDir: DATA_DIR });
|
|
ads.init({ dataDir: DATA_DIR, chain });
|
|
mailer.init({ dataDir: DATA_DIR });
|
|
chatbot.init({ dataDir: DATA_DIR, chain });
|
|
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
|
|
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
|
|
}
|
|
|
|
function siteConfig() {
|
|
let saved = {};
|
|
try { saved = JSON.parse(fs.readFileSync(SITE_FILE, 'utf8')); } catch (e) {}
|
|
return Object.assign({
|
|
siteName: 'InstantAdPay',
|
|
tagline: 'Advertise and earn. Locked in code, not promises.',
|
|
rehearsal: true // shows the testnet banner; flipped off at mainnet launch
|
|
}, saved);
|
|
}
|
|
|
|
// ---- helpers ----
|
|
const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript',
|
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.svg': 'image/svg+xml', '.webp': 'image/webp',
|
|
'.ico': 'image/x-icon', '.json': 'application/json', '.mp4': 'video/mp4', '.woff2': 'font/woff2' };
|
|
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'";
|
|
function baseHeaders(extra) {
|
|
return Object.assign({ 'Content-Security-Policy': CSP, 'X-Content-Type-Options': 'nosniff',
|
|
'Referrer-Policy': 'strict-origin-when-cross-origin' }, extra || {});
|
|
}
|
|
function json(res, code, obj, extra) {
|
|
const body = JSON.stringify(obj);
|
|
res.writeHead(code, baseHeaders(Object.assign({ 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, extra)));
|
|
res.end(body);
|
|
}
|
|
function sendFile(res, file) {
|
|
fs.readFile(file, (err, data) => {
|
|
if (err) { res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' })); return res.end('Not found'); }
|
|
const ext = path.extname(file).toLowerCase();
|
|
res.writeHead(200, baseHeaders({ 'Content-Type': MIME[ext] || 'application/octet-stream',
|
|
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600' }));
|
|
res.end(data);
|
|
});
|
|
}
|
|
function readBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let d = ''; let n = 0;
|
|
req.on('data', c => { n += c.length; if (n > 64 * 1024) { req.destroy(); reject(new Error('too big')); } d += c; });
|
|
req.on('end', () => { try { resolve(d ? JSON.parse(d) : {}); } catch (e) { reject(e); } });
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
function parseCookies(req) {
|
|
const out = {};
|
|
for (const p of (req.headers.cookie || '').split(';')) {
|
|
const i = p.indexOf('='); if (i > 0) out[p.slice(0, i).trim()] = decodeURIComponent(p.slice(i + 1).trim());
|
|
}
|
|
return out;
|
|
}
|
|
function isAdmin(req) {
|
|
const h = req.headers.authorization || '';
|
|
return h === 'Bearer ' + ADMIN_PASSWORD;
|
|
}
|
|
// A sponsor token is a numeric chain id or a site share code. Codes resolve
|
|
// to the referrer's CURRENT chain id, so activation any time before the
|
|
// referral's first purchase still locks the line to them.
|
|
async function resolveSponsorToken(tok) {
|
|
const t = String(tok || '').trim().toLowerCase();
|
|
if (!t) return 0;
|
|
if (/^\d+$/.test(t)) return Number(t);
|
|
const acct = await accounts.byCode(t);
|
|
if (!acct || !acct.address) return 0;
|
|
try { return await chain.memberIdByAccount(acct.address); } catch (e) { return 0; }
|
|
}
|
|
// The moment someone joins through a code, nudge its owner to activate.
|
|
async function nudgeReferrer(ref) {
|
|
try {
|
|
const t = String(ref || '').trim().toLowerCase();
|
|
if (!t || /^\d+$/.test(t) || !mailer.hasKey()) return;
|
|
const owner = await accounts.byCode(t);
|
|
if (!owner || !owner.email || owner.address) return; // already activated-ready
|
|
mailer.send(owner.email, 'Someone just joined through your InstantAdPay link',
|
|
'Good news: a new member just signed up through your share link.\n\n'
|
|
+ 'One thing to do so you never miss a payment: sign in and switch on payouts '
|
|
+ '(one free wallet step). The contract locks each buyer to their sponsor at their '
|
|
+ 'first purchase, so have payouts on before your people start buying.\n\n'
|
|
+ 'https://instantadpay.com/my\n\nInstantAdPay').catch(e => console.error('nudge failed', e.message));
|
|
} catch (e) {}
|
|
}
|
|
|
|
// ---- live feed (SSE) ----
|
|
const feedClients = new Set();
|
|
function pushFeed(ev) {
|
|
const line = 'data: ' + JSON.stringify(ev) + '\n\n';
|
|
for (const res of feedClients) { try { res.write(line); } catch (e) { feedClients.delete(res); } }
|
|
}
|
|
|
|
// ---- server ----
|
|
const server = http.createServer(async (req, res) => {
|
|
try {
|
|
const u = new URL(req.url, 'http://x');
|
|
const p = u.pathname;
|
|
|
|
// -- join links: /join/<memberId or share code> — first-touch cookie.
|
|
// Codes resolve LATE (at buy time) to whatever chain id the referrer
|
|
// has by then, so free members refer from day one.
|
|
let m = /^\/join\/([A-Za-z0-9]{1,16})$/.exec(p);
|
|
if (m && req.method === 'GET') {
|
|
const tok = m[1].toLowerCase();
|
|
const cookies = parseCookies(req);
|
|
const headers = { Location: '/' };
|
|
if (!cookies['iap.sponsor']) {
|
|
headers['Set-Cookie'] = `iap.sponsor=${tok}; Path=/; SameSite=Lax; Max-Age=${180 * 24 * 3600}${IS_PROD ? '; Secure' : ''}`;
|
|
}
|
|
res.writeHead(302, baseHeaders(headers));
|
|
return res.end();
|
|
}
|
|
|
|
// -- public API
|
|
if (p === '/api/config' && req.method === 'GET') {
|
|
const c = chain.getConfig();
|
|
return json(res, 200, Object.assign({ contract: c.contract, chainId: c.chainId,
|
|
chainName: c.chainName, explorer: c.explorer, rpc: c.rpcs[0],
|
|
emailAuth: mailer.hasKey() || !IS_PROD }, siteConfig()));
|
|
}
|
|
if (p === '/api/catalog' && req.method === 'GET') {
|
|
return json(res, 200, { products: await chain.catalog() });
|
|
}
|
|
if (p === '/api/feed' && req.method === 'GET') {
|
|
return json(res, 200, { events: chain.recentEvents(Number(u.searchParams.get('n')) || 100) });
|
|
}
|
|
if (p === '/api/feed/live' && req.method === 'GET') {
|
|
res.writeHead(200, baseHeaders({ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' }));
|
|
res.write(': connected\n\n');
|
|
feedClients.add(res);
|
|
req.on('close', () => feedClients.delete(res));
|
|
return;
|
|
}
|
|
m = /^\/api\/tx\/(0x[0-9a-fA-F]{64})$/.exec(p);
|
|
if (m && req.method === 'GET') {
|
|
// receipt relay so the browser never talks to the RPC directly (CSP stays 'self')
|
|
try {
|
|
const r = await chain.rpc('eth_getTransactionReceipt', [m[1]]);
|
|
return json(res, 200, r ? { found: true, status: r.status, blockNumber: r.blockNumber } : { found: false });
|
|
} catch (e) { return json(res, 200, { found: false, rpcError: true }); }
|
|
}
|
|
if (p === '/api/sponsor' && req.method === 'GET') {
|
|
const tok = parseCookies(req)['iap.sponsor'] || '';
|
|
const sponsorId = await resolveSponsorToken(tok);
|
|
return json(res, 200, { ref: tok, sponsorId, invited: !!tok });
|
|
}
|
|
if (p === '/api/stats' && req.method === 'GET') {
|
|
let members = 0; try { members = await chain.memberCount(); } catch (e) {}
|
|
return json(res, 200, Object.assign({ onchainMembers: members, siteAccounts: await accounts.count() }, chain.totals()));
|
|
}
|
|
|
|
// -- 24/7 assistant
|
|
if (p === '/api/chat' && req.method === 'POST') {
|
|
const ip = req.socket.remoteAddress || 'x';
|
|
if (chatLimited(ip)) return json(res, 429, { error: 'Give it a minute, then ask again.' });
|
|
const b = await readBody(req);
|
|
const r = await chatbot.answer(b.message, b.history);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
|
|
// -- accounts: email + password is the normal join path (wallet comes
|
|
// out only at purchase / payout-activation time and gets linked then)
|
|
if (p === '/api/signup' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const ref = parseCookies(req)['iap.sponsor'] || ''; // first-touch attribution
|
|
const r = await accounts.signup(b.email, b.password, ref);
|
|
if (r.error) return json(res, 400, r);
|
|
nudgeReferrer(ref).catch(() => {});
|
|
const token = await auth.mintSession({ email: r.account.email });
|
|
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
|
}
|
|
if (p === '/api/login' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const r = await accounts.login(b.email, b.password);
|
|
if (r.error) return json(res, 400, r);
|
|
let memberId = 0;
|
|
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (e) {} }
|
|
const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
|
|
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
|
}
|
|
|
|
// -- passwordless: email code sign-in (signup and login are the same act)
|
|
if (p === '/api/auth/email/start' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const e = String(b.email || '').trim().toLowerCase();
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)) return json(res, 400, { error: 'That email address does not look right.' });
|
|
const prev = emailCodes.get(e);
|
|
if (prev && Date.now() < prev.nextAt) return json(res, 429, { error: 'Code already sent. Give it a minute, then try again.' });
|
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
|
emailCodes.set(e, { code, exp: Date.now() + 15 * 60 * 1000, tries: 0, nextAt: Date.now() + 60 * 1000 });
|
|
if (mailer.hasKey()) {
|
|
try { await mailer.sendCode(e, code); } catch (err) {
|
|
console.error('sendCode failed', err.message);
|
|
return json(res, 502, { error: 'Could not send the email. Try again in a minute.' });
|
|
}
|
|
return json(res, 200, { ok: true, sent: true });
|
|
}
|
|
if (!IS_PROD) return json(res, 200, { ok: true, sent: false, devCode: code });
|
|
return json(res, 503, { error: 'Email sign-in is not configured yet.' });
|
|
}
|
|
if (p === '/api/auth/email/verify' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const e = String(b.email || '').trim().toLowerCase();
|
|
const rec = emailCodes.get(e);
|
|
if (!rec || rec.exp < Date.now()) return json(res, 400, { error: 'Code expired. Request a fresh one.' });
|
|
rec.tries += 1;
|
|
if (rec.tries > 6) { emailCodes.delete(e); return json(res, 400, { error: 'Too many tries. Request a fresh code.' }); }
|
|
if (String(b.code || '').trim() !== rec.code) return json(res, 400, { error: 'That code does not match.' });
|
|
emailCodes.delete(e);
|
|
const ref = parseCookies(req)['iap.sponsor'] || '';
|
|
const r = await accounts.ensure(e, ref); // first touch wins; existing accounts unchanged
|
|
if (r.error) return json(res, 400, r);
|
|
if (r.created) nudgeReferrer(ref).catch(() => {});
|
|
let memberId = 0;
|
|
if (r.account.address) { try { memberId = await chain.memberIdByAccount(r.account.address); } catch (err) {} }
|
|
const token = await auth.mintSession({ email: r.account.email, address: r.account.address, memberId });
|
|
return json(res, 200, { ok: true, account: r.account }, { 'Set-Cookie': auth.sessionCookie(token) });
|
|
}
|
|
|
|
// -- wallet auth: link-to-account when an email session exists, or
|
|
// wallet-first sign-in for crypto-native users
|
|
if (p === '/api/auth/challenge' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const r = auth.makeChallenge(b.address);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
if (p === '/api/auth/verify' && req.method === 'POST') {
|
|
const b = await readBody(req);
|
|
const r = await auth.verifyChallenge(b.address, b.signature);
|
|
if (r.error) return json(res, 400, r);
|
|
let memberId = 0;
|
|
try { memberId = await chain.memberIdByAccount(r.address); } catch (e) {}
|
|
const s = await auth.fromRequest(req);
|
|
if (s && s.email) {
|
|
const lr = await accounts.linkWallet(s.email, r.address);
|
|
if (lr.error) return json(res, 400, lr);
|
|
await auth.updateSession(s.token, { address: r.address, memberId });
|
|
return json(res, 200, { ok: true, linked: true, address: r.address, memberId });
|
|
}
|
|
const acct = await accounts.byAddress(r.address);
|
|
const token = await auth.mintSession({ email: acct ? acct.email : null, address: r.address, memberId });
|
|
return json(res, 200, { ok: true, address: r.address, memberId },
|
|
{ 'Set-Cookie': auth.sessionCookie(token) });
|
|
}
|
|
if (p === '/api/auth/logout' && req.method === 'POST') {
|
|
await auth.logout(req);
|
|
return json(res, 200, { ok: true }, { 'Set-Cookie': auth.clearCookie() });
|
|
}
|
|
if (p === '/api/me' && req.method === 'GET') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s) return json(res, 200, { signedIn: false });
|
|
const memberId = await auth.refreshMemberId(s);
|
|
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
|
|
const sponsorId = await resolveSponsorToken((acct && acct.sponsorRef) || parseCookies(req)['iap.sponsor']);
|
|
const out = { signedIn: true, email: s.email || (acct && acct.email) || null,
|
|
address: s.address || (acct && acct.address) || null, memberId,
|
|
refCode: (acct && acct.code) || null, sponsorId };
|
|
if (memberId) {
|
|
try {
|
|
const mm = await chain.member(memberId);
|
|
out.buyerCount = mm.buyerCount;
|
|
out.onchainSponsorId = mm.sponsorId;
|
|
out.credits = await chain.creditBalance(memberId, 0);
|
|
} catch (e) { out.chainReadError = true; }
|
|
}
|
|
return json(res, 200, out);
|
|
}
|
|
|
|
if (p === '/api/my/dashboard' && req.method === 'GET') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s) return json(res, 401, { error: 'Sign in first.' });
|
|
const memberId = await auth.refreshMemberId(s);
|
|
const acct = (s.email && await accounts.byEmail(s.email)) || (s.address && await accounts.byAddress(s.address)) || null;
|
|
const out = { memberId, email: s.email || (acct && acct.email) || null,
|
|
address: s.address || (acct && acct.address) || null,
|
|
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
|
|
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
|
|
if (out.email) out.welcomeCredits = await ads.grantWelcome(out.email); // idempotent lazy grant
|
|
if (memberId) {
|
|
try {
|
|
const mm = await chain.member(memberId);
|
|
out.buyerCount = mm.buyerCount;
|
|
out.credits = await ads.availableCredits(memberId);
|
|
} catch (e) { out.chainReadError = true; }
|
|
let earned = 0n, n = 0;
|
|
for (const ev of chain.recentEvents(600)) {
|
|
if ((ev.type === 'TierPaid' && ev.recipientId === memberId) || (ev.type === 'AwardPaid' && ev.toId === memberId)) {
|
|
earned += BigInt(ev.amountWei); n += 1;
|
|
}
|
|
}
|
|
out.earnedWei = earned.toString();
|
|
out.earnCount = n;
|
|
}
|
|
// who joined through this member (code and, once on-chain, numeric id)
|
|
const refs = [];
|
|
if (acct && acct.code) refs.push(acct.code);
|
|
if (memberId) refs.push(String(memberId));
|
|
const joined = await accounts.listByReferrer(refs);
|
|
out.referrals = joined.map(r => ({
|
|
email: r.email.replace(/^(.).*(@.*)$/, '$1***$2'), // privacy mask
|
|
joined: r.created,
|
|
status: r.address ? 'wallet linked' : 'joined free'
|
|
}));
|
|
return json(res, 200, out);
|
|
}
|
|
// -- earn credits by viewing ads (attention-gated daily claim)
|
|
if (p === '/api/my/earn' && req.method === 'GET') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
|
return json(res, 200, await ads.viewStatus(s.email));
|
|
}
|
|
if (p === '/api/my/adview' && req.method === 'POST') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
|
return json(res, 200, await ads.recordView(s.email));
|
|
}
|
|
if (p === '/api/my/claim' && req.method === 'POST') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
|
const r = await ads.claimDaily(s.email);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
if (p === '/api/my/activity' && req.method === 'GET') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s) return json(res, 401, { error: 'Sign in first.' });
|
|
const id = s.memberId || await auth.refreshMemberId(s);
|
|
if (!id) return json(res, 200, { memberId: 0, earnings: [], purchases: [], referrals: [] });
|
|
const evs = chain.recentEvents(600);
|
|
return json(res, 200, {
|
|
memberId: id,
|
|
earnings: evs.filter(e => (e.type === 'TierPaid' && e.recipientId === id) || (e.type === 'AwardPaid' && e.toId === id)),
|
|
purchases: evs.filter(e => e.type === 'Purchase' && e.buyerId === id),
|
|
referrals: evs.filter(e => (e.type === 'MemberActivated' && e.sponsorId === id) || (e.type === 'BuyerCounted' && e.sponsorId === id))
|
|
});
|
|
}
|
|
|
|
// -- ad engine (spec §8b v1: banners, text, login ads)
|
|
if (p === '/api/ads/slot' && req.method === 'GET') {
|
|
const ad = await 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 = await ads.click(m[1]);
|
|
if (!target) { res.writeHead(404, baseHeaders()); return res.end(); }
|
|
res.writeHead(302, baseHeaders({ Location: target }));
|
|
return res.end();
|
|
}
|
|
if (p === '/api/my/campaigns' && req.method === 'GET') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
|
const memberId = await auth.refreshMemberId(s);
|
|
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates() };
|
|
out.purchasedCredits = memberId ? await ads.availableCredits(memberId) : 0;
|
|
out.earnedCredits = await ads.earnedBalance(s.email);
|
|
out.availableCredits = out.purchasedCredits + out.earnedCredits;
|
|
return json(res, 200, out);
|
|
}
|
|
if (p === '/api/my/campaigns' && req.method === 'POST') {
|
|
const s = await auth.fromRequest(req);
|
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
|
const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text
|
|
const b = await readBody(req);
|
|
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 = await auth.fromRequest(req);
|
|
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
|
|
const r = await ads.setStatus(s.email, m[1], m[2] === 'pause' ? 'paused' : 'active');
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
|
|
// -- admin (Bearer ADMIN_PASSWORD)
|
|
if (p === '/api/admin/burns' && req.method === 'GET') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
return json(res, 200, { pending: await ads.pendingBurns() });
|
|
}
|
|
if (p === '/api/admin/burns/mark' && req.method === 'POST') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
const b = await readBody(req);
|
|
const r = await ads.markBurned(b.id, b.tx);
|
|
return json(res, r.error ? 400 : 200, r);
|
|
}
|
|
if (p === '/api/admin/rates' && req.method === 'PATCH') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
const b = await readBody(req);
|
|
return json(res, 200, { ok: true, rates: ads.setRates(b) });
|
|
}
|
|
|
|
if (p === '/api/admin/site' && req.method === 'PATCH') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
const b = await readBody(req);
|
|
const cur = siteConfig();
|
|
fs.writeFileSync(SITE_FILE, JSON.stringify(Object.assign(cur, b), null, 2));
|
|
return json(res, 200, { ok: true, site: siteConfig() });
|
|
}
|
|
if (p === '/api/admin/chain' && req.method === 'PATCH') {
|
|
if (!isAdmin(req)) return json(res, 401, { error: 'auth' });
|
|
const b = await readBody(req);
|
|
const file = path.join(DATA_DIR, 'config.json');
|
|
let cur = {}; try { cur = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
|
|
fs.writeFileSync(file, JSON.stringify(Object.assign(cur, b), null, 2));
|
|
chain.reloadConfig();
|
|
return json(res, 200, { ok: true, config: chain.getConfig() });
|
|
}
|
|
|
|
// -- pages
|
|
if (req.method === 'GET') {
|
|
if (p === '/') return sendFile(res, path.join(PUBLIC_DIR, 'index.html'));
|
|
if (p === '/ledger') return sendFile(res, path.join(PUBLIC_DIR, 'ledger.html'));
|
|
if (p === '/contract') return sendFile(res, path.join(PUBLIC_DIR, 'contract.html'));
|
|
if (p === '/my') return sendFile(res, path.join(PUBLIC_DIR, 'my.html'));
|
|
const safe = path.normalize(p).replace(/^([.\\/])+/, '');
|
|
const file = path.join(PUBLIC_DIR, safe);
|
|
if (file.startsWith(PUBLIC_DIR) && fs.existsSync(file) && fs.statSync(file).isFile()) return sendFile(res, file);
|
|
}
|
|
|
|
res.writeHead(404, baseHeaders({ 'Content-Type': 'text/plain' }));
|
|
res.end('Not found');
|
|
} catch (e) {
|
|
console.error('request error', req.url, e.message);
|
|
try { json(res, 500, { error: 'server error' }); } catch (_) {}
|
|
}
|
|
});
|
|
boot().then(() => {
|
|
server.listen(PORT, () => console.log(`InstantAdPay site on :${PORT} — chain: ${chain.getConfig().chainName} — store: ${db.enabled() ? 'MySQL' : 'volume JSON'}`));
|
|
}).catch(e => { console.error('boot failed:', e.message); process.exit(1); });
|