Self-host the site translation instead of the Google widget

The widget needed inline-script CSP we refuse to grant, so: a
/api/public/translate endpoint feeds misses through the chatbot's
OpenRouter model and caches every string forever on the volume
(translations.json); the client walks text nodes, swaps them in
place, and a MutationObserver keeps late-rendered panels covered.
CSP goes back to fully tight. 21 languages; choice sticks in
localStorage; level names and POL stay untranslated.
This commit is contained in:
martbost
2026-08-21 07:30:01 -05:00
parent faf397f681
commit 944d2beb87
2 changed files with 144 additions and 27 deletions
+86 -25
View File
@@ -1,47 +1,108 @@
// Floating language picker — lazy-loads the Google Translate element on first
// tap so pages pay zero cost until someone actually wants a translation. The
// googtrans cookie carries the chosen language across every page after that.
// Floating language picker — self-hosted translation. Text nodes are collected,
// translated through /api/public/translate (server-cached forever per string),
// and swapped in place. A MutationObserver keeps late-rendered dashboard panels
// translated too. No third-party scripts touch the page.
(function(){
var LANGS=[['es','Español'],['pt','Português'],['fr','Français'],['de','Deutsch'],['it','Italiano'],
['nl','Nederlands'],['pl','Polski'],['ro','Română'],['ru','Русский'],['uk','Українська'],
['tr','Türkçe'],['ar','العربية'],['hi','हिन्दी'],['fil','Filipino'],['vi','Tiếng Việt'],
['id','Bahasa Indonesia'],['th','ไทย'],['zh','中文'],['ja','日本語'],['ko','한국어'],['sw','Kiswahili']];
var STYLE=''+
'#rmcTrBtn{position:fixed;bottom:18px;left:18px;z-index:9998;display:flex;align-items:center;gap:7px;'+
'background:#0d2236;border:1px solid #24425d;color:#f7f9fc;border-radius:999px;padding:9px 14px;'+
'font:700 13px/1 Inter,system-ui,sans-serif;cursor:pointer;box-shadow:0 6px 24px rgba(0,0,0,.35)}'+
'#rmcTrBtn:hover{border-color:#4ed6cb}'+
'#rmcTrPanel{position:fixed;bottom:64px;left:18px;z-index:9999;background:#0d2236;border:1px solid #24425d;'+
'border-radius:14px;padding:14px;display:none;box-shadow:0 10px 34px rgba(0,0,0,.45)}'+
'border-radius:14px;padding:12px;display:none;box-shadow:0 10px 34px rgba(0,0,0,.45);max-height:60vh;overflow:auto;width:230px}'+
'#rmcTrPanel.on{display:block}'+
'#rmcTrPanel .t{color:#aebdca;font:600 12px/1.4 Inter,system-ui,sans-serif;margin:0 0 8px}'+
/* suppress Google's top banner + body shift */
'.goog-te-banner-frame,.skiptranslate iframe{display:none!important}'+
'body{top:0!important}'+
'#google_translate_element select{background:#071726;color:#f7f9fc;border:1px solid #24425d;border-radius:8px;padding:8px;font-size:14px;max-width:230px}'+
'#google_translate_element .goog-te-gadget{color:#aebdca;font-size:11px}'+
'#google_translate_element .goog-logo-link{color:#aebdca!important}';
var loaded=false;
'#rmcTrPanel button{display:block;width:100%;text-align:left;background:none;border:none;color:#f7f9fc;'+
'padding:8px 10px;border-radius:8px;font:600 14px/1 Inter,system-ui,sans-serif;cursor:pointer}'+
'#rmcTrPanel button:hover{background:#123049}'+
'#rmcTrPanel button.on{background:#123049;color:#4ed6cb}';
var lang=null;
try{ lang=localStorage.getItem('rmc.lang')||null; }catch(e){}
var cacheKey=function(){ return 'rmc.tr.'+lang; };
var memo={};
function loadMemo(){ memo={}; try{ memo=JSON.parse(sessionStorage.getItem(cacheKey())||'{}'); }catch(e){} }
function saveMemo(){ try{ var k=Object.keys(memo); if(k.length>900){memo={};} sessionStorage.setItem(cacheKey(),JSON.stringify(memo)); }catch(e){} }
var SKIP={SCRIPT:1,STYLE:1,NOSCRIPT:1,CODE:1,TEXTAREA:1,SELECT:1};
function collect(root){
var nodes=[],w=document.createTreeWalker(root,NodeFilter.SHOW_TEXT,{acceptNode:function(n){
var p=n.parentNode; if(!p||SKIP[p.nodeName])return NodeFilter.FILTER_REJECT;
if(p.closest&&p.closest('#rmcTrPanel,#rmcTrBtn'))return NodeFilter.FILTER_REJECT;
var t=n.nodeValue; if(!t||!t.trim()||t.trim().length<2)return NodeFilter.FILTER_REJECT;
if(!/[A-Za-z]{2}/.test(t))return NodeFilter.FILTER_REJECT;
if(n.__rmcTr===lang)return NodeFilter.FILTER_REJECT;
return NodeFilter.FILTER_ACCEPT;
}});
var n; while((n=w.nextNode()))nodes.push(n);
return nodes;
}
var busy=false,queued=false;
function translatePage(root){
if(!lang)return;
if(busy){queued=true;return}
busy=true;
var nodes=collect(root||document.body);
var uniq=[],idx={};
nodes.forEach(function(n){ var t=n.nodeValue.trim(); if(!(t in idx)&&memo[t]==null){ idx[t]=uniq.length; uniq.push(t);} });
var chunks=[]; for(var i=0;i<uniq.length;i+=45)chunks.push(uniq.slice(i,i+45));
var p=Promise.resolve();
chunks.forEach(function(ch){
p=p.then(function(){
return fetch('/api/public/translate',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({tl:lang,texts:ch})}).then(function(r){return r.json()}).then(function(d){
if(d&&Array.isArray(d.t))ch.forEach(function(t,j){ memo[t]=d.t[j]; });
}).catch(function(){});
});
});
p.then(function(){
saveMemo();
nodes.forEach(function(n){
var raw=n.nodeValue,t=raw.trim(),tr=memo[t];
if(tr&&tr!==t){ n.nodeValue=raw.replace(t,tr); }
n.__rmcTr=lang;
});
busy=false;
if(queued){queued=false;translatePage();}
});
}
var moTimer=null;
var mo=new MutationObserver(function(){
if(!lang)return;
clearTimeout(moTimer); moTimer=setTimeout(function(){translatePage();},700);
});
function setLang(code){
if(code){ lang=code; try{localStorage.setItem('rmc.lang',code)}catch(e){} loadMemo(); translatePage(); }
else{ lang=null; try{localStorage.removeItem('rmc.lang')}catch(e){} location.reload(); }
}
function mount(){
var st=document.createElement('style');st.textContent=STYLE;document.head.appendChild(st);
var btn=document.createElement('button');btn.id='rmcTrBtn';btn.type='button';
btn.innerHTML='🌐 <span>Translate</span>';
btn.setAttribute('aria-label','Translate this page');
var panel=document.createElement('div');panel.id='rmcTrPanel';
panel.innerHTML='<p class="t">Choose your language — it sticks on every page.</p><div id="google_translate_element"></div>';
var htmlBits=['<p class="t">Choose your language — it sticks on every page.</p>',
'<button data-l="">English (original)</button>'];
LANGS.forEach(function(L){ htmlBits.push('<button data-l="'+L[0]+'"'+(lang===L[0]?' class="on"':'')+'>'+L[1]+'</button>'); });
panel.innerHTML=htmlBits.join('');
document.body.appendChild(btn);document.body.appendChild(panel);
btn.addEventListener('click',function(){
panel.classList.toggle('on');
if(!loaded){
loaded=true;
window.googleTranslateElementInit=function(){
new google.translate.TranslateElement({pageLanguage:'en',autoDisplay:false},'google_translate_element');
};
var s=document.createElement('script');
s.src='https://translate.google.com/translate_a/element.js?cb=googleTranslateElementInit';
s.onerror=function(){document.getElementById('google_translate_element').innerHTML='<p class="t">Translation is unavailable right now — your browser’s own “Translate page” option also works on this site.</p>';};
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();
})();
+57 -1
View File
@@ -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'){