diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-17 22:41:55 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-17 22:41:55 +0900 |
| commit | 243d222c35f4607ac13a400e827333f44a0f44c7 (patch) | |
| tree | 82acd276c371e047fdfa1968966fc4c3ea20babb | |
| parent | 5f653a4c9f7c76d461ee4377fb4e025771cff872 (diff) | |
Fix image proxy 400 for application/octet-stream responses
GCS-backed Active Storage (omatsuri.tech, used by rinky.info venues)
returns application/octet-stream instead of image/*. Detect actual image
type from magic bytes (JPEG/PNG/GIF/WebP) and serve with correct
Content-Type. Also add X-Content-Type-Options: nosniff.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | app/routes/api.image.ts | 25 |
1 files changed, 20 insertions, 5 deletions
diff --git a/app/routes/api.image.ts b/app/routes/api.image.ts index 814cd1c..fee6401 100644 --- a/app/routes/api.image.ts +++ b/app/routes/api.image.ts @@ -12,6 +12,19 @@ function isPrivateHost(hostname: string): boolean { return PRIVATE_IP_RE.test(hostname) || hostname === "localhost"; } +function resolveContentType(declared: string, data: ArrayBuffer): string | null { + if (declared.startsWith("image/")) return declared; + if (declared.startsWith("application/octet-stream") || declared === "") { + const b = new Uint8Array(data, 0, 12); + if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg"; + if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png"; + if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return "image/gif"; + if (b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && + b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp"; + } + return null; +} + export async function loader({ request }: LoaderFunctionArgs) { const raw = new URL(request.url).searchParams.get("url"); if (!raw) return new Response("Missing url", { status: 400 }); @@ -54,21 +67,23 @@ export async function loader({ request }: LoaderFunctionArgs) { if (!res.ok) return new Response("Upstream error", { status: 502 }); const contentType = res.headers.get("content-type") ?? ""; - if (!contentType.startsWith("image/")) { + const data = await res.arrayBuffer(); + + const resolvedType = resolveContentType(contentType, data); + if (!resolvedType) { 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 }); + cache.set(raw, { data, contentType: resolvedType, expiresAt: now + CACHE_TTL_MS }); return new Response(data.slice(0), { headers: { - "Content-Type": contentType, + "Content-Type": resolvedType, "Cache-Control": "public, max-age=86400", + "X-Content-Type-Options": "nosniff", }, }); } |
