ec2091631e
- Videos loudness-normalized to -16 LUFS (originals were ~-33 LUFS). - Static file serving rewritten to stream with HTTP Range support so video seeking works; .mp4/.webm MIME types added. - Training nav links on homepage and /start, plus a first-time callout on /start; training page views tracked per source in admin analytics. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
153 lines
13 KiB
JavaScript
153 lines
13 KiB
JavaScript
const http = require('http');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { URL } = require('url');
|
|
|
|
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 sessions = new Map();
|
|
|
|
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'].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)}`};
|
|
}
|
|
function securityHeaders(extra={}) {
|
|
return {
|
|
'X-Content-Type-Options':'nosniff','X-Frame-Options':'DENY','Referrer-Policy':'strict-origin-when-cross-origin',
|
|
'Permissions-Policy':'camera=(), microphone=(), geolocation=()',
|
|
'Content-Security-Policy':"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; frame-ancestors 'none'",
|
|
...extra
|
|
};
|
|
}
|
|
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'};
|
|
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/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);
|
|
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/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 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 token=crypto.randomBytes(32).toString('hex');sessions.set(token,{expires:Date.now()+SESSION_TTL});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);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/state')return json(res,200,{sponsors:getSponsors(),config:getConfig(),analytics:getAnalytics()});
|
|
if(req.method==='POST'&&pathname==='/api/admin/sponsors'){
|
|
const b=await bodyJson(req);const {id,name,parentId='',level='Scintilla',notes=''}=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.'});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()});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'])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.'});for(const k of ['name','parentId','directs','level','notes'])if(Object.prototype.hasOwnProperty.call(b,k))sponsors[idx][k]=b[k];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'});
|
|
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{
|
|
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.');});
|