interface Env { PROXY_API_KEY: string; } interface ExecutionContext { waitUntil(promise: Promise): void; } interface WorkerCacheStorage extends CacheStorage { default: Cache; } type Route = { upstream: URL; ttl: number; }; const REQUEST_TIMEOUT_MS = 8000; const MAX_BADGE_IDS = 50; const MAX_CURSOR_LENGTH = 512; const BADGE_UNIVERSES = new Set(['5505906463', '2354637886', '5042905930']); const inFlight = new Map>(); function jsonResponse(body: Record, status: number): Response { return Response.json(body, { status, headers: { 'Cache-Control': 'no-store' }, }); } function isValidId(value: string): boolean { return /^[1-9]\d{0,19}$/.test(value); } function getRoute(requestUrl: URL): Route | null { const parts = requestUrl.pathname.split('/').filter(Boolean); if (parts[0] !== 'v1' || parts[1] !== 'roblox') return null; if ( parts[2] === 'users' && parts.length === 4 && requestUrl.search === '' && isValidId(parts[3]) ) { return { upstream: new URL(`https://users.roblox.com/v1/users/${parts[3]}`), ttl: 3600, }; } if ( parts[2] === 'users' && parts.length === 6 && requestUrl.search === '' && isValidId(parts[3]) && parts[4] === 'groups' && parts[5] === 'roles' ) { return { upstream: new URL(`https://groups.roblox.com/v2/users/${parts[3]}/groups/roles`), ttl: 300, }; } if ( parts[2] === 'universes' && parts.length === 5 && parts[4] === 'badges' && BADGE_UNIVERSES.has(parts[3]) ) { if ( [...requestUrl.searchParams.keys()].some(key => !['limit', 'cursor'].includes(key)) || requestUrl.searchParams.getAll('limit').length > 1 || requestUrl.searchParams.getAll('cursor').length > 1 ) return null; const limit = requestUrl.searchParams.get('limit') ?? '100'; const cursor = requestUrl.searchParams.get('cursor'); if (limit !== '100' || (cursor !== null && cursor.length > MAX_CURSOR_LENGTH)) return null; const upstream = new URL(`https://badges.roblox.com/v1/universes/${parts[3]}/badges`); upstream.searchParams.set('limit', '100'); if (cursor) upstream.searchParams.set('cursor', cursor); return { upstream, ttl: 3600 }; } if (parts[2] === 'badges' && parts[3] === 'icons' && parts.length === 4) { if ([...requestUrl.searchParams.keys()].some(key => key !== 'badgeIds')) return null; const values = requestUrl.searchParams.getAll('badgeIds'); if (values.length !== 1) return null; const badgeIds = values[0].split(',').sort(); if ( badgeIds.length === 0 || badgeIds.length > MAX_BADGE_IDS || badgeIds.some(id => !isValidId(id)) ) return null; const upstream = new URL('https://thumbnails.roblox.com/v1/badges/icons'); upstream.searchParams.set('badgeIds', badgeIds.join(',')); upstream.searchParams.set('size', '150x150'); upstream.searchParams.set('format', 'Png'); upstream.searchParams.set('isCircular', 'false'); return { upstream, ttl: 86400 }; } return null; } async function isAuthorized(request: Request, configuredKey: string): Promise { const supplied = request.headers.get('Authorization')?.match(/^Bearer (.+)$/)?.[1]; if (!supplied || configuredKey.length < 32) return false; const encoder = new TextEncoder(); const [suppliedHash, configuredHash] = await Promise.all([ crypto.subtle.digest('SHA-256', encoder.encode(supplied)), crypto.subtle.digest('SHA-256', encoder.encode(configuredKey)), ]); const suppliedBytes = new Uint8Array(suppliedHash); const configuredBytes = new Uint8Array(configuredHash); let difference = 0; for (let index = 0; index < suppliedBytes.length; index++) { difference |= suppliedBytes[index] ^ configuredBytes[index]; } return difference === 0; } async function fetchAndCache( upstream: URL, cacheKey: Request, ttl: number, context: ExecutionContext, ): Promise { const cache = (caches as WorkerCacheStorage).default; const cached = await cache.match(cacheKey); if (cached) return cached; const key = cacheKey.url; const existing = inFlight.get(key); if (existing) return (await existing).clone(); const pending = (async () => { const response = await fetch(upstream, { headers: { Accept: 'application/json' }, redirect: 'error', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); const headers = new Headers({ 'Cache-Control': response.ok ? `public, max-age=${ttl}` : 'no-store', 'Content-Type': response.headers.get('Content-Type') ?? 'application/json', }); const result = new Response(response.body, { status: response.status, headers, }); if (response.ok) context.waitUntil(cache.put(cacheKey, result.clone())); return result; })(); inFlight.set(key, pending); try { return (await pending).clone(); } finally { inFlight.delete(key); } } async function handleRequest( request: Request, env: Env, context: ExecutionContext, ): Promise { if (request.method !== 'GET') { return jsonResponse({ error: 'Method not allowed' }, 405); } if (!(await isAuthorized(request, env.PROXY_API_KEY ?? ''))) { return jsonResponse({ error: 'Unauthorized' }, 401); } const requestUrl = new URL(request.url); const route = getRoute(requestUrl); if (!route) return jsonResponse({ error: 'Not found' }, 404); const cacheKey = new Request(requestUrl, { method: 'GET' }); try { return await fetchAndCache(route.upstream, cacheKey, route.ttl, context); } catch (error) { console.error('Roblox global API request failed.', error); return jsonResponse({ error: 'Roblox service unavailable' }, 502); } } const worker = { fetch(request: Request, env: Env, context: ExecutionContext): Promise { return handleRequest(request, env, context); }, }; export default worker;