Serve article images locally: /images proxy route with SSRF-guarded

upstream fetch, magic-byte validation, persistent /data/media cache,
immutable cache headers, CoverImage fallback component, og:image to
absolute proxy URL. Ported from technews 6f08da3.
This commit is contained in:
2026-08-18 18:11:44 -04:00
parent 92373aa3c6
commit 86a77b4907
11 changed files with 575 additions and 12 deletions
+7 -4
View File
@@ -4,7 +4,8 @@ import { notFound } from 'next/navigation';
import { getArticleFull, getRelatedArticles } from '@/lib/queries';
import RelatedArticles from '@/components/RelatedArticles';
import { InArticleAd, SidebarAd } from '@/components/ads/placements';
import { categoryCover, resolveCoverImage } from '@/lib/cover';
import { categoryCover, ogImageUrl, resolveCoverImage } from '@/lib/cover';
import { CoverImage } from '@/components/CoverImage';
import { env } from '@/lib/env';
import { formatFull } from '@/lib/format';
import { safeParse } from '@/lib/llm/parse';
@@ -58,7 +59,7 @@ export async function generateMetadata({
section: sectionLabel,
tags: a.tags ? safeParse<string[]>(a.tags).slice(0, 5) : undefined,
images: [{
url: (a.image && a.image.trim()) || categoryCover(a.category),
url: ogImageUrl(a.image, a.category),
width: 1200,
height: 630,
alt: title,
@@ -68,7 +69,7 @@ export async function generateMetadata({
card: 'summary_large_image',
title,
description,
images: [(a.image && a.image.trim()) || categoryCover(a.category)],
images: [ogImageUrl(a.image, a.category)],
},
};
}
@@ -106,9 +107,11 @@ export default async function ArticlePage({
</h1>
</header>
<img
<CoverImage
src={resolveCoverImage(a.image, a.category)}
category={a.category}
alt={title}
loading="eager"
className="mt-6 aspect-[16/8] w-full rounded-2xl object-cover"
/>
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest } from 'next/server';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import {
MAX_PROXY_BYTES,
MEDIA_DIR,
cacheImage,
fetchUpstreamImage,
mediaKey,
sniffImage,
} from '@/lib/images';
import { isSafeProxyTarget } from '@/lib/image-mapping';
export const dynamic = 'force-dynamic';
const MAX_AGE_1Y = 'public, max-age=31536000, immutable';
function imageResponse(
buf: Buffer,
type: string,
headers?: Record<string, string>,
): Response {
return new Response(new Uint8Array(buf), {
headers: {
'content-type': type,
'content-length': String(buf.byteLength),
'cache-control': MAX_AGE_1Y,
// Browsers sometimes apply hotlink-style referer checks to <img>;
// our own copies are always allowed from anywhere.
'access-control-allow-origin': '*',
vary: 'accept-encoding, referer',
...headers,
},
});
}
/**
* Server-side image proxy + local cache.
*
* GET /images?url=<absolute https:// publisher image url>
*
* 1. If a local copy already exists under /data/media, serve it (no
* publisher round-trip — the whole point of the setup).
* 2. Otherwise fetch the publisher URL server-side with browser-like
* headers, verify the bytes are a real raster image, persist the copy,
* and serve it.
*
* Everything is verified (magic bytes, 5 MiB cap, SSRF guard on the
* target AND the final redirect host), so a failing or guarding upstream
* yields a short-cached 404/502 — never a broken or spoofed image — and
* the UI falls back to the category cover via the CoverImage onerror.
*/
export async function GET(req: NextRequest): Promise<Response> {
const url = req.nextUrl.searchParams.get('url') ?? '';
const t0 = Date.now();
if (!url || !isSafeProxyTarget(url)) {
return Response.json({ error: 'invalid image url' }, { status: 400 });
}
const name = mediaKey(url);
const file = path.join(MEDIA_DIR, name);
// 1) Local hit — serve the permanent copy.
try {
const buf = await readFile(file);
const type = sniffImage(buf) ?? 'application/octet-stream';
return imageResponse(buf, type, { 'x-image-cache': 'hit' });
} catch {
// miss → fall through to upstream fetch
}
// 2) Upstream fetch (browser UA defeats referrer/hotlink guards).
const got = await fetchUpstreamImage(url);
if (!got.ok) {
console.warn(
`[images] upstream miss ${got.status} (${got.reason}) after ${Date.now() - t0}ms: ${url.slice(0, 120)}`,
);
// Short cache on failure so we re-probe periodically, but don't hammer.
return Response.json(
{ error: 'image unavailable' },
{ status: got.status, headers: { 'cache-control': 'public, max-age=300' } },
);
}
// Await the write so a racing second request cannot double-fetch:
// the first request is the only one that pays the upstream cost.
await cacheImage(url, got.buf);
console.log(
`[images] cached ${Math.round(got.buf.byteLength / 1024)} KiB from ${new URL(url).hostname} in ${Date.now() - t0}ms`,
);
return imageResponse(got.buf, got.type, { 'x-image-cache': 'miss' });
}
+4 -2
View File
@@ -1,6 +1,7 @@
import { getPublishedArticles, getTagList } from '@/lib/queries';
import ArticleCard from '@/components/ArticleCard';
import { resolveCoverImage } from '@/lib/cover';
import { CoverImage } from '@/components/CoverImage';
import { InFeedAd, SidebarAd } from '@/components/ads/placements';
import Link from 'next/link';
@@ -33,10 +34,11 @@ export default async function HomePage({
className="group mb-8 block overflow-hidden rounded-2xl border border-ink-200 bg-white shadow-sm"
>
<div className="relative aspect-[16/8] overflow-hidden bg-ink-100">
{/* cover.ts falls back to a per-category brand cover when no photo was captured */}
<img
<CoverImage
src={resolveCoverImage(hero.image, hero.category)}
category={hero.category}
alt=""
loading="eager"
className="h-full w-full object-cover transition duration-500 group-hover:scale-[1.02]"
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink-950/95 via-ink-950/70 to-transparent px-4 pb-4 pt-10 sm:px-5 sm:pb-5 sm:pt-16">