LinkSpin test area: InstantAdPay engine fork rebranded, network registry, sponsor carry-over with engine activation and claim window, rotator with /r/ redirects, link-domain mini-sites

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-15 16:16:52 -05:00
commit 010e8d7ffc
130 changed files with 20096 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
// The rotator (LinkSpin's core tool, 2026-09-15): a member owns rotations; each rotation has a short
// code and a list of destinations with weights. /r/<code> picks one by weight, records the hit
// (day, country, source, device, a daily-salted visitor hash so uniques are honest, bot filter)
// and redirects in one hop. Destinations can be paused; a fallback catches the empty case.
// Health checks (a dead or parked destination is paused automatically) come in a later pass.
// JSON store on the volume for the test area; the MySQL twin follows the engine's dual-mode pattern.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
let DATA_DIR = null, geo = null;
const DAY = 86400000;
const J = {
db: null,
FILE: () => path.join(DATA_DIR, 'rotator.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { v: 1, rotations: [], hits: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
function init(opts) { DATA_DIR = opts.dataDir; geo = opts.geo || null; J.load(); }
const norm = e => String(e || '').trim().toLowerCase();
const newCode = () => { const a = 'abcdefghjkmnpqrstuvwxyz23456789'; let s = ''; for (let i = 0; i < 6; i++) s += a[crypto.randomInt(a.length)]; return s; };
const BOT = /bot|crawl|spider|slurp|facebookexternalhit|preview|telegrambot|whatsapp|curl|wget|python-requests|headless/i;
const cleanUrl = u => { const s = String(u || '').trim(); return /^https?:\/\/[^\s]+$/i.test(s) && s.length <= 500 ? s : null; };
function pub(r) {
const hits = J.db.hits.filter(h => h.r === r.id);
const since7 = Date.now() - 7 * DAY;
const perDest = {};
for (const h of hits) { const d = perDest[h.d] = perDest[h.d] || { hits: 0, hits7: 0, uniques: new Set() }; d.hits++; if (h.ts >= since7) d.hits7++; d.uniques.add(h.v); }
return { id: r.id, code: r.code, name: r.name, owner: r.owner, created: r.created, fallback: r.fallback || '', paused: !!r.paused,
destinations: r.destinations.map(d => Object.assign({}, d, { hits: (perDest[d.id] || { hits: 0 }).hits, hits7: (perDest[d.id] || { hits7: 0 }).hits7, uniques: (perDest[d.id] || { uniques: new Set() }).uniques.size })),
hits: hits.length, hits7: hits.filter(h => h.ts >= since7).length, uniques: new Set(hits.map(h => h.v)).size, bots: hits.filter(h => h.bot).length };
}
async function list(owner) { return J.db.rotations.filter(r => r.owner === norm(owner)).map(pub); }
async function get(owner, id) { const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); return r ? pub(r) : null; }
async function create(owner, body) {
const name = String(body.name || '').trim().slice(0, 80); if (!name) return { error: 'Give the rotation a name.' };
if (J.db.rotations.filter(r => r.owner === norm(owner)).length >= 100) return { error: 'That is a lot of rotations. Archive some first.' };
let code = newCode(); while (J.db.rotations.some(r => r.code === code)) code = newCode();
const r = { id: (J.db.rotations.reduce((m, x) => Math.max(m, x.id), 0) + 1), owner: norm(owner), name, code, fallback: cleanUrl(body.fallback) || '', destinations: [], created: Date.now(), paused: false, nextDest: 1 };
J.db.rotations.push(r); J.save(); return { ok: true, rotation: pub(r) };
}
async function update(owner, id, body) {
const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return { error: 'No such rotation.' };
if (body.name != null) { const n = String(body.name).trim().slice(0, 80); if (n) r.name = n; }
if (body.fallback != null) r.fallback = cleanUrl(body.fallback) || '';
if (body.paused != null) r.paused = !!body.paused;
J.save(); return { ok: true, rotation: pub(r) };
}
async function remove(owner, id) { const n = J.db.rotations.length; J.db.rotations = J.db.rotations.filter(r => !(r.id === Number(id) && r.owner === norm(owner))); J.save(); return n !== J.db.rotations.length ? { ok: true } : { error: 'No such rotation.' }; }
async function addDest(owner, id, body) {
const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return { error: 'No such rotation.' };
const url = cleanUrl(body.url); if (!url) return { error: 'Destination must be a full https:// address.' };
if (r.destinations.length >= 25) return { error: 'Up to 25 destinations per rotation.' };
const w = Math.max(1, Math.min(100, Math.round(Number(body.weight) || 1)));
const d = { id: r.nextDest++, url, label: String(body.label || '').trim().slice(0, 60), weight: w, active: true, added: Date.now() };
r.destinations.push(d); J.save(); return { ok: true, rotation: pub(r) };
}
async function updateDest(owner, id, did, body) {
const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return { error: 'No such rotation.' };
const d = r.destinations.find(d => d.id === Number(did)); if (!d) return { error: 'No such destination.' };
if (body.url != null) { const u = cleanUrl(body.url); if (!u) return { error: 'Destination must be a full https:// address.' }; d.url = u; }
if (body.label != null) d.label = String(body.label).trim().slice(0, 60);
if (body.weight != null) d.weight = Math.max(1, Math.min(100, Math.round(Number(body.weight) || 1)));
if (body.active != null) d.active = !!body.active;
if (body.remove) r.destinations = r.destinations.filter(x => x.id !== d.id);
J.save(); return { ok: true, rotation: pub(r) };
}
// pick by weight among active destinations
function pick(r) {
const live = r.destinations.filter(d => d.active);
if (!live.length) return null;
const total = live.reduce((n, d) => n + d.weight, 0); let x = crypto.randomInt(total);
for (const d of live) { x -= d.weight; if (x < 0) return d; }
return live[live.length - 1];
}
// the redirect: returns { url, dest } or null (unknown code). Records the hit.
async function resolve(code, req) {
const r = J.db.rotations.find(r => r.code === norm(code)); if (!r) return null;
const ua = String(req.headers['user-agent'] || ''); const bot = BOT.test(ua) || !ua;
const d = r.paused ? null : pick(r);
const url = d ? d.url : (r.fallback || null);
const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
const day = new Date().toISOString().slice(0, 10);
const v = crypto.createHash('sha256').update(day + '|' + ip + '|' + ua).digest('hex').slice(0, 16); // daily-salted visitor hash, no IP stored
let country = ''; try { country = geo && geo.countryOf ? (geo.countryOf(ip) || '') : ''; } catch (e) {}
const ref = String(req.headers.referer || ''); let src = 'direct'; try { if (ref) src = new URL(ref).hostname.replace(/^www\./, ''); } catch (e) {}
const device = /mobile|android|iphone|ipad/i.test(ua) ? 'mobile' : 'desktop';
J.db.hits.push({ r: r.id, d: d ? d.id : 0, ts: Date.now(), v, c: country, s: src, dv: device, bot: bot ? 1 : 0 });
if (J.db.hits.length > 200000) J.db.hits = J.db.hits.slice(-150000);
J.save();
return { url, dest: d, rotation: r };
}
async function stats(owner, id) {
const r = J.db.rotations.find(r => r.id === Number(id) && r.owner === norm(owner)); if (!r) return null;
const hits = J.db.hits.filter(h => h.r === r.id && !h.bot);
const by = key => { const m = {}; for (const h of hits) { const k = h[key] || '(none)'; m[k] = (m[k] || 0) + 1; } return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 12).map(([k, n]) => ({ k, n })); };
const days = {}; for (const h of hits) { const k = new Date(h.ts).toISOString().slice(0, 10); days[k] = (days[k] || 0) + 1; }
const hours = new Array(24).fill(0); for (const h of hits) hours[new Date(h.ts).getUTCHours()]++;
return { rotation: pub(r), country: by('c'), source: by('s'), device: by('dv'), days: Object.entries(days).sort().slice(-30).map(([k, n]) => ({ day: k, n })), hours, bots: J.db.hits.filter(h => h.r === r.id && h.bot).length };
}
function totals() { return { rotations: J.db.rotations.length, hits: J.db.hits.length }; }
module.exports = { init, list, get, create, update, remove, addDest, updateDest, resolve, stats, totals };