010e8d7ffc
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
105 lines
8.0 KiB
JavaScript
105 lines
8.0 KiB
JavaScript
// 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 };
|