Files
Radicade/Tools/recroom-mirror/radicade-adapter.mjs
T
2026-10-16 23:33:13 -07:00

81 lines
5.7 KiB
JavaScript

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;
}