// Ad engine v1 (spec §8b types 1-3). Dual-mode like accounts.js: // MySQL (db.enabled) with guarded UPDATEs for the concurrent serving path, // JSON volume file fallback for local dev. // Spend accrues per campaign; burns queue for the engine signer to consume() // on-chain (/api/admin/burns). All exported functions are async. const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const db = require('./db'); let DATA_DIR = null; let chain = null; const RATES_FILE = () => path.join(DATA_DIR, 'adrates.json'); // REHEARSAL PLACEHOLDER RATES — admin-adjustable via /api/admin/rates. function rates() { let saved = {}; try { saved = JSON.parse(fs.readFileSync(RATES_FILE(), 'utf8')); } catch (e) {} return Object.assign({ bannerBatch: 10, bannerCreditsPerBatch: 2, textBatch: 10, textCreditsPerBatch: 1, loginCreditsPerDay: 100, loginDwellSeconds: 10, // full-screen interstitial after sign-in burnBatchMin: 50, welcomeCredits: 25, dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim) dailyClaimCredits: 5, viewDwellSeconds: 5, // onsite solo ads: full-message inbox delivery, charged per guaranteed recipient soloCostPerRecipient: 5, soloMinRecipients: 10, soloReadCredits: 2, // earned by the reader per rewarded read soloReadCapPerDay: 5, soloReadDwellSeconds: 10, // watch-to-earn video ads: advertiser picks a required watch length, which // sets the per-view price; the viewer earns per completed watch videoTiers: [ { secs: 10, cost: 3, reward: 1 }, { secs: 30, cost: 7, reward: 2 }, { secs: 60, cost: 12, reward: 4 } ], videoWatchCapPerDay: 8 }, saved); } function setRates(patch) { fs.writeFileSync(RATES_FILE(), JSON.stringify(Object.assign(rates(), patch), null, 2)); return rates(); } const TYPES = ['banner', 'text', 'login', 'solo', 'video']; // standard IAB sizes — ids map straight to NAS sponsorads width/height (pid 2) const BANNER_SIZES = [ { id: '728x90', w: 728, h: 90, label: 'Leaderboard 728×90' }, { id: '300x250', w: 300, h: 250, label: 'Medium rectangle 300×250' }, { id: '468x60', w: 468, h: 60, label: 'Banner 468×60' }, { id: '160x600', w: 160, h: 600, label: 'Wide skyscraper 160×600' }, { id: '120x600', w: 120, h: 600, label: 'Skyscraper 120×600' }, { id: '320x50', w: 320, h: 50, label: 'Mobile leaderboard 320×50' } ]; // solo bodies are member-authored rich text rendered in OTHER members' // browsers: whitelist-sanitize server-side — known tags only, no attributes // (except http(s) hrefs, rebuilt clean), every stray angle bracket escaped const SOLO_TAGS = new Set(['b', 'strong', 'i', 'em', 'u', 's', 'p', 'br', 'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'div', 'span', 'figure']); function sanitizeSolo(html) { const src = String(html || '').replace(//g, '') .replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, '') // drop script/style content whole .replace(/<(script|style)\b[^>]*>/gi, '') // and any unclosed opener .slice(0, 12000).replace(//g, ''); const keep = []; const tokenized = src.replace(/<\s*(\/?)\s*([a-zA-Z0-9]+)((?:[^>"']|"[^"]*"|'[^']*')*)>/g, (m, close, tag, attrs) => { tag = tag.toLowerCase(); let out = ''; const safeSrc = s => /^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif|mp4|webm)|https:\/\/[^\s"'<>]+)$/i.test(s); const srcOf = a => { const m2 = /src\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(a || ''); return (m2 && (m2[1] || m2[2])) || ''; }; if (tag === 'a') { if (close) out = ''; else { const hm = /href\s*=\s*(?:"([^"]*)"|'([^']*)')/i.exec(attrs || ''); const href = (hm && (hm[1] || hm[2])) || ''; out = URL_RE.test(href) ? '' : ''; } } else if (tag === 'img') { // inline images: only our uploads or https, no other attrs const s = srcOf(attrs); out = (!close && safeSrc(s)) ? '' : ''; } else if (tag === 'video') { out = close ? '' : (safeSrc(srcOf(attrs)) ? '