summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/components/EventCard.tsx3
-rw-r--r--app/lib/image-proxy.ts4
-rw-r--r--app/routes.ts1
-rw-r--r--app/routes/api.image.ts74
-rw-r--r--app/routes/events.$id.tsx3
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"
/>