summaryrefslogtreecommitdiff
path: root/app/routes/api.image.ts
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/api.image.ts')
-rw-r--r--app/routes/api.image.ts74
1 files changed, 74 insertions, 0 deletions
diff --git a/app/routes/api.image.ts b/app/routes/api.image.ts
new file mode 100644
index 0000000..814cd1c
--- /dev/null
+++ b/app/routes/api.image.ts
@@ -0,0 +1,74 @@
+import type { LoaderFunctionArgs } from "react-router";
+
+type CacheEntry = { data: ArrayBuffer; contentType: string; expiresAt: number };
+const cache = new Map<string, CacheEntry>();
+const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
+const MAX_CACHE_ENTRIES = 200;
+
+const PRIVATE_IP_RE =
+ /^(127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|::1$|fd[0-9a-f]{2}:)/i;
+
+function isPrivateHost(hostname: string): boolean {
+ return PRIVATE_IP_RE.test(hostname) || hostname === "localhost";
+}
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const raw = new URL(request.url).searchParams.get("url");
+ if (!raw) return new Response("Missing url", { status: 400 });
+
+ let parsed: URL;
+ try {
+ parsed = new URL(raw);
+ } catch {
+ return new Response("Invalid url", { status: 400 });
+ }
+
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
+ return new Response("Invalid protocol", { status: 400 });
+ }
+ if (isPrivateHost(parsed.hostname)) {
+ return new Response("Forbidden", { status: 403 });
+ }
+
+ const now = Date.now();
+ const cached = cache.get(raw);
+ if (cached && cached.expiresAt > now) {
+ return new Response(cached.data.slice(0), {
+ headers: {
+ "Content-Type": cached.contentType,
+ "Cache-Control": "public, max-age=86400",
+ },
+ });
+ }
+
+ let res: Response;
+ try {
+ res = await fetch(raw, {
+ headers: { "User-Agent": "Tokyo-Livehouse-Events/1.0" },
+ signal: AbortSignal.timeout(10_000),
+ });
+ } catch {
+ return new Response("Upstream unreachable", { status: 502 });
+ }
+
+ if (!res.ok) return new Response("Upstream error", { status: 502 });
+
+ const contentType = res.headers.get("content-type") ?? "";
+ if (!contentType.startsWith("image/")) {
+ return new Response("Not an image", { status: 400 });
+ }
+
+ const data = await res.arrayBuffer();
+
+ if (cache.size >= MAX_CACHE_ENTRIES) {
+ cache.delete(cache.keys().next().value!);
+ }
+ cache.set(raw, { data, contentType, expiresAt: now + CACHE_TTL_MS });
+
+ return new Response(data.slice(0), {
+ headers: {
+ "Content-Type": contentType,
+ "Cache-Control": "public, max-age=86400",
+ },
+ });
+}