Files
Termix/src/backend/database/routes/homepage-proxy-routes.ts
T

143 lines
4.1 KiB
TypeScript
Raw Normal View History

+5
2026-08-19 14:12:06 -05:00
import { getErrorMessage } from "../../utils/error-message.js";
import express, { type Request, type Response } from "express";
2026-06-29 13:28:26 -05:00
import https from "https";
import http from "http";
+8
2026-07-19 12:29:52 -05:00
import { lookup } from "dns/promises";
+3
2026-08-06 14:41:39 -05:00
import { isIP } from "net";
2026-06-29 13:28:26 -05:00
import { homepageLogger } from "../../utils/logger.js";
+3
2026-08-06 14:41:39 -05:00
import { isBlockedAddress } from "../../utils/safe-outbound-fetch.js";
2026-06-29 13:28:26 -05:00
export const homepageProxyRouter = express.Router();
interface ProxyCacheEntry {
data: unknown;
expires: number;
}
const proxyCache = new Map<string, ProxyCacheEntry>();
const CACHE_SIZE = 50;
const FETCH_TIMEOUT_MS = 8000;
+8
2026-07-19 12:29:52 -05:00
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<unknown> {
const { url, address } = await resolvePublicUrl(rawUrl);
2026-06-29 13:28:26 -05:00
return new Promise((resolve, reject) => {
+8
2026-07-19 12:29:52 -05:00
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);
},
);
2026-06-29 13:28:26 -05:00
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) {
+5
2026-08-19 14:12:06 -05:00
const msg = getErrorMessage(err);
2026-06-29 13:28:26 -05:00
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" });
}
});