diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-17 22:27:47 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-17 22:27:47 +0900 |
| commit | 5f653a4c9f7c76d461ee4377fb4e025771cff872 (patch) | |
| tree | 4cb8e8ee191bc082a69c10b99c717df948c01cca | |
| parent | b323ad4824cc62cf54a9bfa4f9f68dda4b783323 (diff) | |
Add image proxy endpoint to avoid mixed content issues
Serve external event images through /api/image?url=<encoded> so HTTP
image URLs from venue sites are fetched server-side and delivered over
HTTPS. Validates Content-Type is image/*, blocks private IPs (SSRF),
and caches responses in memory (24h TTL, 200-entry LRU).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | app/components/EventCard.tsx | 3 | ||||
| -rw-r--r-- | app/lib/image-proxy.ts | 4 | ||||
| -rw-r--r-- | app/routes.ts | 1 | ||||
| -rw-r--r-- | app/routes/api.image.ts | 74 | ||||
| -rw-r--r-- | app/routes/events.$id.tsx | 3 |
5 files changed, 83 insertions, 2 deletions
diff --git a/app/components/EventCard.tsx b/app/components/EventCard.tsx index 6651ff9..b23742d 100644 --- a/app/components/EventCard.tsx +++ b/app/components/EventCard.tsx @@ -1,5 +1,6 @@ import { Link } from "react-router"; import type { Event } from "~/lib/db.server"; +import { proxyImageUrl } from "~/lib/image-proxy"; interface Props { event: Event; @@ -16,7 +17,7 @@ export default function EventCard({ event }: Props) { > {event.image_url ? ( <img - src={event.image_url} + src={proxyImageUrl(event.image_url)!} alt={event.title} className="h-36 w-full object-cover" /> diff --git a/app/lib/image-proxy.ts b/app/lib/image-proxy.ts new file mode 100644 index 0000000..b9af3bf --- /dev/null +++ b/app/lib/image-proxy.ts @@ -0,0 +1,4 @@ +export function proxyImageUrl(url: string | null | undefined): string | null { + if (!url) return null; + return `/api/image?url=${encodeURIComponent(url)}`; +} diff --git a/app/routes.ts b/app/routes.ts index 91757b5..21a950d 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -16,5 +16,6 @@ export default [ route("events/:id/calendar.ics", "routes/api.events.$id.ics.ts"), route("venues", "routes/api.venues.ts"), route("openapi.json", "routes/api.openapi.ts"), + route("image", "routes/api.image.ts"), ]), ] satisfies RouteConfig; 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", + }, + }); +} diff --git a/app/routes/events.$id.tsx b/app/routes/events.$id.tsx index 87c5e2d..87836ed 100644 --- a/app/routes/events.$id.tsx +++ b/app/routes/events.$id.tsx @@ -1,6 +1,7 @@ import { useLoaderData, Link } from "react-router"; import type { Route } from "./+types/events.$id"; import { getEvent } from "~/lib/db.server"; +import { proxyImageUrl } from "~/lib/image-proxy"; export async function loader({ params }: Route.LoaderArgs) { const id = parseInt(params.id, 10); @@ -83,7 +84,7 @@ export default function EventDetail() { <div className="mt-6"> {event.image_url && ( <img - src={event.image_url} + src={proxyImageUrl(event.image_url)!} alt={event.title} className="w-full object-contain rounded-xl mb-6" /> |
