Archived
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:
@@ -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"
|
||||
/>
|
||||
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user