Add public member dashboard at /my/:id

Members enter their on-chain ID (or follow a /my/46-style link) and see
their own position: tier/level/directs/earnings facts, their subtree
pyramid with downline rollups and open slots, classified payment
history, and lineage to root — all chain-derived data only, none of the
admin operational config. Public endpoint /api/public/member is cached
(120s) and rate-limited (20/min/IP). Linked from the public footers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-13 14:35:05 -05:00
parent c69ef6c1f6
commit 9f8898d2bd
6 changed files with 159 additions and 4 deletions
+22 -1
View File
@@ -50,6 +50,13 @@ RULES:
}
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;
}
const submitHits = new Map();
function submitRateLimited(ip) {
const now = Date.now(), rec = submitHits.get(ip);
@@ -250,6 +257,20 @@ async function handleApi(req,res,pathname){
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))]);
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'});
}
@@ -330,7 +351,7 @@ const server=http.createServer(async(req,res)=>{
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'});
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==='/')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{
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);