From 944d2beb87ed3d107082deb82797bf9131e077ed Mon Sep 17 00:00:00 2001
From: martbost
Translation is unavailable right now — your browser’s own “Translate page” option also works on this site.
';}; - document.head.appendChild(s); - } + btn.addEventListener('click',function(){ panel.classList.toggle('on'); }); + panel.addEventListener('click',function(e){ + var b=e.target.closest('button[data-l]'); if(!b)return; + panel.querySelectorAll('button').forEach(function(x){x.classList.remove('on')}); + if(b.dataset.l)b.classList.add('on'); + panel.classList.remove('on'); + setLang(b.dataset.l||null); }); document.addEventListener('click',function(e){ - if(panel.classList.contains('on')&&!panel.contains(e.target)&&e.target!==btn&&!btn.contains(e.target))panel.classList.remove('on'); + if(panel.classList.contains('on')&&!panel.contains(e.target)&&!btn.contains(e.target))panel.classList.remove('on'); }); + mo.observe(document.body,{childList:true,subtree:true}); + if(lang){ loadMemo(); translatePage(); } } if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',mount);else mount(); })(); diff --git a/server.js b/server.js index f400eb0..248ac8a 100644 --- a/server.js +++ b/server.js @@ -355,7 +355,62 @@ 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' https://translate.google.com https://translate.googleapis.com https://translate-pa.googleapis.com; style-src 'self' 'unsafe-inline' https://www.gstatic.com; img-src 'self' data: https://www.gstatic.com https://fonts.gstatic.com https://www.google.com https://translate.googleapis.com; connect-src 'self' https://translate.googleapis.com https://translate-pa.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; form-action 'self'; frame-src https://www.youtube-nocookie.com https://translate.google.com"; + +// --- on-demand UI translation: strings cached forever on the volume, misses +// filled by the same OpenRouter model the chatbot uses. Public site text only. +const TR_FILE = path.join(DATA_DIR, 'translations.json'); +const TR_LANGS = new Set(['es','pt','fr','de','it','nl','pl','ro','ru','uk','tr','ar','hi','fil','vi','id','th','zh','ja','ko','sw']); +const TR_LANG_NAMES = {es:'Spanish',pt:'Portuguese',fr:'French',de:'German',it:'Italian',nl:'Dutch',pl:'Polish',ro:'Romanian',ru:'Russian',uk:'Ukrainian',tr:'Turkish',ar:'Arabic',hi:'Hindi',fil:'Filipino (Tagalog)',vi:'Vietnamese',id:'Indonesian',th:'Thai',zh:'Simplified Chinese',ja:'Japanese',ko:'Korean',sw:'Swahili'}; +let trCache=null,trDirty=false; +function trLoad(){ if(trCache)return trCache; try{trCache=JSON.parse(fs.readFileSync(TR_FILE,'utf8'))}catch(e){trCache={}} return trCache; } +setInterval(()=>{ if(trDirty){trDirty=false;try{fs.writeFileSync(TR_FILE,JSON.stringify(trCache))}catch(e){}} },15000).unref(); +const trIpHits=new Map(); +function trLimited(ip){ const now=Date.now(); const h=trIpHits.get(ip)||{n:0,ts:now}; if(now-h.ts>600000){h.n=0;h.ts=now} h.n++; trIpHits.set(ip,h); if(trIpHits.size>2000)trIpHits.clear(); return h.n>60; } +async function handleTranslate(req,res){ + const ip=String(req.headers['x-forwarded-for']||req.socket.remoteAddress||'').split(',')[0].trim(); + if(trLimited(ip))return json(res,429,{error:'Too many translation requests — give it a minute.'}); + const b=await bodyJson(req).catch(()=>null); + const tl=b?String(b.tl||''):''; + const texts=b&&Array.isArray(b.texts)?b.texts.slice(0,60).map(t=>String(t).slice(0,300)):null; + if(!TR_LANGS.has(tl)||!texts||!texts.length)return json(res,400,{error:'Bad request'}); + if(texts.reduce((a,t)=>a+t.length,0)>9000)return json(res,400,{error:'Too much text'}); + const cache=trLoad(); + const keyOf=t=>tl+'|'+crypto.createHash('sha1').update(t).digest('hex').slice(0,16); + const out=new Array(texts.length); const miss=[]; + texts.forEach((t,i)=>{ const c=cache[keyOf(t)]; if(c!=null)out[i]=c; else miss.push(i); }); + if(miss.length){ + const apiKey=getOpenRouterKey(); + let done=false; + if(apiKey){ + try{ + const ctrl=new AbortController(); const timer=setTimeout(()=>ctrl.abort(),25000); + 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.team','X-Title':'RM Circle Translate'}, + body:JSON.stringify({model:OPENROUTER_MODEL,max_tokens:3000,temperature:0,messages:[ + {role:'system',content:`You translate website UI strings from English to ${TR_LANG_NAMES[tl]}. Reply with ONLY a JSON array of the translated strings, same order and length as the input array. Keep these words untranslated wherever they appear: Scintilla, Ascensus, Fabrica, Culmen, Apex, Fastigium, Vertex, Corona, POL, Polygon, RM Circle, MoonPay, MetaMask, Trust Wallet. Keep numbers, #ids, emoji and punctuation intact. Natural, friendly tone.`}, + {role:'user',content:JSON.stringify(miss.map(i=>texts[i]))} + ]}) + }); + clearTimeout(timer); + if(r.ok){ + const d=await r.json(); + let reply=d&&d.choices&&d.choices[0]&&d.choices[0].message&&d.choices[0].message.content||''; + reply=reply.replace(/^```(?:json)?\s*/,'').replace(/```\s*$/,'').trim(); + const arr=JSON.parse(reply); + if(Array.isArray(arr)&&arr.length===miss.length){ + miss.forEach((idx,j)=>{ const tr=String(arr[j]).slice(0,600); out[idx]=tr; cache[keyOf(texts[idx])]=tr; }); + trDirty=true; done=true; + } + }else{ console.error('translate openrouter status',r.status); } + }catch(e){ console.error('translate error',e.message); } + } + if(!done)miss.forEach(i=>{out[i]=texts[i]}); + } + return json(res,200,{t:out}); +} + +const CSP_BASE="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; 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 { @@ -582,6 +637,7 @@ async function handleApi(req,res,pathname){ 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/translate')return await handleTranslate(req,res); 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'){