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">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import type { ArticleCard as Card } from '@/lib/queries';
|
||||
import { resolveCoverImage } from '@/lib/cover';
|
||||
import { CoverImage } from '@/components/CoverImage';
|
||||
import { formatRelativeTime } from '@/lib/format';
|
||||
|
||||
export default function ArticleCard({ card }: { card: Card }) {
|
||||
@@ -13,10 +14,9 @@ export default function ArticleCard({ card }: { card: Card }) {
|
||||
href={href}
|
||||
className="relative block aspect-[16/9] overflow-hidden bg-ink-100"
|
||||
>
|
||||
<img
|
||||
<CoverImage
|
||||
src={image}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* CoverImage — the single <img> used for every article photo on the site.
|
||||
*
|
||||
* The `src` here is the result of cover.resolveCoverImage: either a local
|
||||
* category cover (/covers/<slug>.svg) or /images?url=... (the server-side
|
||||
* proxy). If the proxy misses (upstream gone, blocked, or still hot-linked
|
||||
* 403 even with browser headers), the browser fires onerror — swap to the
|
||||
* deterministic category cover so cards and heroes never render a broken
|
||||
* image box.
|
||||
*/
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import { categoryCover } from '@/lib/cover';
|
||||
|
||||
interface CoverImageProps {
|
||||
/** Already-resolved display URL (see lib/cover.ts). */
|
||||
src: string;
|
||||
alt?: string;
|
||||
category?: string | null;
|
||||
className?: string;
|
||||
loading?: 'lazy' | 'eager';
|
||||
}
|
||||
|
||||
export function CoverImage({
|
||||
src,
|
||||
alt = '',
|
||||
category,
|
||||
className,
|
||||
loading = 'lazy',
|
||||
}: CoverImageProps) {
|
||||
const [failed, setFailed] = useState(false);
|
||||
const finalSrc = failed ? categoryCover(category) : src;
|
||||
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return (
|
||||
<img
|
||||
src={finalSrc}
|
||||
alt={alt}
|
||||
loading={loading}
|
||||
className={className}
|
||||
onError={() => {
|
||||
if (!failed) setFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link';
|
||||
import type { ArticleCard } from '@/lib/queries';
|
||||
import { resolveCoverImage } from '@/lib/cover';
|
||||
import { CoverImage } from '@/components/CoverImage';
|
||||
import { formatRelativeTime } from '@/lib/format';
|
||||
|
||||
export default function RelatedArticles({ items }: { items: ArticleCard[] }) {
|
||||
@@ -12,10 +13,10 @@ export default function RelatedArticles({ items }: { items: ArticleCard[] }) {
|
||||
{items.map((a) => (
|
||||
<li key={a.id} className="flex gap-3">
|
||||
<div className="h-16 w-24 flex-shrink-0 overflow-hidden rounded-md bg-ink-100">
|
||||
<img
|
||||
<CoverImage
|
||||
src={resolveCoverImage(a.image, a.category)}
|
||||
category={a.category}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
+20
-1
@@ -8,6 +8,8 @@
|
||||
* piece — never a bare glyph (2026-08-17 "every article has a photo").
|
||||
*/
|
||||
|
||||
import { absSiteUrl, proxyImageUrl } from '@/lib/image-mapping';
|
||||
|
||||
/** Category slugs that have a generated cover asset. */
|
||||
const COVER_SLUGS = ['top-stories', 'canada', 'national'] as const;
|
||||
type CoverSlug = (typeof COVER_SLUGS)[number];
|
||||
@@ -24,9 +26,26 @@ export function categoryCover(category?: string | null): string {
|
||||
return '/covers/top-stories.svg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-image URL for OG/Twitter tags. Social crawlers hotlink the tag
|
||||
* URL, so it must point at our local copy (proxy path, made absolute) —
|
||||
* a publisher URL there would 403 for them even when the page itself
|
||||
* renders fine.
|
||||
*/
|
||||
export function ogImageUrl(
|
||||
image: string | null | undefined,
|
||||
category?: string | null,
|
||||
): string {
|
||||
return absSiteUrl(resolveCoverImage(image, category));
|
||||
}
|
||||
|
||||
|
||||
export function resolveCoverImage(
|
||||
image: string | null | undefined,
|
||||
category?: string | null,
|
||||
): string {
|
||||
return (image ?? '').trim() ? image! : categoryCover(category);
|
||||
const stored = (image ?? '').trim();
|
||||
if (!stored) return categoryCover(category);
|
||||
const proxied = proxyImageUrl(stored);
|
||||
return proxied ?? stored;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Pure (node-free) URL-mapping helpers for the image proxy.
|
||||
*
|
||||
* Kept separate from lib/images.ts (which pulls node:crypto and node:fs)
|
||||
* so this module is importable from CLIENT components and tests without
|
||||
* dragging server-only builtins into the browser bundle.
|
||||
*
|
||||
* See app/images/route.ts for the server-side half of the pipeline.
|
||||
*/
|
||||
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
/** Absolute origin like https://technews.krisforbes.ca (no trailing slash). */
|
||||
export function siteOrigin(): string {
|
||||
return (env.siteUrl || `http://localhost:${process.env.PORT ?? 3000}`).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Absolute URL for OG/Twitter tags (relative display paths → absolute). */
|
||||
export function absSiteUrl(path: string): string {
|
||||
if (/^https?:\/\//i.test(path)) return path;
|
||||
return `${siteOrigin()}${path.startsWith('/') ? '' : '/'}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the stored image URL is remote, return the locally-proxied display
|
||||
* path (`/images?url=...`); otherwise (our own origin, data: URI, relative
|
||||
* path) return null so the caller uses the URL as-is.
|
||||
*/
|
||||
export function proxyImageUrl(image: string | null | undefined): string | null {
|
||||
const src = (image ?? '').trim();
|
||||
if (!src) return null;
|
||||
if (!/^https?:\/\//i.test(src)) return null; // relative or data: — local
|
||||
try {
|
||||
const u = new URL(src);
|
||||
if (u.origin === siteOrigin()) return null; // our own domain — direct
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return `/images?url=${encodeURIComponent(src)}`;
|
||||
}
|
||||
|
||||
const LOOPBACK_HOSTNAME =
|
||||
/^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|::1|\[::\]|0|)$/;
|
||||
const NO_PUBLIC_TLD = /\.(local|internal|home\.arpa|lan)$/i;
|
||||
|
||||
function isPrivateIPv4(h: string): boolean {
|
||||
return (
|
||||
/^(10\.|192\.168\.|169\.254\.|127\.|0\.)/.test(h) ||
|
||||
/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIPv6(h: string): boolean {
|
||||
const s = h.toLowerCase();
|
||||
if (s === '::' || s === '::1') return true; // unspecified / loopback
|
||||
if (/^fe[89ab]/.test(s)) return true; // fe80::/10 link-local
|
||||
if (/^f[cd]/.test(s)) return true; // fc00::/7 unique local
|
||||
if (s.startsWith('::ffff:')) {
|
||||
// IPv4-mapped: ::ffff:a.b.c.d or ::ffff:HHHH:HHHH — retest as IPv4.
|
||||
const tail = s.slice(7);
|
||||
let v4: string | null = null;
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(tail)) {
|
||||
v4 = tail;
|
||||
} else if (/^[0-9a-f]{4}:[0-9a-f]{4}$/.test(tail)) {
|
||||
const [a, b] = tail.split(':').map((x) => parseInt(x, 16));
|
||||
v4 = `${a >> 8}.${a & 0xff}.${b >> 8}.${b & 0xff}`;
|
||||
}
|
||||
return v4 === null ? true : isPrivateIPv4(v4);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True for loopback / private / link-local hosts (IPv4, IPv6, mapped). */
|
||||
export function isPrivateHost(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
return (
|
||||
LOOPBACK_HOSTNAME.test(h) || isPrivateIPv4(h) || isPrivateIPv6(h)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF guard: only plain public http(s) hosts are proxyable. Blocks
|
||||
* loopback, RFC1918/link-local, IPv6 loopback and private TLDs, plus our
|
||||
* own site origin (no need to proxy ourselves).
|
||||
*/
|
||||
export function isSafeProxyTarget(raw: string): boolean {
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
||||
const h = u.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
if (!h || isPrivateHost(h)) return false;
|
||||
if (NO_PUBLIC_TLD.test(h)) return false;
|
||||
if (h === new URL(siteOrigin()).hostname.toLowerCase()) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Server-side image proxy helpers (see app/images/route.ts for the HTTP
|
||||
* side and lib/image-mapping.ts for the pure URL-mapping half).
|
||||
*
|
||||
* Articles store the publisher's own CDN URL in Article.image, and the UI
|
||||
* used to hotlink it directly. Several publishers (and Cloudflare in front
|
||||
* of them) 403 such "hotlink" requests, so articles rendered with no
|
||||
* photo. The UI now renders any remote image through our own
|
||||
* `GET /images?url=...` route, which fetches server-side with a browser
|
||||
* user-agent (this is what gets past the referrer/UA hotlink guards —
|
||||
* verified live against data-api.investing.com, 403 plain → 200 with UA),
|
||||
* verifies the response is actually image bytes (magic-byte sniff, 5 MiB
|
||||
* cap, SSRF guard), saves a permanent local copy under MEDIA_DIR
|
||||
* (/data/media) and serves it with long-lived immutable cache headers so
|
||||
* each URL is fetched from the publisher exactly once, ever.
|
||||
*
|
||||
* SERVER-ONLY module — node:crypto/node:fs. Client code must import
|
||||
* lib/image-mapping instead.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import path from 'node:path';
|
||||
import { mkdir, rename, writeFile } from 'node:fs/promises';
|
||||
|
||||
import { isPrivateHost, siteOrigin } from '@/lib/image-mapping';
|
||||
|
||||
/** Where lazily-downloaded image copies live (volume-backed, app-writable). */
|
||||
export const MEDIA_DIR = process.env.MEDIA_DIR ?? '/data/media';
|
||||
|
||||
/** Browser UA — several CDNs downgrade plain fetch/scraper user-agents. */
|
||||
export const PROXY_USER_AGENT =
|
||||
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36';
|
||||
|
||||
/** Same accept header Chrome sends for <img> — gets the optimized format. */
|
||||
const PROXY_ACCEPT =
|
||||
'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8';
|
||||
|
||||
/** Hard cap for a proxied image body (5 MiB). */
|
||||
export const MAX_PROXY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** Upstream timeout for the proxy fetch. */
|
||||
export const PROXY_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Stable on-disk name for a URL (no extension — content-type is sniffed). */
|
||||
export function mediaKey(url: string): string {
|
||||
return createHash('sha256').update(url).digest('hex').slice(0, 40);
|
||||
}
|
||||
|
||||
/**
|
||||
* Magic-byte sniff → IANA Content-Type, or null when the buffer is not a
|
||||
* known raster image format. (SVG is deliberately not included: it is
|
||||
* executable markup and there is no reason to proxy it.) Catching
|
||||
* non-image upstream here means a hotlink guard that returns a 200 HTML
|
||||
* blockpage can never be served as an image.
|
||||
*/
|
||||
export function sniffImage(buf: Buffer): string | null {
|
||||
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8) return 'image/jpeg';
|
||||
if (
|
||||
buf.length >= 8 &&
|
||||
buf[0] === 0x89 &&
|
||||
buf[1] === 0x50 &&
|
||||
buf[2] === 0x4e &&
|
||||
buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d &&
|
||||
buf[5] === 0x0a
|
||||
) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (buf.length >= 6 && buf.toString('ascii', 0, 3) === 'GIF') return 'image/gif';
|
||||
if (
|
||||
buf.length >= 12 &&
|
||||
buf.toString('ascii', 0, 4) === 'RIFF' &&
|
||||
buf.toString('ascii', 8, 12) === 'WEBP'
|
||||
) {
|
||||
return 'image/webp';
|
||||
}
|
||||
if (buf.length >= 12 && buf.toString('ascii', 4, 8) === 'ftyp') {
|
||||
const brand = buf.toString('ascii', 8, 12).toLowerCase();
|
||||
if (brand === 'avif' || brand === 'avis') return 'image/avif';
|
||||
if (brand === 'heic' || brand === 'heix' || brand === 'mif1') {
|
||||
return 'image/heic';
|
||||
}
|
||||
}
|
||||
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) return 'image/bmp';
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ProxyFetchResult =
|
||||
| { ok: true; buf: Buffer; type: string; upstreamStatus: number }
|
||||
| { ok: false; status: number; reason: string };
|
||||
|
||||
/**
|
||||
* Fetch the upstream image with browser-like headers and verify it is
|
||||
* really image bytes. Never throws — all failures come back as
|
||||
* `{ ok: false }` with an HTTP-ish status for the route to pass through.
|
||||
*/
|
||||
export async function fetchUpstreamImage(url: string): Promise<ProxyFetchResult> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
|
||||
headers: {
|
||||
'user-agent': PROXY_USER_AGENT,
|
||||
accept: PROXY_ACCEPT,
|
||||
'accept-language': 'en-CA,en;q=0.9',
|
||||
referer: `${siteOrigin()}/articles`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status >= 500 ? 502 : res.status, reason: `upstream ${res.status}` };
|
||||
}
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
if (buf.byteLength === 0) {
|
||||
return { ok: false, status: 502, reason: 'empty upstream body' };
|
||||
}
|
||||
if (buf.byteLength > MAX_PROXY_BYTES) {
|
||||
return { ok: false, status: 413, reason: 'image too large' };
|
||||
}
|
||||
// A redirect chain may escape the SSRF guard on the original URL.
|
||||
const finalHost = new URL(res.url).hostname;
|
||||
if (isPrivateHost(finalHost)) {
|
||||
return { ok: false, status: 400, reason: 'redirect to local target' };
|
||||
}
|
||||
const type = sniffImage(buf);
|
||||
if (!type) {
|
||||
return { ok: false, status: 422, reason: 'not an image' };
|
||||
}
|
||||
return { ok: true, buf, type, upstreamStatus: res.status };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (/timeout|aborted/i.test(msg)) {
|
||||
return { ok: false, status: 504, reason: 'upstream timeout' };
|
||||
}
|
||||
return { ok: false, status: 502, reason: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort durable cache: write atomically (tmp + rename) so readers
|
||||
* never observe a partial file. Returns the final path on success.
|
||||
*/
|
||||
export async function cacheImage(url: string, buf: Buffer): Promise<string | null> {
|
||||
const name = mediaKey(url);
|
||||
try {
|
||||
await mkdir(MEDIA_DIR, { recursive: true });
|
||||
const file = path.join(MEDIA_DIR, name);
|
||||
const tmp = `${file}.tmp${process.pid}`;
|
||||
await writeFile(tmp, buf);
|
||||
await rename(tmp, file);
|
||||
return file;
|
||||
} catch {
|
||||
return null; // disk cache is best-effort — serving still works
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user