mirror of
https://github.com/JogmansonReal/Radicade.git
synced 2026-10-25 23:18:56 -07:00
Radicade Public Code
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../artifacts/recroom-network-mirror');
|
||||
const origin = 'https://recroom.network';
|
||||
const allowed = new Set(['recroom.network', 'cdn.recroom.network']);
|
||||
const queue = ['/', '/rooms', '/events', '/news', '/creator', '/shop', '/download'].map(p => origin + p);
|
||||
const seen = new Set();
|
||||
const manifest = [];
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
function localPath(url) {
|
||||
return (url.hostname === 'cdn.recroom.network' ? '/mirror-cdn' : '') + url.pathname;
|
||||
}
|
||||
function rewrite(text) {
|
||||
return text.replaceAll('https://cdn.recroom.network', '/mirror-cdn')
|
||||
.replaceAll('https://recroom.network', '');
|
||||
}
|
||||
function discover(text, base) {
|
||||
const add = raw => {
|
||||
try {
|
||||
const value = raw.replaceAll('&', '&').replaceAll('\\/', '/');
|
||||
const url = new URL(value, base);
|
||||
if (!allowed.has(url.hostname) || url.protocol !== 'https:') return;
|
||||
if (!/\.(?:js|css|woff2?|ttf|png|jpe?g|webp|svg|ico|avif)(?:$|\?)/i.test(url.pathname + url.search)) return;
|
||||
url.hash = ''; if (!seen.has(url.href)) queue.push(url.href);
|
||||
} catch {}
|
||||
};
|
||||
for (const m of text.matchAll(/(?:src|href|poster)=["']([^"']+)["']/g)) add(m[1]);
|
||||
for (const m of text.matchAll(/url\(["']?([^\s)'";]+)["']?\)/g)) add(m[1]);
|
||||
for (const m of text.matchAll(/["'`](https:\/\/[^"'`\s<>]+|\/(?:_next|static|logos|images)\/[^"'`\s<>]+)["'`]/g)) add(m[1]);
|
||||
for (const m of text.matchAll(/["'](static\/(?:chunks|media)\/[^"']+)["']/g)) add('/_next/' + m[1]);
|
||||
}
|
||||
while (queue.length && seen.size < 350) {
|
||||
const source = queue.shift(); if (seen.has(source)) continue; seen.add(source);
|
||||
try {
|
||||
const response = await fetch(source, { signal: AbortSignal.timeout(25000) });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const url = new URL(source);
|
||||
let output = localPath(url);
|
||||
if (type.includes('text/html')) output = output.replace(/\/$/, '') + '/index.html';
|
||||
const filename = path.resolve(root, '.' + output);
|
||||
if (!filename.startsWith(root + path.sep)) throw new Error('Invalid output path');
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
await fs.mkdir(path.dirname(filename), { recursive: true });
|
||||
if (/javascript|text\/css|text\/html/.test(type)) {
|
||||
const sourceText = bytes.toString('utf8'); discover(sourceText, source);
|
||||
await fs.writeFile(filename, rewrite(sourceText));
|
||||
} else await fs.writeFile(filename, bytes);
|
||||
manifest.push({ source, file: output, type, bytes: bytes.length });
|
||||
console.log(`${manifest.length}: ${output} (${bytes.length})`);
|
||||
} catch (error) { manifest.push({ source, error: error.message }); console.log(`FAILED ${source}: ${error.message}`); }
|
||||
}
|
||||
await fs.writeFile(path.join(root, 'manifest.json'), JSON.stringify({ capturedAt: new Date().toISOString(), source: origin, files: manifest }, null, 2));
|
||||
console.log(`Saved ${manifest.filter(x => !x.error).length} files; ${manifest.filter(x => x.error).length} failed; ${queue.length} queued. ${root}`);
|
||||
@@ -0,0 +1,75 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export function upstreamFor(url) {
|
||||
const routes = {
|
||||
'/_public/rooms/featuredrooms/current': 'https://rooms.recroom.network/featuredrooms/current',
|
||||
'/_public/rooms/rooms/bulk': 'https://rooms.recroom.network/rooms/bulk',
|
||||
'/_public/accounts/account/bulk': 'https://accounts.recroom.network/account/bulk',
|
||||
'/_public/apim/apis/api/images/v3/feed/global': 'https://apim.recroom.network/apis/api/images/v3/feed/global',
|
||||
};
|
||||
if (routes[url.pathname]) {
|
||||
const target = new URL(routes[url.pathname]);
|
||||
for (const [key,value] of url.searchParams) {
|
||||
if (!['id','ids','skip','take','include'].includes(key) || !/^[0-9,]{1,2000}$/.test(value)) return null;
|
||||
target.searchParams.append(key,value);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
const image = url.pathname.match(/^\/_public\/img\/([a-zA-Z0-9_.%-]{1,180})$/);
|
||||
if (image) {
|
||||
return new URL('https://img.recroom.network/' + image[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function servePublicCache(url, req, res, root) {
|
||||
if (!url.pathname.startsWith('/_public/')) return false;
|
||||
const upstream = upstreamFor(url);
|
||||
if (!upstream) { res.writeHead(404); res.end(); return true; }
|
||||
const key = createHash('sha256').update(upstream.href).digest('hex');
|
||||
const dir = path.join(root, 'public-cache');
|
||||
try {
|
||||
let bytes, type;
|
||||
try {
|
||||
bytes = await fs.readFile(path.join(dir,key));
|
||||
type = await fs.readFile(path.join(dir,key+'.type'),'utf8');
|
||||
} catch {
|
||||
const response = await fetch(upstream, {redirect:'error', signal:AbortSignal.timeout(20000)});
|
||||
if (response.status === 404 && upstream.hostname === 'img.recroom.network') {
|
||||
res.writeHead(200, {'Content-Type':'image/svg+xml','X-Mirror-Placeholder':'source-image-missing'});
|
||||
res.end('<svg xmlns="http://www.w3.org/2000/svg" width="480" height="300"><rect width="480" height="300" fill="#303030"/><text x="240" y="155" fill="#aaa" font-family="Arial" font-size="20" text-anchor="middle">Image unavailable in source archive</text></svg>');
|
||||
return true;
|
||||
}
|
||||
if (!response.ok) throw new Error(`Public source returned ${response.status}`);
|
||||
type = response.headers.get('content-type') || 'application/octet-stream';
|
||||
bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length > 15 * 1024 * 1024) throw new Error('Response too large');
|
||||
await fs.mkdir(dir,{recursive:true});
|
||||
await fs.writeFile(path.join(dir,key),bytes);
|
||||
await fs.writeFile(path.join(dir,key+'.type'),type);
|
||||
}
|
||||
res.writeHead(200, {'Content-Type':type,'Cache-Control':'public, max-age=3600'});
|
||||
res.end(req.method === 'HEAD' ? undefined : bytes);
|
||||
} catch(error) {
|
||||
console.error('Public cache:', upstream.href, error.message);
|
||||
res.writeHead(502, {'Content-Type':'application/json'});
|
||||
res.end(JSON.stringify({error:'public_snapshot_unavailable'}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const bridgeScript = `<script>
|
||||
const mirrorFetch=window.fetch.bind(window);
|
||||
window.fetch=function(input,init){
|
||||
const u=new URL(input instanceof Request?input.url:input,location.href);
|
||||
const routes={'rooms.recroom.network':'rooms','apim.recroom.network':'apim','accounts.recroom.network':'accounts'};
|
||||
if(routes[u.hostname]){
|
||||
const method=(init?.method||(input instanceof Request?input.method:'GET')).toUpperCase();
|
||||
if(method!=='GET'&&method!=='HEAD')return Promise.resolve(new Response('{"error":"offline_preview"}',{status:501}));
|
||||
return mirrorFetch('/_public/'+routes[u.hostname]+u.pathname+u.search,{method,signal:init?.signal,credentials:'omit',cache:'no-store'});
|
||||
}
|
||||
return mirrorFetch(input,init);
|
||||
};
|
||||
</script>`;
|
||||
@@ -0,0 +1,80 @@
|
||||
const server = new URL(process.env.RADICADE_WEB_SERVER || 'https://ns.radicade.app');
|
||||
if (!['https:', 'http:'].includes(server.protocol) || server.username || server.password || server.search || server.hash || server.pathname !== '/') throw new Error('RADICADE_WEB_SERVER must be a server origin');
|
||||
export const backendOrigin = server.origin;
|
||||
const galleryIds = (process.env.RADICADE_GALLERY_IDS || '2').split(',').filter(x => /^\d+$/.test(x));
|
||||
const memory = new Map();
|
||||
const inflight = new Map();
|
||||
async function json(route) {
|
||||
if (memory.get(route)?.until > Date.now()) return memory.get(route).data;
|
||||
if (inflight.has(route)) return inflight.get(route);
|
||||
const pending = (async () => {
|
||||
const r = await fetch(backendOrigin + route, {redirect:'error',signal:AbortSignal.timeout(20000)});
|
||||
if (!r.ok) throw new Error(`Radicade ${route.split('?')[0]} returned ${r.status}`);
|
||||
const data = await r.json(); memory.set(route,{data,until:Date.now()+15000}); return data;
|
||||
})();
|
||||
inflight.set(route,pending);
|
||||
try { return await pending; } finally { inflight.delete(route); }
|
||||
}
|
||||
export function publicRooms(rows) { return rows.filter(x => x.Accessibility === 1 && x.State === 0 && !x.IsDorm); }
|
||||
export function roomCard(x) {
|
||||
return {RoomId:String(x.RoomId),Name:x.Name,Description:x.Description ?? null,ImageName:x.ImageName || 'default',
|
||||
CreatorAccountId:x.CreatorAccountId == null ? null : String(x.CreatorAccountId),Accessibility:1,IsRRO:!!x.IsRRO,IsDorm:false,
|
||||
Stats:{CheerCount:x.Stats?.CheerCount ?? 0,VisitCount:x.Stats?.VisitCount ?? 0}};
|
||||
}
|
||||
export function accountCard(x) {
|
||||
return {accountId:String(x.accountId),username:x.username,displayName:x.displayName,profileImage:x.profileImage || 'default',
|
||||
isJunior:!!x.isJunior,platforms:x.platforms ?? 0,createdAt:iso(x.createdAt),identityFlags:x.identityFlags ?? 0,
|
||||
personalPronouns:x.personalPronouns ?? 0,isMetaPlatformBlocked:false};
|
||||
}
|
||||
function iso(date) { return /(?:Z|[+-]\d\d:\d\d)$/.test(date) ? date : date+'Z'; }
|
||||
export function photoCard(x) {
|
||||
return {...x,Id:String(x.Id),PlayerId:String(x.PlayerId),RoomId:x.RoomId == null ? null : String(x.RoomId),
|
||||
PlayerEventId:x.PlayerEventId == null ? null : String(x.PlayerEventId),Description:x.Description ?? null,
|
||||
CreatedAt:iso(x.CreatedAt),TaggedPlayerIds:(x.TaggedPlayerIds || []).map(String)};
|
||||
}
|
||||
async function rooms() { const data=await json('/rooms/rooms/hot'); return publicRooms(data.Results || []); }
|
||||
function ids(url) {
|
||||
const values=url.searchParams.getAll('id');
|
||||
if (values.length>100 || values.some(x=>!/^\d{1,18}$/.test(x))) throw new Error('Invalid IDs');
|
||||
return values;
|
||||
}
|
||||
function idQuery(values) { return new URLSearchParams(values.map(x=>['id',String(x)])).toString(); }
|
||||
export async function serveRadicade(url,req,res) {
|
||||
if (!url.pathname.startsWith('/_public/') && url.pathname !== '/_adapter/status') return false;
|
||||
try {
|
||||
let data;
|
||||
if (url.pathname === '/_adapter/status') data={backend:backendOrigin,mode:'radicade',photoFeed:'public galleries of room creators and configured accounts',galleryIds};
|
||||
else if (url.pathname === '/_public/rooms/featuredrooms/current') {
|
||||
const list=await rooms(); list.sort((a,b)=>(b.Stats?.VisitCount||0)-(a.Stats?.VisitCount||0));
|
||||
data={Rooms:list.slice(0,6).map(x=>({RoomId:String(x.RoomId)}))};
|
||||
} else if (url.pathname === '/_public/rooms/rooms/bulk') {
|
||||
const wanted=new Set(ids(url)); data=(await rooms()).filter(x=>wanted.has(String(x.RoomId))).map(roomCard);
|
||||
} else if (url.pathname === '/_public/accounts/account/bulk') {
|
||||
const wanted=ids(url); data=wanted.length ? (await json('/accounts/account/bulk?'+idQuery(wanted))).map(accountCard) : [];
|
||||
} else if (url.pathname === '/_public/apim/apis/api/images/v3/feed/global') {
|
||||
const skip=Number(url.searchParams.get('skip') || 0),take=Number(url.searchParams.get('take') || 10);
|
||||
if (!Number.isInteger(skip)||!Number.isInteger(take)||skip<0||skip>10000||take<1||take>100) throw new Error('Invalid pagination');
|
||||
const creators=[...new Set([...galleryIds,...(await rooms()).map(x=>String(x.CreatorAccountId))])].slice(0,50);
|
||||
const images=[];
|
||||
for(const id of creators) images.push(...await json('/api/images/v5/player/'+id));
|
||||
const unique=[...new Map(images.filter(x=>x.Accessibility===1 && x.Type===1).map(x=>[String(x.Id),x])).values()];
|
||||
unique.sort((a,b)=>new Date(b.CreatedAt)-new Date(a.CreatedAt));
|
||||
data=unique.slice(skip,skip+take).map(photoCard);
|
||||
} else if (/^\/_public\/img\/[a-zA-Z0-9_.%-]{1,180}$/.test(url.pathname)) {
|
||||
const name=url.pathname.split('/').pop();
|
||||
if (decodeURIComponent(name).includes('/') || decodeURIComponent(name).includes('\\') || name === '..') throw new Error('Invalid image');
|
||||
const r=await fetch(backendOrigin+'/images/img/'+name,{redirect:'error',signal:AbortSignal.timeout(20000)});
|
||||
if (!r.ok) throw new Error('Radicade image returned '+r.status);
|
||||
const type=r.headers.get('content-type') || '';
|
||||
if (!type.startsWith('image/')) throw new Error('Unexpected image type');
|
||||
res.writeHead(200,{'Content-Type':type,'Cache-Control':'public, max-age=60'});
|
||||
res.end(req.method==='HEAD'?undefined:Buffer.from(await r.arrayBuffer())); return true;
|
||||
} else {res.writeHead(404,{'Content-Type':'application/json'});res.end('{"error":"adapter_route_not_connected"}');return true;}
|
||||
res.writeHead(200,{'Content-Type':'application/json','Cache-Control':'no-store','X-Radicade-Backend':backendOrigin});
|
||||
res.end(req.method==='HEAD'?undefined:JSON.stringify(data));
|
||||
} catch(error) {
|
||||
console.error('Radicade adapter:',error.message);
|
||||
res.writeHead(502,{'Content-Type':'application/json'});res.end(JSON.stringify({error:'radicade_unavailable',message:error.message}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import http from 'node:http';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { servePublicCache, bridgeScript } from './public-cache.mjs';
|
||||
import { serveRadicade, backendOrigin } from './radicade-adapter.mjs';
|
||||
const archiveMode = process.env.RECNET_DATA_MODE === 'archive';
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../artifacts/recroom-network-mirror');
|
||||
const types = {'.html':'text/html; charset=utf-8','.js':'text/javascript','.css':'text/css','.json':'application/json','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.avif':'image/avif','.ico':'image/x-icon','.woff':'font/woff','.woff2':'font/woff2','.ttf':'font/ttf'};
|
||||
http.createServer(async (req, res) => {
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; media-src 'self' blob:; form-action 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'");
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
if (!['GET','HEAD'].includes(req.method)) { res.writeHead(501, {'Content-Type':'application/json'}); res.end('{"error":"offline_preview","message":"Login and server actions are not connected in this local copy."}'); return; }
|
||||
try {
|
||||
const url = new URL(req.url, 'http://127.0.0.1');
|
||||
if (!archiveMode && url.pathname.startsWith('/_radicade/v2/')) {
|
||||
url.pathname = url.pathname.replace('/_radicade/v2/', '/_public/');
|
||||
}
|
||||
if (archiveMode ? await servePublicCache(url, req, res, root) : await serveRadicade(url, req, res)) return;
|
||||
if (url.pathname === '/static/img/NoSearchResult.png') {
|
||||
res.writeHead(200, {'Content-Type':'image/svg+xml'});
|
||||
res.end('<svg xmlns="http://www.w3.org/2000/svg" width="160" height="120"><text x="80" y="70" fill="#aaa" font-size="42" text-anchor="middle">[ = )</text></svg>');
|
||||
return;
|
||||
}
|
||||
const filename = path.resolve(root, '.' + url.pathname);
|
||||
if (filename !== root && !filename.startsWith(root + path.sep)) throw new Error('Invalid path');
|
||||
let actual = filename;
|
||||
const stat = await fs.stat(actual);
|
||||
if (stat.isDirectory()) actual = path.join(actual, 'index.html');
|
||||
let content = await fs.readFile(actual);
|
||||
const ext = path.extname(actual);
|
||||
const dataPrefix = archiveMode ? '/_public/' : '/_radicade/v2/';
|
||||
if (ext === '.js' || ext === '.html') content = Buffer.from(content.toString()
|
||||
.replaceAll('https://img.recroom.network/', dataPrefix + 'img/')
|
||||
.replaceAll('/_public/img/', dataPrefix + 'img/'));
|
||||
if (ext === '.html') {
|
||||
const previewScript = `<script>document.addEventListener('click',function(e){const a=e.target.closest('a');const b=e.target.closest('button');if(b&&/^log\\s?in$/i.test(b.textContent.trim())){e.preventDefault();e.stopImmediatePropagation();alert('Login is not connected in this local preview.');return;}if(a){const u=new URL(a.href,location.href);if(u.origin===location.origin&&!u.hash){e.preventDefault();e.stopImmediatePropagation();location.assign(u.href);}}},true);</script>`;
|
||||
const sourceLabel=archiveMode?'Cached recroom.network data':'Radicade public data · '+backendOrigin.replace(/[&<>"']/g,'');
|
||||
const activeBridge = bridgeScript.replaceAll('/_public/', dataPrefix);
|
||||
content = Buffer.from(content.toString().replace('<head>', '<head>' + activeBridge + previewScript).replace('</body>', '<div style="position:fixed;bottom:12px;left:12px;z-index:2147483647;background:#202020;color:#fff;padding:10px 16px;border:1px solid #888;border-radius:8px;font:13px Arial;max-width:90vw">Local copy · '+sourceLabel+' · Login not connected</div></body>'));
|
||||
}
|
||||
res.writeHead(200, {'Content-Type':types[ext] || 'application/octet-stream'});
|
||||
res.end(req.method === 'HEAD' ? undefined : content);
|
||||
} catch { res.writeHead(404, {'Content-Type':'application/json'}); res.end('{"error":"not_archived","message":"This file or API is not available in the local copy."}'); }
|
||||
}).listen(3040, '127.0.0.1', () => console.log('Local website copy: http://127.0.0.1:3040'));
|
||||
Reference in New Issue
Block a user