010e8d7ffc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1581 lines
88 KiB
JavaScript
1581 lines
88 KiB
JavaScript
// 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)
|
||
|
||
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@linkspin-test.saasy.top';
|
||
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(/ |