Disclaimer page, self-service member alerts, and admin login lockout

- /disclaimer: independent-resource, affiliate, earnings, risk, and
  not-advice disclosures; linked from all footers.
- Self-service alerts: members opt in with their email on /my/:id to get
  "you've been paid" + "upgrade needed" emails for their own position
  (same watcher as the owner alerts, extended). Signed unsubscribe link
  (/unsubscribe?id=&t=HMAC), on-chain-registration check, rate-limited,
  masked-email status, confirmation email.
- Admin login: timing-safe compare + per-IP lockout (8 fails -> 15 min,
  escalating). Previously unlimited/brute-forceable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-14 14:45:57 -05:00
parent 8b3e82d960
commit 0f5630e6d0
9 changed files with 159 additions and 28 deletions
+95 -22
View File
@@ -58,6 +58,26 @@ function memberLookupLimited(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);
@@ -100,10 +120,11 @@ function sendEmailRaw(toEmail, subject, 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) {
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 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/training\n\n— The RM Circle Team\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 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) {
@@ -337,6 +358,27 @@ async function handleApi(req,res,pathname){
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/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.'});
@@ -353,7 +395,13 @@ async function handleApi(req,res,pathname){
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 b=await bodyJson(req).catch(e=>null);if(!b)return json(res,400,{error:'Invalid request'});if(typeof b.password!=='string'||b.password!==ADMIN_PASSWORD)return json(res,401,{error:'Invalid password'});
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'){
@@ -434,8 +482,16 @@ const server=http.createServer(async(req,res)=>{
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(/^\/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{
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);
@@ -449,35 +505,49 @@ server.listen(PORT,()=>{console.log(`Crypto Team Build sponsor router running on
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); }
function checkOwnerUpgrades(){
// 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 ids=parseOwnerIds();
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 alerted=loadOwnerAlerts(); const active=new Set(); const tgDone=new Set();
for(const n of res.needs){
const key=`${n.id}:${n.reason}:${n.neededLevel}`; active.add(key);
if(alerted.has(key)) continue;
alerted.add(key);
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.`;
if(c.ownerAlertEmail){
sendEmailRaw(c.ownerAlertEmail,
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 — one of your positions has money about to arrive that it can't catch yet.\n\nPosition #${n.id} is at ${n.levelName}. Member(s) ${who} are ONE upgrade away from paying #${n.id} about ${n.amountAtRisk} POL — but that payment 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 you (it doesn't come back). Your positions: https://rmcircle.saasy.top/admin\n\n— RM Circle auto-watch`);
`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.`); }
}
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('owner upgrade check', e.message); }
}catch(e){ console.error('upgrade alert check', e.message); }
}
setInterval(checkOwnerUpgrades, 5*60*1000).unref();
setTimeout(checkOwnerUpgrades, 30000).unref();
setInterval(checkUpgradeAlerts, 5*60*1000).unref();
setTimeout(checkUpgradeAlerts, 30000).unref();
chain.startIndexer(evt=>{
try{
const c=getConfig();
@@ -500,11 +570,14 @@ chain.startIndexer(evt=>{
}
}
}catch(e){console.error('team alert error',e.message)}
// "you've been paid" email — any payout whose recipient has a contact email on file (not subtree-gated)
// "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);
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 (Marty 2026-08-14, "prevent manual effort"): a new