// 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/ 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 };