images: harden the cache per Codex audit (SSRF-safe, cache-only endpoint, WebP-only)
Blocker fixes for the image cache:
- /api/img/{id} now serves cache HITS ONLY and is restricted to ACCEPTED, CANONICAL
articles. It never fetches — the cycle (newsimg.warm) owns all fetching — so the
public endpoint has no SSRF/worker-exhaustion surface. Dropped 1-year immutable
caching (image_url can change) → public, max-age=86400.
- newsimg._safe_fetch: SSRF-safe (reuses enrich._host_is_public + _NoRedirect, http(s)
only, every redirect hop re-validated, body capped). _FetchError distinguishes
permanent refusals (negative-cached via a .fail marker) from transient errors (retry).
- _encode re-encodes only decoded RASTER images to WebP and REJECTS everything else
(SVG, undecodable, decompression bombs via MAX_IMAGE_PIXELS, pathological dimensions);
originals are never retained. prune() also sweeps stale .fail markers.
- Concurrency: fetching only runs inside the cycle lock; writes stay atomic.
Smaller fixes:
- share.py visible image has onerror→this.remove() (degrade to the text unfurl, no
broken icon when an image isn't cached yet).
- share-page Back follows history only on a SAME-ORIGIN referrer (never bounce to an
external site); menu now honors Escape + resets crossing back to desktop (HubBar parity).
Tests: private host, redirect-to-private, hostile SVG/non-image, transient-vs-permanent
failure, LRU prune, warm (accepted+canonical only, idempotent), cache-only endpoint
(404 on not-cached/unaccepted/duplicate, never fetches), share chrome parity. 441 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+15
-13
@@ -2339,22 +2339,24 @@ def create_app() -> FastAPI:
|
||||
|
||||
@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)."""
|
||||
"""Serve the locally-cached, downscaled WebP for an accepted, canonical article.
|
||||
Cache HITS ONLY — this never fetches (the cycle owns all fetching via newsimg.warm),
|
||||
so the public endpoint has no SSRF/worker-exhaustion surface. A miss 404s; the
|
||||
frontend handles that (retry, then the typographic cover)."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT image_url FROM articles WHERE id = ?", (article_id,)).fetchone()
|
||||
row = conn.execute(
|
||||
"SELECT a.image_url FROM articles a JOIN article_scores s ON s.article_id = a.id "
|
||||
"WHERE a.id = ? AND s.accepted = 1 AND a.duplicate_of IS NULL",
|
||||
(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)
|
||||
path = newsimg.path_for(url) if url else None
|
||||
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"})
|
||||
raise HTTPException(status_code=404, detail="image not cached")
|
||||
# NOT immutable: a re-enriched article can change image_url, so the bytes behind a
|
||||
# given id can change. A day's browser cache is plenty (we're direct-origin, no CDN).
|
||||
return FileResponse(str(path), media_type="image/webp",
|
||||
headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
@app.api_route("/api/art/image/{object_id}", methods=["GET", "HEAD"])
|
||||
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
|
||||
|
||||
Reference in New Issue
Block a user