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
+20 -1
View File
@@ -36,7 +36,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import art, auth, bloom, daily, email_send, feeds, games, oauth_google, onthisday, publishing, queries, quote, readtime, share, sources, summarize, wotd
from . import art, auth, bloom, daily, email_send, feeds, games, newsimg, oauth_google, onthisday, publishing, queries, quote, readtime, share, sources, summarize, wotd
from .localtime import local_today
from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect
@@ -2332,6 +2332,25 @@ def create_app() -> FastAPI:
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
}
@app.api_route("/api/img/{article_id}", methods=["GET", "HEAD"])
def article_image(article_id: int) -> FileResponse:
"""Serve a locally-cached, downscaled copy of an article's image instead of
hotlinking the source. Allowlisted by construction: the id resolves to a URL
already in our corpus (no open proxy). A miss fetches+caches once; a hard
failure 404s, which the frontend handles (retry, then the typo cover)."""
with get_conn() as conn:
row = conn.execute("SELECT image_url FROM articles WHERE id = ?", (article_id,)).fetchone()
url = row["image_url"] if row else None
if not url:
raise HTTPException(status_code=404, detail="no image for this article")
path = newsimg.get_or_fetch(url)
if not path:
raise HTTPException(status_code=404, detail="image unavailable")
media = "image/webp" if path.suffix == ".webp" else None
# Stable per article id (image_url doesn't change) → cache hard at the edge/browser.
return FileResponse(str(path), media_type=media,
headers={"Cache-Control": "public, max-age=31536000, immutable"})
@app.api_route("/api/art/image/{object_id}", methods=["GET", "HEAD"])
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
cdir = art.cache_dir()