import { getErrorMessage } from "../../utils/error-message.js"; import express, { type Request, type Response } from "express"; import https from "https"; import http from "http"; import { lookup } from "dns/promises"; import { isIP } from "net"; import { homepageLogger } from "../../utils/logger.js"; import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js"; export const homepageProxyRouter = express.Router(); interface ProxyCacheEntry { data: unknown; expires: number; } const proxyCache = new Map(); const CACHE_SIZE = 50; const FETCH_TIMEOUT_MS = 8000; async function resolvePublicUrl(rawUrl: string): Promise<{ url: URL; address: string; }> { const url = new URL(rawUrl); if ( !["http:", "https:"].includes(url.protocol) || url.username || url.password ) { throw new Error("Invalid URL"); } const hostname = url.hostname.replace(/^\[|\]$/g, ""); const addresses = isIP(hostname) ? [{ address: hostname }] : await lookup(hostname, { all: true, verbatim: true }); if ( addresses.length === 0 || addresses.some(({ address }) => isBlockedAddress(address)) ) { throw new Error("Private destinations are not allowed"); } return { url, address: addresses[0].address }; } async function fetchJson(rawUrl: string): Promise { const { url, address } = await resolvePublicUrl(rawUrl); return new Promise((resolve, reject) => { const mod = url.protocol === "https:" ? https : http; const req = mod.get( { protocol: url.protocol, hostname: address, port: url.port || undefined, path: `${url.pathname}${url.search}`, headers: { Host: url.host }, servername: url.protocol === "https:" ? url.hostname : undefined, timeout: FETCH_TIMEOUT_MS, }, (res) => { const chunks: Buffer[] = []; res.on("data", (chunk: Buffer) => chunks.push(chunk)); res.on("end", () => { try { const text = Buffer.concat(chunks).toString("utf-8"); resolve(JSON.parse(text)); } catch { reject(new Error("Response is not valid JSON")); } }); res.on("error", reject); }, ); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("Fetch timeout")); }); }); } /** * @openapi * /homepage/proxy: * get: * summary: Proxy a JSON API URL and return the parsed response * tags: * - Homepage * parameters: * - in: query * name: url * required: true * schema: * type: string * - in: query * name: ttl * schema: * type: integer * description: Cache TTL in seconds (min 10, default 60) * responses: * 200: * description: The JSON body returned by the target URL. * 400: * description: Invalid or missing URL, or non-JSON response. * 500: * description: Failed to fetch the target URL. */ homepageProxyRouter.get("/", async (req: Request, res: Response) => { const targetUrl = req.query.url as string; const ttl = Math.max(10, Number(req.query.ttl) || 60) * 1000; if (!targetUrl) return res.status(400).json({ error: "url is required" }); try { new URL(targetUrl); } catch { return res.status(400).json({ error: "Invalid URL" }); } const cached = proxyCache.get(targetUrl); if (cached && cached.expires > Date.now()) { return res.json(cached.data); } try { const data = await fetchJson(targetUrl); if (proxyCache.size >= CACHE_SIZE) { const oldest = proxyCache.keys().next().value; if (oldest) proxyCache.delete(oldest); } proxyCache.set(targetUrl, { data, expires: Date.now() + ttl }); res.json(data); } catch (err) { const msg = getErrorMessage(err); homepageLogger.warn("Proxy fetch failed", { targetUrl, msg }); if (msg.includes("not valid JSON")) { return res.status(400).json({ error: "Response is not valid JSON" }); } res.status(500).json({ error: "Failed to fetch URL" }); } });