// 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'); const nas = require('./nas'); // NAS syndication (inert unless NAS_DB_* env is set) const adrevlnks = require('./adrevlnks'); // AdRevLinks popup syndication (inert unless ADREVLNKS_BRIDGE_* is set) const dripoffers = require('./dripoffers'); // DripOffers paid-per-click syndication (inert unless DRIPOFFERS_BRIDGE_* is set) 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. // Admin house ads: owned by this pseudo-account, funded by nothing. They serve // through every normal path but the spend is never charged to anyone; the // budget only acts as a delivery cap (status flips to 'out' when it's reached). const HOUSE_OWNER = 'house@instantadpay.com'; 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: 10, // Marty 2026-09-09: at least 10s so people actually look // 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, // featured rotation: your link runs in the featured strip for N days; the // dilution (how many links share the rotation) is disclosed before you buy featuredPerDay: 40, // credits per day featuredDurations: [1, 2, 7], featuredSlotsPerDay: 10, // cap on links sharing the rotation on any one day featuredWindowDays: 7, // how far ahead a day can be booked // verified visits: buy a pack of guaranteed unique human visits; each is a // dwelled + captcha-verified visit by a distinct member (never a repeat) visitCostPerVisit: 3, // credits the advertiser pays per delivered visit visitMinPack: 20, // smallest pack visitReward: 1, // credits the viewer earns per verified visit visitDwellSeconds: 8, visitCapPerDay: 20, // per-viewer daily cap on rewarded visits // credit bonuses paid once when a member reaches each milestone (the same // ladder as the Overview stepper). Keys: payouts / firstBuyer / level2 / level3 milestoneBonus: { payouts: 10, firstBuyer: 25, level2: 50, level3: 100 } }, 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', 'featured', 'visits']; // 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' }, { id: '125x125', w: 125, h: 125, label: 'Square button 125×125' } ]; // 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)) ? '