Compare commits

..
7 Commits
Author SHA1 Message Date
krisf 1e7e951c91 ingest: suppress Google-news shared placeholder image (googleusercontent) -> null -> category cover fallback
Root cause: Google News RSS items carry no enclosures; pipeline fell back
to og:image from the news.google.com interstitial, which serves ONE shared
Google-branded tile (lh3.googleusercontent.com/J6_coFbo...) for every
article. 601 rows shared that URL (maple 243 / finance 358). New
image-guard blocks *.googleusercontent.com images at ingest; 16/16
regression tests; prod DBs migrated tile->NULL; images rebuilt
technews:10 finance:4 maple-brief:11 and deployed.
2026-08-18 21:30:23 -04:00
krisf 86a77b4907 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.
2026-08-18 18:11:44 -04:00
krisf 92373aa3c6 fix(entrypoint): arm cron worker via self-ping after boot
instrumentationHook is disabled in the standalone build, so node-cron only
arms on POST /api/worker/ping. Without a self-ping, a fresh container or
restart runs zero ingest passes (worker silently never armed) — exactly
what happened on the :8 deploy. Adds the same self-ping block as
technews/finance entrypoints (identical).
2026-08-18 11:22:12 -04:00
krisf 08b9deb3d5 fix(pipeline): dedupe+P2002; add red maple-leaf cover system; replace dead CBC feeds (Google News CN / NatPost Canada); worker dead-letter 2026-08-18 10:24:09 -04:00
krisf 44d3220c4d UI: trim footer boilerplate, header tagline to 'Your Canadian News' 2026-08-16 08:39:24 -04:00
krisf 7a19afb63a llm: forbid source/newsroom attribution in synthesized report 2026-08-16 05:09:56 -04:00
krisf b5a25c1232 ui: remove source attribution + publisher rights notice 2026-08-16 05:01:26 -04:00
24 changed files with 968 additions and 75 deletions
+17
View File
@@ -24,4 +24,21 @@ if [ ! -f "$DB" ]; then
echo "[entrypoint] initialized $DB from seed snapshot"
fi
# Arm the ingest/synthesis worker once the server answers.
# The Next standalone build has instrumentationHook disabled, so node-cron
# only starts when a request reaches /api/worker/ping. Pinging ourselves
# after boot means a fresh container or restart re-arms the worker (and
# triggers the boot pipeline pass) without any external health pinger.
(
APP_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
[ -z "$APP_IP" ] && APP_IP=127.0.0.1
i=0
until curl -sf -o /dev/null --max-time 2 "http://${APP_IP}:${PORT:-3000}/api/worker/ping"; do
i=$((i + 1))
[ "$i" -ge 90 ] && exit 0
sleep 1
done
echo "[entrypoint] armed cron worker via /api/worker/ping"
) >/dev/null 2>&1 &
exec node server.js
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "prisma generate && next build",
"start": "next start",
"typecheck": "tsc --noEmit",
"test": "tsx tests/llm-parse.test.ts",
"test": "tsx tests/llm-parse.test.ts && tsx tests/image-guard.test.ts && tsx tests/image-proxy.test.ts",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="Canada — MapleBrief"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/><stop offset="1.3" stop-color="#45060c"/></linearGradient><pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/></pattern></defs><rect width="1200" height="630" fill="url(#bg)"/><rect width="1200" height="630" fill="url(#grid)"/><g fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"><path d="M880 130L904 196L964 182L934 236L1006 250L948 296L1006 338L934 346L964 402L904 386L880 452L856 386L796 402L826 346L754 338L812 296L754 250L826 236L796 182L856 196Z"/><path d="M880 452V520"/></g><g fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M700 452H1060"/></g><text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">Canada</text><text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="National — MapleBrief"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/><stop offset="1.3" stop-color="#45060c"/></linearGradient><pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/></pattern></defs><rect width="1200" height="630" fill="url(#bg)"/><rect width="1200" height="630" fill="url(#grid)"/><g fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"><path d="M760 440V300M880 440V180M1000 440V300"/><path d="M850 180L880 120L910 180" stroke-width="7"/><path d="M782 300L760 268L738 300M978 300L1000 268L1022 300" stroke-width="7"/><path d="M760 380H1000M820 340H940" stroke-width="7"/></g><g fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M700 440H1060M700 160H1060"/></g><text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">National</text><text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="Top Stories — MapleBrief"><defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/><stop offset="1.3" stop-color="#45060c"/></linearGradient><pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse"><path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/></pattern></defs><rect width="1200" height="630" fill="url(#bg)"/><rect width="1200" height="630" fill="url(#grid)"/><g fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"><circle cx="880" cy="305" r="150"/><ellipse cx="880" cy="305" rx="64" ry="150"/><path d="M736 254H1024M730 356H1030"/></g><g fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"><path d="M700 155H1060M700 455H1060"/></g><text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">Top Stories</text><text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Generate per-category cover art used as the last-resort image fallback
* (baked into /public/covers/*.svg, served statically).
*
* Design language matches the brand system: ink→brazil gradient (#161614 to
* #45060c), maple-500 line art, Source Serif word label. 1200x630 (16:8).
*
* Usage: node scripts/make-covers.mjs (idempotent — overwrites)
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const outDir = join(root, 'public', 'covers');
const BG =
'<defs><linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">' +
'<stop offset="0" stop-color="#161614"/><stop offset="0.55" stop-color="#2b2b28"/>' +
'<stop offset="1.3" stop-color="#45060c"/></linearGradient>' +
'<pattern id="grid" width="48" height="48" patternUnits="userSpaceOnUse">' +
'<path d="M48 0H0V48" fill="none" stroke="#f8402f" stroke-opacity="0.10" stroke-width="1"/>' +
'</pattern></defs>';
const stroke = 'fill="none" stroke="#f8402f" stroke-opacity="0.38" stroke-width="10" stroke-linecap="round" stroke-linejoin="round"';
const faint = 'fill="none" stroke="#f8402f" stroke-opacity="0.14" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"';
/** Simple geometric motif per category, centred at (880, 300). */
const MOTIFS = {
// Parliament Hill: central tower + two flanking towers with connecting wings
national:
`<g ${stroke}>` +
`<path d="M760 440V300M880 440V180M1000 440V300"/>` +
`<path d="M850 180L880 120L910 180" stroke-width="7"/>` +
`<path d="M782 300L760 268L738 300M978 300L1000 268L1022 300" stroke-width="7"/>` +
`<path d="M760 380H1000M820 340H940" stroke-width="7"/>` +
`</g>` +
`<g ${faint}>` +
`<path d="M700 440H1060M700 160H1060"/>` +
`</g>`,
// maple leaf: stem + lobe outline
canada:
`<g ${stroke}>` +
`<path d="M880 130L904 196L964 182L934 236L1006 250L948 296L1006 338L934 346L964 402L904 386L880 452L856 386L796 402L826 346L754 338L812 296L754 250L826 236L796 182L856 196Z"/>` +
`<path d="M880 452V520"/>` +
`</g>` +
`<g ${faint}>` +
`<path d="M700 452H1060"/>` +
`</g>`,
// world globe with meridian (top stories)
'top-stories':
`<g ${stroke}>` +
`<circle cx="880" cy="305" r="150"/>` +
`<ellipse cx="880" cy="305" rx="64" ry="150"/>` +
`<path d="M736 254H1024M730 356H1030"/>` +
`</g>` +
`<g ${faint}>` +
`<path d="M700 155H1060M700 455H1060"/>` +
`</g>`,
};
const LABEL = {
national: 'National',
canada: 'Canada',
'top-stories': 'Top Stories',
};
function cover(category, motif, label) {
void category; // contextual only; filename is the real key
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="${label} — MapleBrief">` +
BG +
`<rect width="1200" height="630" fill="url(#bg)"/>` +
`<rect width="1200" height="630" fill="url(#grid)"/>` +
motif +
`<text x="80" y="556" font-family="Georgia, 'Times New Roman', serif" font-size="44" font-weight="700" fill="#ffd9d4" fill-opacity="0.92">${label}</text>` +
`<text x="80" y="592" font-family="Inter, 'Segoe UI', sans-serif" font-size="18" letter-spacing="4" fill="#f8402f" fill-opacity="0.55">MAPLEBRIEF</text>` +
`</svg>`
);
}
await mkdir(outDir, { recursive: true });
const covers = {
'top-stories.svg': cover('top-stories', MOTIFS['top-stories'], LABEL['top-stories']),
'national.svg': cover('national', MOTIFS.national, LABEL.national),
'canada.svg': cover('canada', MOTIFS.canada, LABEL.canada),
};
for (const [name, svg] of Object.entries(covers)) {
await writeFile(join(outDir, name), svg);
console.log('wrote', name);
}
+13 -11
View File
@@ -4,6 +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, 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';
@@ -56,16 +58,18 @@ export async function generateMetadata({
modifiedTime: a.synthesizedAt?.toISOString(),
section: sectionLabel,
tags: a.tags ? safeParse<string[]>(a.tags).slice(0, 5) : undefined,
images: a.image
? [{ url: a.image, width: 1200, height: 630, alt: title }]
: [{ url: '/og-image.png', width: 1200, height: 630, alt: 'MapleBrief' }],
authors: [a.siteName],
images: [{
url: ogImageUrl(a.image, a.category),
width: 1200,
height: 630,
alt: title,
}],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: a.image ? [a.image] : undefined,
images: [ogImageUrl(a.image, a.category)],
},
};
}
@@ -94,7 +98,6 @@ export default async function ArticlePage({
<span className="rounded bg-maple-600/10 px-2 py-0.5 text-xs font-bold uppercase tracking-wide text-maple-700">
{SECTIONS.find((s) => s.slug === a.category)?.label ?? 'Briefing'}
</span>
<span className="text-ink-500">via {a.siteName}</span>
{a.publishedAt && (
<span className="text-ink-400">· {formatFull(a.publishedAt.toISOString())}</span>
)}
@@ -104,14 +107,13 @@ export default async function ArticlePage({
</h1>
</header>
{a.image && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={a.image}
<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"
/>
)}
{takeaways.length > 0 && (
<section className="mt-8 rounded-xl border-l-4 border-maple-600 bg-maple-50 p-5">
+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' });
}
+6 -12
View File
@@ -1,8 +1,9 @@
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';
import { env } from '@/lib/env';
export const dynamic = 'force-dynamic';
@@ -33,20 +34,13 @@ 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">
{hero.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={hero.image}
<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="flex h-full items-center justify-center bg-masthead-gradient">
<span className="font-display text-2xl font-bold text-white/90">
{env.siteName}
</span>
</div>
)}
<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">
<span className="mb-2 inline-block rounded bg-maple-600 px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider text-white">
Lead briefing
+5 -13
View File
@@ -1,32 +1,24 @@
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 }) {
const href = `/article/${card.slug}`;
const title = card.headline ?? card.title;
const image = resolveCoverImage(card.image, card.category);
return (
<article className="group flex flex-col overflow-hidden rounded-xl border border-ink-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:shadow-md">
<Link
href={href}
className="relative block aspect-[16/9] overflow-hidden bg-ink-100"
>
{card.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={card.image}
<CoverImage
src={image}
alt=""
loading="lazy"
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
/>
) : (
<span className="flex h-full items-center justify-center font-display text-4xl text-ink-300">
{card.siteName.charAt(0)}
</span>
)}
<span className="absolute left-3 top-3 rounded bg-ink-950/80 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white">
{card.siteName}
</span>
</Link>
<div className="flex flex-1 flex-col p-4">
<h3 className="font-display text-lg font-bold leading-snug text-ink-900 group-hover:text-maple-700">
+48
View File
@@ -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);
}}
/>
);
}
+9 -6
View File
@@ -1,5 +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[] }) {
@@ -11,10 +13,12 @@ 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">
{a.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={a.image} alt="" loading="lazy" className="h-full w-full object-cover" />
) : null}
<CoverImage
src={resolveCoverImage(a.image, a.category)}
category={a.category}
alt=""
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0">
<Link
@@ -24,8 +28,7 @@ export default function RelatedArticles({ items }: { items: ArticleCard[] }) {
{a.headline ?? a.title}
</Link>
<span className="mt-1 block text-xs text-ink-500">
{a.siteName}
{a.publishedAt ? ` · ${formatRelativeTime(a.publishedAt)}` : ''}
{a.publishedAt ? formatRelativeTime(a.publishedAt) : ''}
</span>
</div>
</li>
+2 -5
View File
@@ -9,9 +9,7 @@ export default function Footer() {
<div>
<p className="font-display text-lg font-bold text-white">{env.siteName}</p>
<p className="mt-2 text-sm leading-relaxed text-ink-400">
An independent digest of Canadian headlines. Every brief is a
rewritten, in-depth version of the reported story, with full
attribution.
An independent digest of Canadian headlines.
</p>
</div>
<nav aria-label="Footer" className="text-sm">
@@ -38,8 +36,7 @@ export default function Footer() {
</div>
<div className="border-t border-ink-800/70">
<div className="mx-auto max-w-6xl px-4 py-4 text-xs text-ink-500 sm:px-6">
© {year} {env.siteName}. Briefs compiled from public news feeds.
All rights to original content remain with the respective publishers.
© {year} {env.siteName}.
</div>
</div>
</footer>
+1 -1
View File
@@ -21,7 +21,7 @@ export default function Header() {
{env.siteName}
</span>
<span className="text-[10px] uppercase tracking-[0.2em] text-ink-300">
Canada, brief by
Your Canadian News
</span>
</span>
</Link>
+14 -13
View File
@@ -14,23 +14,24 @@ export interface FeedSeed {
export const STARTER_FEEDS: FeedSeed[] = [
{
// NOTE: these cbc-stats URLs are the official CBC feed paths from the
// project spec, but as of 2026-08-15 CBC returns 404 for every public
// RSS endpoint (repeatedly re-verified with multiple UAs). Keep them so
// they light up if CBC restores the feeds; disable in `Feed.enabled`
// or swap URLs in the DB if the 404s bother you (see README → Feeds).
name: 'CBC News — Top Stories',
url: 'https://www.cbc.ca/cbc-stats/rss/rss-topstories.xml',
slug: 'cbc-top-stories',
// Replacement for CBC News — Top Stories: cbc-stats returned 404 for
// every public RSS path (re-verified from egress 2026-08-18). Google
// News Canada hub gives a dense national top-stories stream (100 items).
name: 'Google News — Canada',
url: 'https://news.google.com/rss/search?q=canada%20when:48h&hl=en-CA&gl=CA&ceid=CA:en',
slug: 'gn-canada',
category: 'top-stories',
siteName: 'CBC News',
siteName: 'Google News',
},
{
name: 'CBC News — Canada',
url: 'https://www.cbc.ca/cbc-stats/rss/rss-canada.xml',
slug: 'cbc-canada',
// Replacement for CBC News — Canada (cbc-stats 404, re-verified
// 2026-08-18). National Post Canada section — distinct from the
// National Post News feed below; cross-feed GUIDs dedupe at ingest.
name: 'National Post — Canada',
url: 'https://nationalpost.com/category/canada/feed',
slug: 'national-post-canada',
category: 'canada',
siteName: 'CBC News',
siteName: 'National Post',
},
{
// Live Arc outbound feed (verified 2026-08). The legacy
+51
View File
@@ -0,0 +1,51 @@
/**
* Resolve the display image for an article.
*
* Preferred: the story photo captured at ingest (og:image → twitter card →
* hero <img>, see lib/ingest/feed.ts). Last resort: a deterministic
* per-category brand cover baked into /public/covers/ so that every card,
* article page and social preview always shows a visual related to the
* 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];
export function categoryCover(category?: string | null): string {
const slug = (category ?? '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
if ((COVER_SLUGS as readonly string[]).includes(slug)) {
return `/covers/${slug}.svg`;
}
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 {
const stored = (image ?? '').trim();
if (!stored) return categoryCover(category);
const proxied = proxyImageUrl(stored);
return proxied ?? stored;
}
+99
View File
@@ -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;
}
+154
View File
@@ -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
}
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Image placeholder guard.
*
* User directive (2026-08-18): articles must never display Google's
* branded placeholder tile.
*
* Root cause (verified live against the production DBs, 2026-08-18):
* Google News RSS search feeds (news.google.com/rss/search?q=...) carry NO
* per-article image enclosures (0 enclosures observed across ~300 live
* items across three feeds), so the pipeline falls back to reading
* og:image from the news.google.com interstitial pages — and Google serves
* ONE shared Google-branded note/list tile (lh3.googleusercontent.com/
* J6_coFbogxh...) as og:image for every single interstitial. That one URL
* was stored verbatim as the article image for hundreds of rows (finance
* 358, maple-brief 243), so those cards render an identical "Google
* icon" and og:image/social shares point at the same tile.
*
* In this pipeline the only producer of a *.googleusercontent.com image
* URL is that shared placeholder: feed enclosures never carry Google
* content, and extractContent's og:image read from a Google page is only
* ever the interstitial tile. Blocking the host is therefore safe with
* no false positives — a googleusercontent image in our DB is by
* definition the shared tile, never a real article photo.
*
* Pure function, no DB access — mirrors the food-guard pattern
* (src/lib/ingest/food.ts).
*/
const GOOGLE_CONTENT_HOSTS = /(^|\.)googleusercontent\.com$/i;
export interface PlaceholderMatch {
blocked: boolean;
/** the offending host, for logging */
reason: string;
}
/**
* Decide whether a candidate article image is a shared placeholder tile
* instead of a real article photo.
* @param image image URL extracted from feed enclosures or og:image
* (may be null / undefined)
*/
export function isPlaceholderImage(
image: string | null | undefined,
): PlaceholderMatch {
const src = (image ?? '').trim();
if (!src) return { blocked: false, reason: '' };
let host = '';
try {
host = new URL(src).hostname.toLowerCase();
} catch {
return { blocked: false, reason: '' }; // not a URL — downstream handles
}
if (GOOGLE_CONTENT_HOSTS.test(host)) {
return { blocked: true, reason: host };
}
return { blocked: false, reason: '' };
}
+32 -3
View File
@@ -2,12 +2,14 @@ import { prisma } from '@/lib/db';
import { env } from '@/lib/env';
import { dedupeKeyOf, slugify } from '@/lib/slug';
import { parseFeed, extractContent, type FeedItem } from './feed';
import { isPlaceholderImage } from './image-guard';
export interface IngestResult {
feed: string;
fetched: number;
newArticles: number;
duplicates: number;
imageFiltered: number;
errors: string[];
}
@@ -34,6 +36,7 @@ export async function ingestFeed(
fetched: 0,
newArticles: 0,
duplicates: 0,
imageFiltered: 0,
errors: [],
};
@@ -51,11 +54,15 @@ export async function ingestFeed(
(i) => !i.publishedAt || i.publishedAt >= cutoff,
);
// Build a set of keys already in the DB (bounded to recent window).
// Build a set of keys already in the DB. Deliberately NOT scoped to the
// publishedAt window: items saved without a pubDate (publishedAt=null)
// would otherwise be invisible to every dedupe set and re-collide on the
// @unique(guid) constraint on every pass (P2002 "item errors", fixed
// 2026-08-18). Costs 3 scalar columns, so the full-history scan is cheap.
const existing = await prisma.article.findMany({
where: { publishedAt: { gte: cutoff }, guid: { not: null } },
where: { guid: { not: null } },
select: { guid: true, dedupKey: true, sourceUrl: true },
take: 5000,
take: 20000,
});
const seenGuid = new Set(existing.map((a) => a.guid).filter(Boolean) as string[]);
const seenDedup = new Set(existing.map((a) => a.dedupKey));
@@ -93,11 +100,23 @@ export async function ingestFeed(
if (!image && ex.image) image = ex.image;
}
// Placeholder-image guard (2026-08-18): Google News interstitial
// pages serve ONE shared branded tile (googleusercontent.com) as
// og:image for every item. Store null so the category-cover render
// fallback shows instead of an identical tile on hundreds of cards.
const imageGuard = isPlaceholderImage(image);
if (imageGuard.blocked) {
result.imageFiltered += 1;
console.log(`[ingest] placeholder image suppressed (${imageGuard.reason}): ${item.title.slice(0, 70)}`);
image = undefined;
}
const baseSlug = slugify(item.title);
const slug = await uniqueSlug(baseSlug);
const sourceUrl = item.link;
const canonicalUrl = `${env.siteUrl.replace(/\/$/, '')}/article/${slug}`;
try {
await prisma.article.create({
data: {
feedId,
@@ -117,6 +136,16 @@ export async function ingestFeed(
status: 'fetched',
},
});
} catch (err) {
if (/P2002|Unique constraint failed/.test((err as Error).message)) {
// A row claiming this guid/dedupKey/slug was written between the
// pre-scan and the insert (concurrent pass or a pre-window row).
// Count it as a duplicate, not a spurious item error.
result.duplicates += 1;
continue;
}
throw err;
}
result.newArticles += 1;
} catch (err) {
result.errors.push(`item "${item.title?.slice(0, 60)}": ${(err as Error).message}`);
+17 -1
View File
@@ -64,13 +64,29 @@ export async function synthesizePending(limit: number = 24) {
let ok = 0;
let failed = 0;
// Dead-letter: a row that is still `fetched` after 24h of continuous
// failure will never succeed on its own (poison content, oversized
// source, provider rejection). Mark it `failed` so it stops consuming
// a slot in every cron pass and out of the backlog. Manual re-queue by
// resetting status to `fetched` if the cause is later fixed.
const deadAfter = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const a of pending) {
try {
await synthesizeArticle(a.id);
ok += 1;
} catch (err) {
failed += 1;
console.error(`[synth] failed article ${a.slug}:`, (err as Error).message);
const dead = a.createdAt < deadAfter;
const why = (err as Error).message;
if (dead) {
await prisma.article.update({
where: { id: a.id },
data: { status: 'failed' },
});
console.error(`[synth] DEAD-LETTER ${a.slug} (failed >24h): ${why}`);
} else {
console.error(`[synth] failed article ${a.slug}: ${why}`);
}
}
}
return { total: pending.length, ok, failed };
+1
View File
@@ -14,6 +14,7 @@ export const DEFAULT_SYNTHESIS_PROMPT = `You are a Canadian newsroom editor. Tur
Rules:
- Write in clear, neutral English. No hype, no editorializing, no sensationalism.
- Do NOT copy sentences verbatim from the sources. Paraphrase and rewrite; cite only what the sources actually state.
- Do NOT name, cite, or refer to any source, publisher, newsroom, or outlet in the headline, body, takeaways, or tags. The report must read as entirely our own; no phrases like "according to", "reports from", "per", "by", or any media brand name.
- Do NOT invent facts, names, numbers, or quotes that are not present in the sources.
- Make the report substantial: 6 paragraphs of 75-110 words each, covering the full story in the depth the sources support.
- Paragraph 1: the core of the story (who/what/where/when) and why it matters.
+99
View File
@@ -0,0 +1,99 @@
/**
* Placeholder-image guard tests (offline — no network).
*
* Contract under test (src/lib/ingest/image-guard.ts):
* - the ONE shared Google-branded tile observed in production (exact URL
* from the 2026-08-18 finance/maple cleanup, lh3.googleusercontent.com
* /J6_coFbogxh...) is ALWAYS blocked, in every variant (resize suffixes,
* host rotation lh2/lh3/lh4, case, extra query strings)
* - real publisher article photos on googleusercontent-lookalike or normal
* publisher hosts are NEVER blocked (must-pass: false positives here
* would strip legitimate images)
*
* Runner: `tsx tests/image-guard.test.ts` -> exit 1 on failure.
*/
import assert from 'node:assert/strict';
import { isPlaceholderImage } from '@/lib/ingest/image-guard';
let passed = 0;
let failed = 0;
function check(name: string, fn: () => void): void {
try {
fn();
passed += 1;
console.log(`ok ${passed} - ${name}`);
} catch (e) {
failed += 1;
console.error(`not ok ${passed + failed} - ${name}`);
console.error(String((e as Error).stack ?? e));
}
}
// The exact shared tile URL stored on 601 production rows (2026-08-18).
const TILE =
'https://lh3.googleusercontent.com/J6_coFbogxhRI9iM864NL_liGXvsQp2Aups' +
'Kei7z0cNNfDvGUmWUy20nuUhkREQyrpY4bEeIBuc=s0-w300';
check('MUST-BLOCK: exact prod shared tile url', () => {
assert.equal(isPlaceholderImage(TILE).blocked, true);
});
check('MUST-BLOCK: -rw resize variant', () => {
assert.equal(isPlaceholderImage(`${TILE}-rw`).blocked, true);
});
check('MUST-BLOCK: lh4 host rotation', () => {
assert.equal(isPlaceholderImage('https://lh4.googleusercontent.com/xyz123=s900').blocked, true);
});
check('MUST-BLOCK: lh2 host rotation', () => {
assert.equal(isPlaceholderImage('https://lh2.googleusercontent.com/xyz123').blocked, true);
});
check('MUST-BLOCK: uppercase host', () => {
assert.equal(isPlaceholderImage('https://LH3.GOOGLEUSERCONTENT.COM/J6_coFbo=s0').blocked, true);
});
check('MUST-BLOCK: extra query string / fragment', () => {
assert.equal(isPlaceholderImage(TILE + '&dummy=1#f').blocked, true);
});
check('MUST-BLOCK: unknown key (not the known tile) still blocked by host rule', () => {
assert.equal(
isPlaceholderImage('https://lh3.googleusercontent.com/someOtherKeyWq9aB=s64').blocked,
true,
);
});
check('MUST-BLOCK: whitespace-wrapped url', () => {
assert.equal(isPlaceholderImage(` ${TILE} `).blocked, true);
});
check('MUST-PASS: wired photo (publisher CDN)', () => {
assert.equal(
isPlaceholderImage('https://media.wired.com/photos/6a84a2c1/191:100/w_1280/c_limit/x.jpg').blocked,
false,
);
});
check('MUST-PASS: cnbcfm photo (publisher CDN)', () => {
assert.equal(isPlaceholderImage('https://media.cnbcfm.com/i/2026/08/robo.jpg').blocked, false);
});
check('MUST-PASS: google.com logo asset (NOT googleusercontent)', () => {
assert.equal(isPlaceholderImage('https://www.google.com/logos/2026/xx512.png').blocked, false);
});
check('MUST-PASS: example.com path segment merely containing the host string', () => {
assert.equal(
isPlaceholderImage('https://cdn.example.com/googleusercontent.com/img.png').blocked,
false,
);
});
check('MUST-PASS: null', () => {
assert.equal(isPlaceholderImage(null).blocked, false);
});
check('MUST-PASS: undefined', () => {
assert.equal(isPlaceholderImage(undefined).blocked, false);
});
check('MUST-PASS: empty string', () => {
assert.equal(isPlaceholderImage('').blocked, false);
});
check('MUST-PASS: not a URL', () => {
assert.equal(isPlaceholderImage('not a url').blocked, false);
});
console.log(`\nimage-guard: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
+143
View File
@@ -0,0 +1,143 @@
/**
* Image-proxy helper tests (offline — no network).
*
* Contract under test:
* - sniffImage: magic-byte → Content-Type for jpeg/png/gif/webp/avif/heic/bmp,
* null for HTML blockpages and junk (a hotlink guard returning 200 HTML
* must never be served as an image).
* - isSafeProxyTarget: public http(s) only — no loopback, RFC1918, link-local,
* IPv6 private, or non-web schemes; our own site origin rejected.
* - proxyImageUrl: remote http(s) → /images?url=... ; relative / data: /
* our-own-origin URLs pass through untouched.
*
* Runner: `tsx tests/image-proxy.test.ts` → TAP-style, exit 1 on failure.
*/
import assert from 'node:assert/strict';
process.env.NEXT_PUBLIC_SITE_URL = 'https://technews.krisforbes.ca';
import {
isSafeProxyTarget,
proxyImageUrl,
isPrivateHost,
} from '@/lib/image-mapping';
import { mediaKey, sniffImage } from '@/lib/images';
let passed = 0;
let failed = 0;
function check(name: string, fn: () => void): void {
try {
fn();
passed += 1;
console.log(`ok ${passed + failed} ${name}`);
} catch (err) {
failed += 1;
console.log(`not ok ${passed + failed} ${name}`);
console.log(` ${(err as Error).message}`);
}
}
const b = (b64: string) => Buffer.from(b64, 'base64');
// Minimal valid magic headers (payload irrelevant to the sniff).
const JPEG = b(
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoKBwYKDwMNDhgMEggRCwUNDAwTFBMSFBQUFxQVFRUUgAFMAAQHBgMCAwYHBgcKEA0HCAkKDw0NDhERCg0RHREKCA8VEg0RERoNDAwQGiYNDg8VIRUQNBMfISEYGRM0KhwjGhs0MioaIxwkIhgY',
);
const PNG = b('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMYAQCABQAB6V282gAAAABJRU5ErkJggg==');
const WEBP = b('UklGRlQAAABXRUJQVlA4IBoAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vuUAAA=');
const AVIF = Buffer.concat([Buffer.alloc(4), Buffer.from('ftypavif', 'ascii')]);
const GIF = b('R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==');
const HTML = Buffer.from('<!doctype html><html><body><h1>Access Denied</h1><p>The referenced entity does not exist</p>');
check('sniff: jpeg magic → image/jpeg', () => {
assert.equal(sniffImage(JPEG), 'image/jpeg');
});
check('sniff: png magic → image/png', () => {
assert.equal(sniffImage(PNG), 'image/png');
});
check('sniff: webp magic → image/webp', () => {
assert.equal(sniffImage(WEBP), 'image/webp');
});
check('sniff: avif ftyp → image/avif', () => {
assert.equal(sniffImage(AVIF), 'image/avif');
});
check('sniff: gif magic → image/gif', () => {
assert.equal(sniffImage(GIF), 'image/gif');
});
check('sniff: HTML blockpage → null', () => {
assert.equal(sniffImage(HTML), null);
});
check('sniff: short/garbage buffer → null', () => {
assert.equal(sniffImage(Buffer.from('ab')), null);
});
check('ssrf: public https ok', () => {
assert.ok(isSafeProxyTarget('https://ichef.bbci.co.uk/ace/branded_news/x.jpg'));
});
check('ssrf: public http ok', () => {
assert.ok(isSafeProxyTarget('http://example.com/a.webp'));
});
check('ssrf: localhost blocked', () => {
assert.ok(!isSafeProxyTarget('http://localhost:3000/images?url=x'));
});
check('ssrf: 127/10/192.168/169.254 blocked', () => {
assert.ok(!isSafeProxyTarget('http://127.0.0.1/a'));
assert.ok(!isSafeProxyTarget('http://10.0.0.5/a'));
assert.ok(!isSafeProxyTarget('http://192.168.1.2/a'));
assert.ok(!isSafeProxyTarget('http://169.254.169.254/latest'));
});
check('ssrf: 172.16-31 blocked, 172.15/172.32 ok', () => {
assert.ok(!isSafeProxyTarget('http://172.17.0.1:11434/'));
assert.ok(!isSafeProxyTarget('http://172.16.0.9/'));
assert.ok(!isSafeProxyTarget('http://172.31.9.9/'));
assert.ok(isSafeProxyTarget('http://172.15.255.1/')); // below private range
assert.ok(isSafeProxyTarget('http://172.32.0.1/')); // above private range
});
check('ssrf: ipv6 loopback/link-local/ULA blocked, mapped private blocked', () => {
assert.ok(!isSafeProxyTarget('http://[::1]/a'));
assert.ok(!isSafeProxyTarget('http://[fe80::1]/a'));
assert.ok(!isSafeProxyTarget('http://[fd00::1]/a'));
assert.ok(!isSafeProxyTarget('http://[::ffff:127.0.0.1]/a'));
assert.ok(!isSafeProxyTarget('http://[::ffff:192.168.1.2]/a'));
assert.ok(!isSafeProxyTarget('http://[::]/a'));
});
check('ssrf: non-web schemes blocked', () => {
assert.ok(!isSafeProxyTarget('file:///etc/passwd'));
assert.ok(!isSafeProxyTarget('gopher://example.com'));
assert.ok(!isSafeProxyTarget('blob:https://x.com/abc'));
});
check('ssrf: our own origin rejected (no self-proxy)', () => {
assert.ok(!isSafeProxyTarget('https://technews.krisforbes.ca/article/x'));
});
check('ssrf: unparseable url → false', () => {
assert.ok(!isSafeProxyTarget('not a url'));
});
check('proxy: remote url → /images?url=...', () => {
const u = 'https://data-api.investing.com/trkd-images/abc.jpg';
assert.equal(proxyImageUrl(u), `/images?url=${encodeURIComponent(u)}`);
});
check('proxy: relative path passes through', () => {
assert.equal(proxyImageUrl('/covers/tech.svg'), null);
});
check('proxy: data URI passes through', () => {
assert.equal(proxyImageUrl('data:image/png;base64,AAA='), null);
});
check('proxy: own-origin url passes through', () => {
assert.equal(proxyImageUrl('https://technews.krisforbes.ca/logo.png'), null);
});
check('proxy: empty/blank → null', () => {
assert.equal(proxyImageUrl(null), null);
assert.equal(proxyImageUrl(' '), null);
});
check('key: stable + 40 hex chars', () => {
const k1 = mediaKey('https://x.com/a.jpg');
const k2 = mediaKey('https://x.com/a.jpg');
assert.equal(k1, k2);
assert.match(k1, /^[0-9a-f]{40}$/);
assert.notEqual(k1, mediaKey('https://x.com/b.jpg'));
});
const total = passed + failed;
console.log(`\n# tests ${total}, pass ${passed}, fail ${failed}`);
process.exit(failed === 0 ? 0 : 1);