d805a33608
Public /api/public/org-stats (from config.orgRootId, default 21) feeds a
prominent home-page band: share of the whole network, team size,
generations deep, and on-chain POL to the team — populated by bridge.js,
section hides itself if stats aren't ready. Paired with three mechanics
cards (spillover fills your matrix, depth pays as it climbs, the rotation
qualifies you) that make the "winning team" case honestly, plus the risk
caveat ("most participants may not profit") and a disclaimer link.
Live: 51.1% of members, 48 in the org, 10 generations, 25,423 POL.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
643 lines
52 KiB
JavaScript
643 lines
52 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { URL } = require('url');
|
|
const chain = require('./chain');
|
|
|
|
const PORT = Number(process.env.PORT || 3000);
|
|
const ROOT = __dirname;
|
|
const PUBLIC_DIR = path.join(ROOT, 'public');
|
|
const DATA_DIR = process.env.DATA_DIR || path.join(ROOT, 'data');
|
|
const SEED_DIR = path.join(ROOT, 'seed');
|
|
const SPONSORS_FILE = path.join(DATA_DIR, 'sponsors.json');
|
|
const CONFIG_FILE = path.join(DATA_DIR, 'config.json');
|
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'changeme';
|
|
const IS_PROD = process.env.NODE_ENV === 'production';
|
|
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
|
const LEVELS = ['Scintilla','Ascensus','Fabrica','Culmen','Apex','Fastigium','Vertex','Corona'];
|
|
const OPENROUTER_MODEL = process.env.OPENROUTER_MODEL || 'deepseek/deepseek-v4-flash:nitro';
|
|
const OPENROUTER_KEY_FILE = path.join(DATA_DIR, 'openrouter.key');
|
|
function getOpenRouterKey() {
|
|
if (process.env.OPENROUTER_API_KEY) return process.env.OPENROUTER_API_KEY;
|
|
try { return fs.readFileSync(OPENROUTER_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
}
|
|
const chatHits = new Map();
|
|
function chatRateLimited(ip) {
|
|
const now = Date.now(), rec = chatHits.get(ip);
|
|
if (!rec || now > rec.reset) { chatHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
rec.count++; return rec.count > 10;
|
|
}
|
|
function chatSystemPrompt() {
|
|
const c = getConfig(), sponsors = getSponsors(), a = activeSponsor(sponsors);
|
|
const waiting = sponsors.filter(s=>s.status==='waiting').length;
|
|
return `You are "Team Help", the assistant on ${c.siteName || 'Crypto Team Build'} (https://rmcircle.saasy.top), the team site for the RM Circle Premium team build — a project of the Crypto Team Build Network.
|
|
|
|
FACTS:
|
|
- Strategy: enter RM Circle dApp at Premium tier (${c.premiumEntryPol || 362} POL on Polygon Mainnet, chain ID 137, POL is the gas token). Each member gets 2 directs to become "qualified"; the team then recommends retiring that link and helping those 2 get their 2 with their OWN links (moving-link strategy) — a position only qualifies through its own directs. If an extra signup lands on a qualified link anyway, present it as a BONUS, not a rule violation: it still pays that member the full entry reward and spills downward into the first open slot in their leg (depth). Spillover never qualifies the people below.
|
|
- First team goal: 30 properly placed positions (2+4+8+16), then 32, 64, 128 and beyond.
|
|
- 8 Premium levels in order: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona. Everyone starts at Scintilla. Upgrade as quickly as practical, ideally with earned POL; the first two payments at each level help fund the next upgrade. Stay aware of your active downline's levels so you don't fall behind and miss payments.
|
|
- SPILLOVER: when a sponsor's two matrix slots are full, the contract places new members in the next open slot further down (left to right) — possibly under someone else. Spillover fills that member's matrix and sets up future upgrade income to their position, but does NOT count toward qualification: only people who join using a member's own ID are their directs. Upgrade payments travel up the MATRIX (not who-referred-whom) and only stop at positions that are qualified (2 directs) AND already at the level being bought — otherwise the payment passes them by to the next eligible upline. Members can see their own matrix, spillover tags, and payments at https://rmcircle.saasy.top/my
|
|
- Current team sponsor: ${a ? `ID ${a.id}${c.showSponsorName && a.name ? ` (${a.name})` : ''}, ${a.directs}/2 directs` : 'shown on the start page'}. ${waiting} placement(s) waiting. Placements rotate as positions qualify — always verify on https://rmcircle.saasy.top/start right before joining.
|
|
- Site pages: https://rmcircle.saasy.top/ (strategy overview + roadmap), https://rmcircle.saasy.top/start (current sponsor + join steps), https://rmcircle.saasy.top/training (4 videos: 1. How the team build works, 2. Create your MetaMask wallet, 3. Funding your wallet, 4. Buying the Premium position), https://rmcircle.saasy.top/contract (plain-language security review of the verified smart contract — code can't change, no pooled funds, locked rules, honest list of operator powers).
|
|
- Telegram group for live team help: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
|
|
|
|
RULES:
|
|
- Keep answers short: 1-4 sentences, plain text, no markdown formatting. Include full URLs when pointing to a page.
|
|
- NEVER promise, estimate, or imply earnings or income. If asked about returns/profit, say results depend on team effort, duplication, upgrades, smart-contract rules and POL's market value, that no income is guaranteed, and to only use funds they can afford to lose.
|
|
- NEVER ask for or discuss handling anyone's Secret Recovery Phrase or private keys except to warn they must never share them with anyone.
|
|
- Only answer questions about this project, the site, wallets/POL as they relate to joining, and the team process. For anything else, or anything you are not sure about, say you're not sure and point them to the Telegram group: ${c.telegramUrl || 'https://t.me/cryptoteambuild'}
|
|
- Never give financial, legal, or tax advice.`;
|
|
}
|
|
const SUBMISSIONS_FILE = path.join(DATA_DIR, 'submissions.json');
|
|
if (!fs.existsSync(SUBMISSIONS_FILE)) fs.writeFileSync(SUBMISSIONS_FILE, '[]');
|
|
const memberCache = new Map();
|
|
const lookupHits = new Map();
|
|
function memberLookupLimited(ip) {
|
|
const now = Date.now(), rec = lookupHits.get(ip);
|
|
if (!rec || now > rec.reset) { lookupHits.set(ip, { count: 1, reset: now + 60000 }); return false; }
|
|
rec.count++; return rec.count > 20;
|
|
}
|
|
// Admin login brute-force gate: after 8 failures from an IP, lock it out for
|
|
// 15 minutes (escalating). Timing-safe password compare above. In-memory —
|
|
// a restart clears it, which is fine (attacker loses their progress too).
|
|
const loginHits = new Map();
|
|
const LOGIN_MAX = 8, LOGIN_LOCK_MS = 15 * 60 * 1000;
|
|
function loginGate(ip) {
|
|
const r = loginHits.get(ip);
|
|
if (r && r.until > Date.now()) return { locked: true, mins: Math.ceil((r.until - Date.now()) / 60000) };
|
|
return { locked: false };
|
|
}
|
|
function loginFail(ip) {
|
|
const now = Date.now();
|
|
let r = loginHits.get(ip);
|
|
if (!r || (r.until && r.until < now && r.count >= LOGIN_MAX)) r = { count: 0, until: 0 };
|
|
r.count++;
|
|
if (r.count >= LOGIN_MAX) { r.until = now + LOGIN_LOCK_MS * Math.min(8, r.count - LOGIN_MAX + 1); loginHits.set(ip, r); return { locked: true, mins: Math.ceil((r.until - now) / 60000) }; }
|
|
loginHits.set(ip, r);
|
|
return { locked: false, left: LOGIN_MAX - r.count };
|
|
}
|
|
function loginReset(ip) { loginHits.delete(ip); }
|
|
const submitHits = new Map();
|
|
function submitRateLimited(ip) {
|
|
const now = Date.now(), rec = submitHits.get(ip);
|
|
if (!rec || now > rec.reset) { submitHits.set(ip, { count: 1, reset: now + 600000 }); return false; }
|
|
rec.count++; return rec.count > 5;
|
|
}
|
|
function sendTelegram(text) {
|
|
const c = getConfig();
|
|
if (!c.telegramBotToken || !c.telegramChatId) return;
|
|
const payload = { chat_id: c.telegramChatId, text };
|
|
if (c.telegramTopicId && /^[0-9]+$/.test(String(c.telegramTopicId))) payload.message_thread_id = Number(c.telegramTopicId);
|
|
fetch(`https://api.telegram.org/bot${c.telegramBotToken}/sendMessage`, {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}).then(async r=>{ if(!r.ok) console.error('telegram sendMessage status', r.status, (await r.text().catch(()=>'')).slice(0,200)); })
|
|
.catch(e=>console.error('telegram error', e.message));
|
|
}
|
|
const SENDGRID_KEY_FILE = path.join(DATA_DIR, 'sendgrid.key');
|
|
function getSendgridKey() {
|
|
if (process.env.SENDGRID_API_KEY) return process.env.SENDGRID_API_KEY;
|
|
try { return fs.readFileSync(SENDGRID_KEY_FILE, 'utf8').trim(); } catch (e) { return ''; }
|
|
}
|
|
// SendGrid is domain-authenticated for marketingwithmarty.com and
|
|
// mybrandedvoice.com — the from address must stay on one of those or DKIM fails.
|
|
function emailFrom() { return getConfig().emailFrom || 'The RM Circle Team <no-reply@marketingwithmarty.com>'; }
|
|
function sendEmailRaw(toEmail, subject, text) {
|
|
const key = getSendgridKey();
|
|
if (!key || !toEmail) return;
|
|
const fromStr = emailFrom();
|
|
const m = fromStr.match(/^(.*)<([^>]+)>\s*$/);
|
|
const from = m ? { email: m[2].trim(), name: m[1].trim() || undefined } : { email: fromStr.trim() };
|
|
fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
method: 'POST',
|
|
headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
personalizations: [{ to: [{ email: toEmail }] }],
|
|
from, subject,
|
|
content: [{ type: 'text/plain', value: text }]
|
|
})
|
|
}).then(r => { if (r.status >= 300) r.text().then(t => console.error('sendgrid status', r.status, t.slice(0, 200))); })
|
|
.catch(e => console.error('sendgrid error', e.message));
|
|
}
|
|
function sendPaidEmail(toEmail, memberName, evt, unsub) {
|
|
const kindLine = evt.kind === 'upline' ? `an upgrade pass-up from member #${evt.fromId}` : `a referral reward from member #${evt.fromId}'s entry`;
|
|
const verify = evt.tx ? `\n\nVerify it yourself on the blockchain:\nhttps://polygonscan.com/tx/${evt.tx}` : '';
|
|
const foot = unsub ? `\n\nStop these alerts: ${unsub}` : `\n\nYou're receiving this because your team admin has this address on file for team-build updates. Reply to this email to be removed.`;
|
|
const text = `Hi ${memberName || 'there'},\n\nGood news — your RM Circle position #${evt.toId} just received ${evt.pol.toFixed(2)} POL (${kindLine}).${verify}\n\nKeep the momentum going: check your level so the next payment in your leg doesn't pass you by.\nhttps://rmcircle.saasy.top/my/${evt.toId}\n\n— The RM Circle Team${foot}`;
|
|
sendEmailRaw(toEmail, `Your RM Circle position #${evt.toId} just got paid ${evt.pol.toFixed(2)} POL`, text);
|
|
}
|
|
function firePostback(clickid, txid, source) {
|
|
const pb = getConfig().bemobPostbackUrl;
|
|
if (!clickid || !pb || !/^https:\/\/[a-z0-9.-]+\/postback/i.test(pb)) return;
|
|
fetch(`${pb}${pb.includes('?')?'&':'?'}cid=${encodeURIComponent(clickid)}&payout=0&txid=${encodeURIComponent(txid)}`)
|
|
.then(r=>{ if(r.ok) recordEvent('postback', source); else console.error('bemob postback status', r.status); })
|
|
.catch(e=>console.error('bemob postback error', e.message));
|
|
}
|
|
async function handleSubmitId(req, res) {
|
|
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if (submitRateLimited(ip)) return json(res, 429, { error: 'Too many submissions — please wait a few minutes.' });
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
if (!b) return json(res, 400, { error: 'Invalid request.' });
|
|
const newId = String(b.newId||'').trim();
|
|
if (!/^[0-9]{1,10}$/.test(newId)) return json(res, 400, { error: 'Enter your numeric RM Circle ID (numbers only).' });
|
|
const memberName = String(b.memberName||'').replace(/[\u0000-\u001f\u007f]/g,'').trim().slice(0, 60);
|
|
if (!memberName) return json(res, 400, { error: 'Add your name or Telegram handle so the team can reach you.' });
|
|
const sponsorId = String(b.sponsorId||'').trim().slice(0, 20).replace(/[^0-9A-Za-z._-]/g,'') || '?';
|
|
const source = typeof b.source==='string' ? b.source : '';
|
|
const clickid = typeof b.clickid==='string' ? b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,'') : '';
|
|
let subs = []; try { subs = readJson(SUBMISSIONS_FILE); } catch(e) {}
|
|
if (subs.some(s=>s.newId===newId)) return json(res, 200, { ok: true, duplicate: true });
|
|
// on-chain verification: does this ID actually exist on the contract?
|
|
let onchain = null;
|
|
try {
|
|
onchain = await Promise.race([
|
|
chain.verifyMember(Number(newId)),
|
|
new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 6000))
|
|
]);
|
|
} catch (e) { onchain = null; }
|
|
// the chain decides the path: a rotation join is one whose on-chain referrer is
|
|
// a rotation-queue sponsor that IS or HAS BEEN worked (status active or
|
|
// qualified) — not just the currently-active one. With auto-advance, the direct
|
|
// who completes a sponsor's 2/2 (and thus joined under it) submits their ID
|
|
// AFTER the rotation has already moved on, so keying off "active only" wrongly
|
|
// labeled them a leg join. A referrer that's a still-waiting queue position, or
|
|
// not in the queue at all, is a personal leg join.
|
|
const sponsorsNow = getSponsors();
|
|
const active = activeSponsor(sponsorsNow);
|
|
let joinPath = 'unknown';
|
|
if (onchain && onchain.registered) {
|
|
const refSp = sponsorsNow.find(s => String(s.id) === String(onchain.referrerId));
|
|
joinPath = (refSp && (refSp.status === 'active' || refSp.status === 'qualified')) ? 'rotation' : 'leg';
|
|
} else if (onchain && !onchain.registered) joinPath = 'notfound';
|
|
subs.push({ newId, memberName, sponsorId, source: source||'(direct)', clickid, ts: new Date().toISOString(), path: joinPath,
|
|
onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId, uplineId: onchain.uplineId } : undefined });
|
|
writeJson(SUBMISSIONS_FILE, subs.slice(-1000));
|
|
recordEvent('purchase', source);
|
|
firePostback(clickid, `purchase-${clickid}`, source);
|
|
let msg;
|
|
if (joinPath === 'rotation') {
|
|
// rotation joins go straight into the queue as waiting positions — no manual step
|
|
let queueNote = '';
|
|
try {
|
|
let sponsors = getSponsors();
|
|
if (sponsors.some(s => String(s.id) === String(newId))) {
|
|
queueNote = 'Already in the rotation queue.';
|
|
} else {
|
|
const maxOrder = sponsors.reduce((m, s) => Math.max(m, s.sortOrder || 0), 0);
|
|
sponsors.push({ id: String(newId), name: memberName, parentId: String(onchain.referrerId), directs: 0, level: onchain.levelName || 'Scintilla', status: sponsors.some(s => s.status === 'active') ? 'waiting' : 'active', sortOrder: maxOrder + 10, clicks: 0, notes: `auto-added: rotation join under #${onchain.referrerId} ${new Date().toISOString().slice(0, 10)}` });
|
|
sponsors = normalizeStatuses(sponsors);
|
|
saveSponsors(sponsors);
|
|
const waitingAhead = sponsors.filter(s => s.status === 'waiting' && (s.sortOrder || 0) < maxOrder + 10).length;
|
|
queueNote = `Auto-added to the rotation queue (${waitingAhead} waiting ahead of them).`;
|
|
}
|
|
} catch (e) { queueNote = `⚠ Auto-add to queue failed (${e.message}) — add manually.`; console.error('queue auto-add', e.message); }
|
|
msg = `🔔 RM Circle: ROTATION JOIN CONFIRMED ✅\nName: ${memberName}\nNew ID: ${newId} (${onchain.tierName}, verified on-chain)\nJoined under rotation sponsor: #${onchain.referrerId}\nSource: ${source||'(direct)'}\n✅ ${queueNote}\n(Sponsor #${onchain.referrerId}'s direct count syncs from the chain automatically.)`;
|
|
} else if (joinPath === 'leg') {
|
|
msg = `🌱 RM Circle: TEAM-BUILD JOIN (not rotation)\nName: ${memberName}\nNew ID: ${newId} (${onchain.tierName}, verified on-chain)\nActual sponsor on-chain: #${onchain.referrerId}${sponsorId!=='?'&&String(onchain.referrerId)!==sponsorId?` (form said ${sponsorId})`:''}\nSource: ${source||'(direct)'}\n→ Leg growth under #${onchain.referrerId} — no rotation action needed. Add them to the rotation queue only if they want the team effort.`;
|
|
} else if (joinPath === 'notfound') {
|
|
msg = `🔔 RM Circle: ID SUBMITTED — ❌ NOT FOUND ON-CHAIN\nName: ${memberName}\nNew ID: ${newId}\nClaimed sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n→ ID has no registration on the contract — double-check with them before any queue action.`;
|
|
} else {
|
|
msg = `🔔 RM Circle: NEW MEMBER SUBMITTED\nName: ${memberName}\nNew ID: ${newId}\nClaimed sponsor: ${sponsorId}\nSource: ${source||'(direct)'}\n⏳ On-chain check unavailable — verify manually in admin (member lookup).`;
|
|
}
|
|
sendTelegram(msg);
|
|
return json(res, 200, { ok: true, path: joinPath, onchain: onchain ? { registered: onchain.registered, tier: onchain.tierName, level: onchain.levelName, referrerId: onchain.referrerId } : null });
|
|
}
|
|
async function handleChat(req, res) {
|
|
const ip = String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if (chatRateLimited(ip)) return json(res, 429, { error: 'Too many messages — give it a minute.' });
|
|
const apiKey = getOpenRouterKey();
|
|
if (!apiKey) return json(res, 200, { fallback: true });
|
|
const b = await bodyJson(req).catch(()=>null);
|
|
if (!b || !Array.isArray(b.messages)) return json(res, 400, { error: 'Invalid request' });
|
|
const msgs = b.messages.slice(-8)
|
|
.filter(m=>m&&(m.role==='user'||m.role==='assistant')&&typeof m.content==='string')
|
|
.map(m=>({ role: m.role, content: m.content.slice(0, 500) }));
|
|
if (!msgs.length || msgs[msgs.length-1].role !== 'user') return json(res, 400, { error: 'Invalid request' });
|
|
try {
|
|
const ctrl = new AbortController(); const timer = setTimeout(()=>ctrl.abort(), 20000);
|
|
const r = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST', signal: ctrl.signal,
|
|
headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', 'HTTP-Referer': 'https://rmcircle.saasy.top', 'X-Title': 'RM Circle Team Help' },
|
|
body: JSON.stringify({ model: OPENROUTER_MODEL, max_tokens: 350, temperature: 0.3, messages: [{ role: 'system', content: chatSystemPrompt() }, ...msgs] })
|
|
});
|
|
clearTimeout(timer);
|
|
if (!r.ok) { console.error('openrouter status', r.status); return json(res, 200, { fallback: true }); }
|
|
const d = await r.json();
|
|
const reply = d && d.choices && d.choices[0] && d.choices[0].message && d.choices[0].message.content;
|
|
if (!reply) return json(res, 200, { fallback: true });
|
|
return json(res, 200, { reply: String(reply).trim().slice(0, 2000) });
|
|
} catch (e) { console.error('openrouter error', e.message); return json(res, 200, { fallback: true }); }
|
|
}
|
|
// Sessions persist in the data volume so redeploys stop logging the admin out.
|
|
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json');
|
|
const sessions = new Map();
|
|
try {
|
|
const saved = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf8'));
|
|
const now = Date.now();
|
|
for (const [t, s] of Object.entries(saved)) if (s && s.expires > now) sessions.set(t, s);
|
|
} catch (e) {}
|
|
function saveSessions() {
|
|
try {
|
|
const now = Date.now();
|
|
for (const [t, s] of sessions) if (s.expires <= now) sessions.delete(t);
|
|
const tmp = SESSIONS_FILE + '.tmp';
|
|
fs.writeFileSync(tmp, JSON.stringify(Object.fromEntries(sessions)), { mode: 0o600 });
|
|
fs.renameSync(tmp, SESSIONS_FILE);
|
|
} catch (e) { console.error('session save failed', e.message); }
|
|
}
|
|
|
|
function ensureDataFile(name) {
|
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
|
const target = path.join(DATA_DIR, name);
|
|
if (!fs.existsSync(target)) fs.copyFileSync(path.join(SEED_DIR, name), target);
|
|
}
|
|
ensureDataFile('sponsors.json');
|
|
ensureDataFile('config.json');
|
|
const ANALYTICS_FILE = path.join(DATA_DIR, 'analytics.json');
|
|
if (!fs.existsSync(ANALYTICS_FILE)) fs.writeFileSync(ANALYTICS_FILE, JSON.stringify({ sources: {} }, null, 2));
|
|
|
|
function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
function writeJson(file, data) {
|
|
const temp = `${file}.${crypto.randomUUID()}.tmp`;
|
|
fs.writeFileSync(temp, JSON.stringify(data, null, 2));
|
|
fs.renameSync(temp, file);
|
|
}
|
|
function getSponsors() { return readJson(SPONSORS_FILE).sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0)); }
|
|
function saveSponsors(s) { writeJson(SPONSORS_FILE, s); }
|
|
function getConfig() { return readJson(CONFIG_FILE); }
|
|
function activeSponsor(sponsors) { return sponsors.find(s=>s.status==='active') || sponsors.find(s=>s.status==='waiting') || null; }
|
|
function getAnalytics() { try { return readJson(ANALYTICS_FILE); } catch (e) { return { sources: {} }; } }
|
|
function recordEvent(event, source) {
|
|
if (!['bridge','start','click','training','postback','purchase','join'].includes(event)) return;
|
|
const s = String(source||'').toLowerCase().trim().replace(/[^a-z0-9.()\-_:/ ]/g,'').slice(0,80) || '(direct)';
|
|
const a = getAnalytics(); if (!a.sources) a.sources = {};
|
|
if (!a.sources[s]) { if (Object.keys(a.sources).length >= 500) return; a.sources[s] = { bridge:0, start:0, click:0 }; }
|
|
a.sources[s][event] = (a.sources[s][event]||0) + 1;
|
|
writeJson(ANALYTICS_FILE, a);
|
|
}
|
|
function normalizeStatuses(sponsors, preferredActiveId=null) {
|
|
const eligible=sponsors.filter(s=>s.status!=='qualified');
|
|
let activeId=preferredActiveId;
|
|
if(!activeId || !eligible.some(s=>s.id===activeId)){
|
|
const existing=eligible.find(s=>s.status==='active');
|
|
activeId=existing?existing.id:(eligible[0]?.id||null);
|
|
}
|
|
return sponsors.map(s=>s.status==='qualified'?s:{...s,status:s.id===activeId?'active':'waiting'});
|
|
}
|
|
function publicSponsorPayload(sponsor, config) {
|
|
if(!sponsor)return null;
|
|
return {id:sponsor.id,name:config.showSponsorName?sponsor.name:null,directs:sponsor.directs,goal:2,level:sponsor.level,referralUrl:`${config.dappReferralBaseUrl}${encodeURIComponent(sponsor.id)}`};
|
|
}
|
|
const CSP_BASE="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-src https://www.youtube-nocookie.com";
|
|
function securityHeaders(extra={}) {
|
|
// Public pages must render inside safelist / traffic-exchange iframes, so framing stays open here; admin.html re-locks it via ADMIN_FRAME_HEADERS.
|
|
return {
|
|
'X-Content-Type-Options':'nosniff','Referrer-Policy':'strict-origin-when-cross-origin',
|
|
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
|
|
'Content-Security-Policy':`${CSP_BASE}; frame-ancestors *`,
|
|
...extra
|
|
};
|
|
}
|
|
const ADMIN_FRAME_HEADERS={'X-Frame-Options':'DENY','Content-Security-Policy':`${CSP_BASE}; frame-ancestors 'none'`};
|
|
function send(res,status,body,headers={}) { res.writeHead(status,securityHeaders(headers));res.end(body); }
|
|
function json(res,status,obj,headers={}) { send(res,status,JSON.stringify(obj),{'Content-Type':'application/json; charset=utf-8',...headers}); }
|
|
function parseCookies(req){const out={};for(const p of (req.headers.cookie||'').split(';')){const i=p.indexOf('=');if(i>0)out[p.slice(0,i).trim()]=decodeURIComponent(p.slice(i+1).trim())}return out}
|
|
function getSession(req){const token=parseCookies(req)['ctb.sid'];if(!token)return null;const s=sessions.get(token);if(!s)return null;if(s.expires<Date.now()){sessions.delete(token);return null}return {token,...s}}
|
|
function requireAdmin(req,res){if(!getSession(req)){json(res,401,{error:'Unauthorized'});return false}return true}
|
|
async function bodyJson(req){return await new Promise((resolve,reject)=>{let data='';req.on('data',c=>{data+=c;if(data.length>100000){reject(new Error('Payload too large'));req.destroy()}});req.on('end',()=>{if(!data)return resolve({});try{resolve(JSON.parse(data))}catch(e){reject(new Error('Invalid JSON'))}});req.on('error',reject)})}
|
|
function contentType(file){const ext=path.extname(file);return ({'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'application/javascript; charset=utf-8','.json':'application/json; charset=utf-8','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.svg':'image/svg+xml','.ico':'image/x-icon','.mp4':'video/mp4','.webm':'video/webm'}[ext]||'application/octet-stream')}
|
|
function staticFile(req,res,file,status=200){
|
|
if(!fs.existsSync(file)||!fs.statSync(file).isFile())return false;
|
|
const size=fs.statSync(file).size;
|
|
const base={'Content-Type':contentType(file),'Accept-Ranges':'bytes','Cache-Control':['.html','.css','.js'].includes(path.extname(file))?'no-cache':'public, max-age=3600',...(path.basename(file)==='admin.html'?ADMIN_FRAME_HEADERS:{})};
|
|
const m=status===200&&req.headers.range?String(req.headers.range).match(/^bytes=(\d*)-(\d*)$/):null;
|
|
if(m&&(m[1]!==''||m[2]!=='')){
|
|
const start=m[1]===''?Math.max(0,size-Number(m[2])):Number(m[1]);
|
|
const end=(m[1]!==''&&m[2]!=='')?Math.min(Number(m[2]),size-1):size-1;
|
|
if(start>end||start>=size){res.writeHead(416,securityHeaders({'Content-Range':`bytes */${size}`}));res.end();return true}
|
|
res.writeHead(206,securityHeaders({...base,'Content-Range':`bytes ${start}-${end}/${size}`,'Content-Length':end-start+1}));
|
|
if(req.method==='HEAD')res.end();else fs.createReadStream(file,{start,end}).pipe(res);
|
|
return true;
|
|
}
|
|
res.writeHead(status,securityHeaders({...base,'Content-Length':size}));
|
|
if(req.method==='HEAD')res.end();else fs.createReadStream(file).pipe(res);
|
|
return true;
|
|
}
|
|
|
|
async function handleApi(req,res,pathname){
|
|
if(req.method==='GET'&&pathname==='/health') return json(res,200,{ok:true});
|
|
if(req.method==='GET'&&pathname==='/api/public/config'){
|
|
const c=getConfig();return json(res,200,{siteName:c.siteName,programName:c.programName,bridgeHeadline:c.bridgeHeadline,bridgeSubheadline:c.bridgeSubheadline,premiumEntryPol:c.premiumEntryPol,telegramUrl:c.telegramUrl,supportLabel:c.supportLabel,showQueueProgress:c.showQueueProgress});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/member'){
|
|
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if(memberLookupLimited(ip))return json(res,429,{error:'Too many lookups — give it a minute.'});
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
|
|
const cached=memberCache.get(id);
|
|
if(cached&&Date.now()-cached.ts<120000)return json(res,200,cached.data,{'Cache-Control':'public, max-age=60'});
|
|
try{
|
|
const r=await Promise.race([chain.memberPublic(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),20000))]);
|
|
if(r.registered)r.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(id)}`;
|
|
// align next-in-line with the human-curated rotation: prefer the ACTIVE
|
|
// rotation sponsor when they sit in this member's leg and need directs;
|
|
// else the first chain-order position that's a queue participant; else
|
|
// keep the chain's pure structural pick (covers legs outside the queue).
|
|
if(r.registered&&r.subtree){
|
|
try{
|
|
const sponsors=getSponsors();
|
|
const act=activeSponsor(sponsors);
|
|
const participants=new Set(sponsors.filter(s=>s.status!=='qualified').map(s=>String(s.id)));
|
|
const bfs=[];const q=[r.subtree.left,r.subtree.right].filter(Boolean);
|
|
while(q.length){const n=q.shift();bfs.push(n);if(n.left)q.push(n.left);if(n.right)q.push(n.right);}
|
|
let pick=null;
|
|
if(act)pick=bfs.find(n=>String(n.id)===String(act.id)&&(n.directCount||0)<2);
|
|
if(!pick)pick=bfs.find(n=>participants.has(String(n.id))&&(n.directCount||0)<2);
|
|
if(pick)r.nextInLine={id:pick.id,directCount:pick.directCount||0,levelName:pick.levelName};
|
|
}catch(e){}
|
|
}
|
|
if(r.nextInLine)r.nextInLine.referralUrl=`${getConfig().dappReferralBaseUrl}${encodeURIComponent(r.nextInLine.id)}`;
|
|
memberCache.set(id,{data:r,ts:Date.now()});
|
|
if(memberCache.size>500)memberCache.delete(memberCache.keys().next().value);
|
|
return json(res,200,r,{'Cache-Control':'public, max-age=60'});
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/payouts'){
|
|
return json(res,200,chain.getPayoutsPublic(),{'Cache-Control':'public, max-age=20'});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/org-stats'){
|
|
const root=Number(getConfig().orgRootId||21);
|
|
const d=chain.getOrgShare(Number.isInteger(root)&&root>0?root:21);
|
|
// expose only the aggregate showcase numbers (all public on-chain data)
|
|
return json(res,200,{ready:d.ready,found:!!d.found,memberPct:d.memberPct,orgMembers:d.orgMembers,generations:d.generations,orgPol:d.orgPol,totalMembers:d.totalMembers},{'Cache-Control':'public, max-age=60'});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/alert-status'){
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
const rec=getMemberAlerts()[id];
|
|
return json(res,200,{subscribed:!!(rec&&rec.email),email:rec&&rec.email?maskEmail(rec.email):null});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/alert-signup'){
|
|
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
if(submitRateLimited(ip))return json(res,429,{error:'Too many requests — wait a few minutes.'});
|
|
const b=await bodyJson(req).catch(()=>null); if(!b)return json(res,400,{error:'Invalid request.'});
|
|
const id=Number(b.id); if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter your numeric member ID.'});
|
|
const email=String(b.email||'').trim();
|
|
const ma=getMemberAlerts();
|
|
if(!email){ if(ma[id]){delete ma[id];saveMemberAlerts(ma);} return json(res,200,{ok:true,subscribed:false}); }
|
|
if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)||email.length>120)return json(res,400,{error:'Enter a valid email address.'});
|
|
let onchain=null; try{ onchain=await Promise.race([chain.verifyMember(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('t')),6000))]); }catch(e){ onchain=null; }
|
|
if(onchain&&!onchain.registered)return json(res,400,{error:`ID ${id} isn't registered on the smart contract — double-check the number.`});
|
|
ma[id]={email:email.slice(0,120),ts:new Date().toISOString()};
|
|
saveMemberAlerts(ma);
|
|
sendEmailRaw(email,`Alerts on for RM Circle position #${id}`,`You're now subscribed to alerts for RM Circle position #${id}.\n\nYou'll get an email when this position is paid, and when it needs an upgrade to catch incoming pay.\n\nSee your position anytime: https://rmcircle.saasy.top/my/${id}\nStop these alerts: ${unsubUrl(id)}\n\n— The RM Circle Team`);
|
|
return json(res,200,{ok:true,subscribed:true,email:maskEmail(email)});
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/public/current-sponsor'){
|
|
const sponsors=getSponsors(),c=getConfig(),a=activeSponsor(sponsors);if(!a)return json(res,404,{error:'No active sponsor is currently assigned.'});
|
|
return json(res,200,{sponsor:publicSponsorPayload(a,c),waitingCount:sponsors.filter(s=>s.status==='waiting').length,message:'Always use the current sponsor shown on this page. Team placement rotates as members qualify.'});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/join-click'){
|
|
const b=await bodyJson(req).catch(()=>({}));recordEvent('click',b.source);
|
|
const clickid=typeof b.clickid==='string'?b.clickid.trim().slice(0,80).replace(/[^A-Za-z0-9._-]/g,''):'';
|
|
firePostback(clickid,`join-${clickid}`,b.source);
|
|
let sponsors=getSponsors();const a=activeSponsor(sponsors);if(a){sponsors=sponsors.map(s=>s.id===a.id?{...s,clicks:(s.clicks||0)+1}:s);saveSponsors(sponsors)}return json(res,200,{ok:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/public/chat')return await handleChat(req,res);
|
|
if(req.method==='POST'&&pathname==='/api/public/submit-id')return await handleSubmitId(req,res);
|
|
if(req.method==='POST'&&pathname==='/api/public/track'){
|
|
const b=await bodyJson(req).catch(()=>({}));recordEvent(b.event,b.source);return json(res,200,{ok:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/login'){
|
|
const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim();
|
|
const gate=loginGate(ip);
|
|
if(gate.locked)return json(res,429,{error:`Too many attempts. Try again in ${gate.mins} minute${gate.mins===1?'':'s'}.`});
|
|
const b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});
|
|
const ok=typeof b.password==='string'&&b.password.length===ADMIN_PASSWORD.length&&crypto.timingSafeEqual(Buffer.from(b.password),Buffer.from(ADMIN_PASSWORD));
|
|
if(!ok){const g=loginFail(ip);return json(res,401,{error:g.locked?`Too many attempts. Locked for ${g.mins} minutes.`:`Invalid password.${g.left<=3?` ${g.left} attempt${g.left===1?'':'s'} left before lockout.`:''}`});}
|
|
loginReset(ip);
|
|
const token=crypto.randomBytes(32).toString('hex');sessions.set(token,{expires:Date.now()+SESSION_TTL});saveSessions();const cookie=`ctb.sid=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL/1000}${IS_PROD?'; Secure':''}`;return json(res,200,{ok:true},{'Set-Cookie':cookie});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/logout'){
|
|
const s=getSession(req);if(s){sessions.delete(s.token);saveSessions();}return json(res,200,{ok:true},{'Set-Cookie':'ctb.sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0'});
|
|
}
|
|
if(pathname.startsWith('/api/admin/')&&!requireAdmin(req,res))return;
|
|
if(req.method==='GET'&&pathname==='/api/admin/matrix-tree'){
|
|
return json(res,200,chain.getMatrixTree());
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/org-share'){
|
|
const raw=new URL(req.url,'http://x').searchParams.get('root');
|
|
const root=Number(raw||parseOwnerIds()[0]||21);
|
|
if(!Number.isInteger(root)||root<1||root>281474976710655)return json(res,400,{error:'Enter a numeric root ID.'});
|
|
return json(res,200,chain.getOrgShare(root));
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/income'){
|
|
const raw=new URL(req.url,'http://x').searchParams.get('ids')||'';
|
|
const ids=[...new Set(raw.split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0&&n<=281474976710655))].slice(0,12);
|
|
if(!ids.length)return json(res,400,{error:'Enter one or more numeric IDs (comma-separated).'});
|
|
try{
|
|
const results=await Promise.race([
|
|
Promise.all(ids.map(id=>chain.getIncome(id).catch(()=>({registered:false,id})))),
|
|
new Promise((_,rej)=>setTimeout(()=>rej(new Error('Blockchain lookup timed out — try again.')),25000))
|
|
]);
|
|
const rows=[],perId={};let grand=0,grandListed=0;
|
|
for(const r of results){
|
|
if(!r.registered){perId[r.id]={registered:false};continue;}
|
|
perId[r.id]={registered:true,levelName:r.levelName,tierName:r.tierName,totalEarnedPol:r.totalEarnedPol,count:r.income.length};
|
|
grand+=r.totalEarnedPol;
|
|
for(const p of r.income){rows.push({toId:r.id,fromId:p.fromId,pol:p.pol,ts:p.ts,desc:p.desc});grandListed+=p.pol;}
|
|
}
|
|
rows.sort((a,b)=>(b.ts||0)-(a.ts||0));
|
|
let upgradeNeeds=[];try{upgradeNeeds=chain.getOwnerUpgradeNeeds(ids).needs;}catch(e){}
|
|
return json(res,200,{ids,perId,rows:rows.slice(0,500),grandEarnedPol:+grand.toFixed(2),grandListedPol:+grandListed.toFixed(2),upgradeNeeds});
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/member-lookup'){
|
|
const id=Number(new URL(req.url,'http://x').searchParams.get('id')||0);
|
|
if(!Number.isInteger(id)||id<1||id>281474976710655)return json(res,400,{error:'Enter a numeric member ID.'});
|
|
try{
|
|
const r=await Promise.race([chain.memberLookup(id),new Promise((_,rej)=>setTimeout(()=>rej(new Error('Chain RPC timeout — try again.')),25000))]);
|
|
return json(res,200,r);
|
|
}catch(e){return json(res,502,{error:e.message||'Lookup failed'})}
|
|
}
|
|
if(req.method==='GET'&&pathname==='/api/admin/state'){let subs=[];try{subs=readJson(SUBMISSIONS_FILE).slice(-50).reverse()}catch(e){}return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics(),submissions:subs,aiChat:{configured:!!getOpenRouterKey(),model:OPENROUTER_MODEL},email:{configured:!!getSendgridKey(),from:emailFrom()}});}
|
|
if(req.method==='POST'&&pathname==='/api/admin/sendgrid-key'){
|
|
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
|
if(key===null)return json(res,400,{error:'Invalid request.'});
|
|
if(key===''){try{fs.unlinkSync(SENDGRID_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getSendgridKey()});}
|
|
if(!/^SG\./.test(key)||key.length<40||/\s/.test(key))return json(res,400,{error:'That does not look like a SendGrid API key (starts with SG.).'});
|
|
fs.writeFileSync(SENDGRID_KEY_FILE,key,{mode:0o600});
|
|
return json(res,200,{configured:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/openrouter-key'){
|
|
const b=await bodyJson(req);const key=typeof b.key==='string'?b.key.trim():null;
|
|
if(key===null)return json(res,400,{error:'Invalid request.'});
|
|
if(key===''){try{fs.unlinkSync(OPENROUTER_KEY_FILE)}catch(e){}return json(res,200,{configured:!!getOpenRouterKey()});}
|
|
if(key.length<20||/\s/.test(key))return json(res,400,{error:'That does not look like a valid API key.'});
|
|
fs.writeFileSync(OPENROUTER_KEY_FILE,key,{mode:0o600});
|
|
return json(res,200,{configured:true});
|
|
}
|
|
if(req.method==='POST'&&pathname==='/api/admin/sponsors'){
|
|
const b=await bodyJson(req);const {id,name,parentId='',level='Scintilla',notes='',email=''}=b;if(!id||!name)return json(res,400,{error:'ID and name are required.'});if(!LEVELS.includes(level))return json(res,400,{error:'Invalid level.'});if(email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(email).trim()))return json(res,400,{error:'Invalid email address.'});let sponsors=getSponsors();if(sponsors.some(s=>String(s.id)===String(id)))return json(res,409,{error:'That sponsor ID already exists.'});
|
|
const maxOrder=sponsors.reduce((m,s)=>Math.max(m,s.sortOrder||0),0);sponsors.push({id:String(id).trim(),name:String(name).trim(),parentId:String(parentId||'').trim(),directs:0,level,status:sponsors.some(s=>s.status==='active')?'waiting':'active',sortOrder:maxOrder+10,clicks:0,notes:String(notes||'').trim(),email:String(email||'').trim().slice(0,120)});sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,201,{sponsors});
|
|
}
|
|
if(req.method==='PATCH'&&pathname==='/api/admin/config'){
|
|
const b=await bodyJson(req),cur=getConfig(),next={...cur};for(const k of ['siteName','programName','bridgeHeadline','bridgeSubheadline','premiumEntryPol','dappReferralBaseUrl','telegramUrl','supportLabel','showSponsorName','showQueueProgress','bemobPostbackUrl','telegramBotToken','telegramChatId','telegramTopicId','teamRootId','emailFrom','teamAlertEmail','ownerIds','ownerAlertEmail','orgRootId'])if(Object.prototype.hasOwnProperty.call(b,k))next[k]=b[k];next.premiumEntryPol=Number(next.premiumEntryPol)||362;next.updatedAt=new Date().toISOString();writeJson(CONFIG_FILE,next);return json(res,200,{config:next});
|
|
}
|
|
const m=pathname.match(/^\/api\/admin\/sponsors\/([^/]+)(?:\/(increment|activate|qualify|reset|move))?$/);
|
|
if(m){const id=decodeURIComponent(m[1]),action=m[2]||null;let sponsors=getSponsors(),idx=sponsors.findIndex(s=>s.id===id);if(idx<0)return json(res,404,{error:'Sponsor not found.'});
|
|
if(req.method==='PATCH'&&!action){const b=await bodyJson(req);if(Object.prototype.hasOwnProperty.call(b,'level')&&!LEVELS.includes(b.level))return json(res,400,{error:'Invalid level.'});if(Object.prototype.hasOwnProperty.call(b,'email')&&b.email&&!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(b.email).trim()))return json(res,400,{error:'Invalid email address.'});for(const k of ['name','parentId','directs','level','notes','email'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=k==='email'?String(b[k]||'').trim().slice(0,120):b[k];sponsors[idx].name=String(sponsors[idx].name||'').trim().slice(0,80)||sponsors[idx].name;sponsors[idx].directs=Math.max(0,Math.min(2,Number(sponsors[idx].directs)||0));saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='DELETE'&&!action){const wasActive=sponsors[idx].status==='active';sponsors.splice(idx,1);if(wasActive)sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='increment'){sponsors[idx].directs=Math.min(2,(Number(sponsors[idx].directs)||0)+1);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='activate'){if(sponsors[idx].status==='qualified')return json(res,400,{error:'Qualified sponsors cannot be activated until reset.'});sponsors=normalizeStatuses(sponsors,id);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='qualify'){sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors,active:activeSponsor(sponsors)});}
|
|
if(req.method==='POST'&&action==='reset'){sponsors[idx]={...sponsors[idx],directs:0,status:'waiting'};sponsors=normalizeStatuses(sponsors);saveSponsors(sponsors);return json(res,200,{sponsors});}
|
|
if(req.method==='POST'&&action==='move'){const b=await bodyJson(req);const swap=b.direction==='up'?idx-1:idx+1;if(swap>=0&&swap<sponsors.length){const t=sponsors[idx].sortOrder;sponsors[idx].sortOrder=sponsors[swap].sortOrder;sponsors[swap].sortOrder=t;saveSponsors(sponsors)}return json(res,200,{sponsors:getSponsors()});}
|
|
}
|
|
return json(res,404,{error:'API endpoint not found'});
|
|
}
|
|
|
|
const server=http.createServer(async(req,res)=>{
|
|
try{
|
|
const u=new URL(req.url,`http://${req.headers.host||'localhost'}`),pathname=decodeURIComponent(u.pathname);
|
|
if(pathname==='/health'||pathname.startsWith('/api/'))return await handleApi(req,res,pathname);
|
|
if(req.method!=='GET'&&req.method!=='HEAD')return send(res,405,'Method Not Allowed',{'Content-Type':'text/plain; charset=utf-8'});
|
|
if(pathname==='/unsubscribe'){
|
|
const id=u.searchParams.get('id')||'', t=u.searchParams.get('t')||'';
|
|
let ok=false;
|
|
if(/^\d{1,15}$/.test(id)&&t&&t===unsubToken(id)){ const ma=getMemberAlerts(); if(ma[id]){delete ma[id];saveMemberAlerts(ma);} ok=true; }
|
|
const safeId=/^\d{1,15}$/.test(id)?id:'';
|
|
const body=`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>${ok?'Unsubscribed':'Invalid link'}</title><link rel="stylesheet" href="/styles.css"></head><body class="admin-bg"><div class="login-panel" style="margin:12vh auto;text-align:center"><h1>${ok?'You are unsubscribed ✓':'Invalid or expired link'}</h1><p style="color:var(--muted)">${ok?`Position #${safeId} will no longer receive alert emails. You can turn them back on anytime from your dashboard.`:'We could not process that unsubscribe link. You can manage alerts from your dashboard.'}</p><a class="btn btn-primary" href="/my/${safeId}">Open dashboard</a></div></body></html>`;
|
|
res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'no-store'}));return res.end(body);
|
|
}
|
|
let file;
|
|
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/start'});return res.end();}else{
|
|
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
|
|
}
|
|
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
|
|
}catch(e){console.error(e);json(res,500,{error:'Internal server error'});}
|
|
});
|
|
server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on http://localhost:${PORT}`);if(ADMIN_PASSWORD==='changeme')console.warn('WARNING: Set ADMIN_PASSWORD before production deployment.');});
|
|
// Team-activity alerts: any NEW on-chain event at/below config.teamRootId goes
|
|
// to the Telegram group topic, with the sponsor's contact email when we have it.
|
|
// Owner upgrade watcher: emails/Telegrams when an owned position (config.ownerIds)
|
|
// has a payment about to arrive it can't catch yet, so Marty can upgrade in time.
|
|
const OWNER_ALERTS_FILE = path.join(DATA_DIR, 'owner-alerts.json');
|
|
function loadOwnerAlerts(){ try{ return new Set(readJson(OWNER_ALERTS_FILE)); }catch(e){ return new Set(); } }
|
|
function parseOwnerIds(){ return [...new Set(String(getConfig().ownerIds||'').split(',').map(s=>parseInt(String(s).trim(),10)).filter(n=>Number.isInteger(n)&&n>0))].slice(0,12); }
|
|
// Self-service member alert subscriptions: memberId -> { email, ts }
|
|
const MEMBER_ALERTS_FILE = path.join(DATA_DIR, 'member-alerts.json');
|
|
if (!fs.existsSync(MEMBER_ALERTS_FILE)) fs.writeFileSync(MEMBER_ALERTS_FILE, '{}');
|
|
function getMemberAlerts(){ try{ return readJson(MEMBER_ALERTS_FILE)||{}; }catch(e){ return {}; } }
|
|
function saveMemberAlerts(o){ writeJson(MEMBER_ALERTS_FILE,o); }
|
|
const ALERT_SECRET = crypto.createHash('sha256').update('rmc-alerts::'+ADMIN_PASSWORD).digest('hex');
|
|
function unsubToken(id){ return crypto.createHmac('sha256',ALERT_SECRET).update('unsub:'+String(id)).digest('hex').slice(0,24); }
|
|
function unsubUrl(id){ return `https://rmcircle.saasy.top/unsubscribe?id=${id}&t=${unsubToken(id)}`; }
|
|
function maskEmail(e){ const i=String(e).indexOf('@'); if(i<1)return '•••'; return e[0]+'•••'+e.slice(i); }
|
|
// Upgrade-need alerts for both owner positions (email+Telegram) and any member
|
|
// who opted in via their dashboard (email only). Runs every 5 min from state.
|
|
function checkUpgradeAlerts(){
|
|
try{
|
|
const c=getConfig();
|
|
const watch={}; // id -> [{email, owner}]
|
|
if(c.ownerAlertEmail) for(const id of parseOwnerIds()){(watch[id]=watch[id]||[]).push({email:c.ownerAlertEmail,owner:true});}
|
|
const ma=getMemberAlerts();
|
|
for(const [idStr,rec] of Object.entries(ma)) if(rec&&rec.email){const id=Number(idStr);(watch[id]=watch[id]||[]).push({email:rec.email,owner:false});}
|
|
const ids=Object.keys(watch).map(Number);
|
|
if(!ids.length) return;
|
|
const res=chain.getOwnerUpgradeNeeds(ids);
|
|
if(!res.ready) return;
|
|
const alerted=loadOwnerAlerts(); const active=new Set(); const tgDone=new Set();
|
|
for(const n of res.needs){
|
|
const who=n.members.map(m=>'#'+m).join(', ');
|
|
const action=n.reason==='qualify'?`Position #${n.id} needs its 2 directs to catch this.`:`Upgrade position #${n.id} (now ${n.levelName}) to ${n.neededLevelName} to catch it.`;
|
|
for(const w of (watch[n.id]||[])){
|
|
const key=`${w.email}:${n.id}:${n.reason}:${n.neededLevel}`; active.add(key);
|
|
if(alerted.has(key)) continue; alerted.add(key);
|
|
const unsub=w.owner?'':`\n\nStop these alerts: ${unsubUrl(n.id)}`;
|
|
sendEmailRaw(w.email,
|
|
`RM Circle: upgrade #${n.id} to ${n.neededLevelName} — ${n.amountAtRisk} POL incoming`,
|
|
`Heads up — position #${n.id} has money about to arrive it can't catch yet.\n\n#${n.id} is at ${n.levelName}. ${who} ${n.members.length===1?'is':'are'} ONE upgrade away from paying #${n.id} about ${n.amountAtRisk} POL — but that only stops at #${n.id} if it's at ${n.neededLevelName} and qualified.\n\n${action}\n\nDo it before they upgrade, or the payment passes to the next eligible position above (it doesn't come back).${unsub}\n\n— RM Circle auto-watch`);
|
|
if(w.owner&&!tgDone.has(n.id+':'+n.neededLevel)){ tgDone.add(n.id+':'+n.neededLevel); sendTelegram(`⏫ UPGRADE #${n.id} SOON: ${who} one upgrade from paying ~${n.amountAtRisk} POL. #${n.id} is ${n.levelName} — needs ${n.neededLevelName}${n.reason==='qualify'?' + 2 directs':''}. Upgrade before they do.`); }
|
|
}
|
|
}
|
|
let changed=false;
|
|
for(const k of [...alerted]) if(!active.has(k)){ alerted.delete(k); changed=true; }
|
|
if(changed||active.size) writeJson(OWNER_ALERTS_FILE,[...alerted]);
|
|
}catch(e){ console.error('upgrade alert check', e.message); }
|
|
}
|
|
setInterval(checkUpgradeAlerts, 5*60*1000).unref();
|
|
setTimeout(checkUpgradeAlerts, 30000).unref();
|
|
chain.startIndexer(evt=>{
|
|
try{
|
|
const c=getConfig();
|
|
const rootId=Number(c.teamRootId)||0;
|
|
const ids=evt.type==='payout'?[evt.toId,evt.fromId]:[evt.id];
|
|
if(rootId&&ids.some(i=>chain.isInTeam(i,rootId))){
|
|
const contact=id=>{const s=getSponsors().find(x=>String(x.id)===String(id));return s&&s.email?`\nContact: ${s.name?s.name+' — ':''}${s.email}`:''};
|
|
let text;
|
|
if(evt.type==='registered')text=`📈 TEAM BUILD: new position!\n#${evt.id} registered under #${evt.referrerId} (${evt.tierName}).`;
|
|
else if(evt.type==='upgraded')text=`🚀 TEAM BUILD: #${evt.id} upgraded to ${evt.levelName}.`;
|
|
else text=`💸 TEAM BUILD: #${evt.toId} just got PAID ${evt.pol.toFixed(2)} POL${evt.kind==='upline'?` (upgrade pass-up from #${evt.fromId})`:` (referral reward from #${evt.fromId})`}.${contact(evt.toId)}`;
|
|
if(evt.tx)text+=`\nhttps://polygonscan.com/tx/${evt.tx}`;
|
|
sendTelegram(text);
|
|
// admin email alert — same team-gated events, so deep-leg action still surfaces
|
|
if(c.teamAlertEmail){
|
|
const subj=evt.type==='registered'?`RM Circle team build: #${evt.id} registered under #${evt.referrerId}`
|
|
:evt.type==='upgraded'?`RM Circle team build: #${evt.id} upgraded to ${evt.levelName}`
|
|
:`RM Circle team build: #${evt.toId} paid ${evt.pol.toFixed(2)} POL`;
|
|
sendEmailRaw(c.teamAlertEmail,subj,text.replace(/^[^\s]+ /,''));
|
|
}
|
|
}
|
|
}catch(e){console.error('team alert error',e.message)}
|
|
// "you've been paid" email — sponsor-record contact, and self-service subscribers
|
|
try{
|
|
if(evt.type==='payout'){
|
|
const sent=new Set();
|
|
const sp=getSponsors().find(x=>String(x.id)===String(evt.toId));
|
|
if(sp&&sp.email){sendPaidEmail(sp.email,sp.name,evt);sent.add(sp.email.toLowerCase());}
|
|
const rec=getMemberAlerts()[evt.toId];
|
|
if(rec&&rec.email&&!sent.has(rec.email.toLowerCase()))sendPaidEmail(rec.email,'there',evt,unsubUrl(evt.toId));
|
|
}
|
|
}catch(e){console.error('paid email error',e.message)}
|
|
// Auto-count directs + AUTO-ADVANCE (Marty 2026-08-15): a new registration
|
|
// whose referrer sits in the rotation queue increments that sponsor's directs.
|
|
// When it reaches 2/2 the sponsor is auto-qualified and the next waiting
|
|
// position activates — no manual Qualify click (changed from the earlier
|
|
// manual design so the rotation never sits stuck at a 2/2 sponsor).
|
|
try{
|
|
if(evt.type==='registered'&&evt.referrerId!=null){
|
|
let sponsors=getSponsors();
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.referrerId));
|
|
if(idx>=0&&sponsors[idx].status!=='qualified'&&(Number(sponsors[idx].directs)||0)<2){
|
|
const newDirects=Math.min(2,(Number(sponsors[idx].directs)||0)+1);
|
|
const s=sponsors[idx];
|
|
if(newDirects>=2){
|
|
sponsors[idx]={...sponsors[idx],directs:2,status:'qualified'};
|
|
sponsors=normalizeStatuses(sponsors);
|
|
saveSponsors(sponsors);
|
|
const next=activeSponsor(sponsors);
|
|
sendTelegram(`✅ AUTO-ADVANCE: queue sponsor #${s.id}${s.name?` (${s.name})`:''} reached 2/2 and is now QUALIFIED.\nRotation moved to ${next?`#${next.id}${next.name?` (${next.name})`:''}`:'— nobody waiting (add the next position to the queue)'}.`);
|
|
}else{
|
|
sponsors[idx].directs=newDirects;
|
|
saveSponsors(sponsors);
|
|
sendTelegram(`🤖 AUTO-COUNT: #${evt.id} is a DIRECT for queue sponsor #${s.id}${s.name?` (${s.name})`:''} — now ${newDirects}/2.`);
|
|
}
|
|
}
|
|
}
|
|
}catch(e){console.error('auto-direct error',e.message)}
|
|
// Queue level sync: when a queue member upgrades on-chain, their Level in the
|
|
// rotation queue follows automatically (same op as the admin level dropdown).
|
|
try{
|
|
if(evt.type==='upgraded'&&evt.id!=null){
|
|
const sponsors=getSponsors();
|
|
const idx=sponsors.findIndex(x=>String(x.id)===String(evt.id));
|
|
if(idx>=0&&LEVELS.includes(evt.levelName)&&sponsors[idx].level!==evt.levelName){
|
|
sponsors[idx].level=evt.levelName;
|
|
saveSponsors(sponsors);
|
|
sendTelegram(`🤖 AUTO-LEVEL: queue sponsor #${sponsors[idx].id}${sponsors[idx].name?` (${sponsors[idx].name})`:''} upgraded on-chain — queue level updated to ${evt.levelName}.`);
|
|
}
|
|
}
|
|
}catch(e){console.error('auto-level error',e.message)}
|
|
});
|