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:
+20
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user