images: cache + serve article images from our own origin (bounded, LRU-evicted)

Stop hotlinking news images from third-party CDNs (the source of the "blank until
you refresh a few times" graphic). New goodnews/newsimg.py caches a downscaled WebP
display copy (≤800px) beside the DB, like art_cache:
- GET/HEAD /api/img/{article_id} — resolves id→image_url (allowlisted to our corpus,
  not an open proxy), fetch+cache on first miss, serve local after, immutable headers.
- cycle warms display copies for recent accepted-with-image articles (so the FIRST
  view is already local) and prunes to a hard size cap (default 1 GB) by LRU eviction.
Frontend now points at /api/img/<id>: the hub lead, every ArticleCard (feed hero +
cards), and the /a/<id> share page's visible image. og:image/twitter:image stay the
source URL so social crawlers fetch the canonical image directly.

Storage is bounded by construction — over the cap, least-recently-used files are
evicted, so it can't grow without limit regardless of ingest rate. Tests cover
fetch/downscale, cache-hit (no refetch), bad-scheme/non-image rejection, fetch
failure, LRU prune, warm, and the endpoint allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-29 20:28:33 -04:00
parent cb06d550bd
commit 8a3c00db3b
7 changed files with 329 additions and 5 deletions
+4 -1
View File
@@ -51,8 +51,11 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
_tag("twitter:image", image, attr="name"),
]))
# The visible image is served from our cached/downscaled copy (not a hotlink), so a
# flaky source CDN can't blank it. og:image/twitter:image above stay the source URL
# so social crawlers fetch the canonical image directly.
media = (
f'<img class="media" src="{escape(image)}" alt="" referrerpolicy="no-referrer">'
f'<img class="media" src="/api/img/{aid}" alt="" referrerpolicy="no-referrer">'
if image else ""
)