images: fix two fetcher bugs + add source-level image-rights policy (Codex)

Fetcher (the two remaining bugs Codex found):
- Real redirects are now followed. _NoRedirect makes urllib RAISE HTTPError on 3xx, so
  the old status-branch was dead code (mocked tests masked it). Handle 301/302/303/307/308
  HTTPError as redirects (re-validate the destination); classify 4xx≠429 as PERMANENT
  (negative-cached), 429/5xx/network as transient. Real-opener redirect + 404/5xx tests.
- The megapixel ceiling is now enforced: explicit `w*h > _MAX_PIXELS` check BEFORE load()
  (Pillow only warns at MAX_IMAGE_PIXELS). Test with a lowered ceiling.

Image-rights policy (per Codex + owner decision — only cache what's cleared):
- sources.image_policy: 'cache' (re-host a downscaled copy — license/permission/PD only),
  'remote' (hotlink the publisher's image — the conservative DEFAULT), 'none' (no image).
- newsimg.display_url resolves the display URL per policy; applied in Article.from_row so
  feed/brief/history return the right URL, and in share.py (og/twitter still reference the
  publisher's own image, never re-hosted). warm() + /api/img both gated on 'cache'.
- Frontend uses the server-resolved image_url (reverted the hardcoded /api/img); the
  graceful retry covers remote hotlinks too. Admin: per-source image-policy selector +
  POST /api/admin/sources/{id}/image-policy. Default 'remote' → nothing re-hosted until
  a source is explicitly cleared.

445 backend + 36 frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-30 14:01:11 -04:00
parent a55ba185a8
commit 8a7606e20d
10 changed files with 238 additions and 62 deletions
+45 -17
View File
@@ -63,6 +63,23 @@ def _key(url: str) -> str:
return hashlib.sha1(url.encode("utf-8")).hexdigest()
def display_url(article_id: int, image_policy: str | None, raw_url: str | None) -> str | None:
"""The image URL the frontend should use, honoring the SOURCE's image policy:
'cache' → our locally-cached copy (/api/img/<id>) — only for sources we've cleared
to re-host (open license / explicit permission / public-domain).
'remote' → the publisher's own URL (hotlinked + the frontend's graceful retry). The
conservative DEFAULT: we display but never re-host.
'none' → no image (typographic cover).
Returns None when there's no image or the policy is 'none'."""
if not raw_url:
return None
if image_policy == "cache":
return f"/api/img/{article_id}"
if image_policy == "none":
return None
return raw_url # 'remote' (default) — hotlink, never re-hosted
class _FetchError(Exception):
"""permanent=True → negative-cache (won't retry soon); False → transient, retry."""
def __init__(self, msg: str, permanent: bool):
@@ -83,16 +100,21 @@ def _safe_fetch(url: str, timeout: int = 12) -> tuple[bytes, str]:
req = urllib.request.Request(current, headers=_UA)
try:
resp = opener.open(req, timeout=timeout)
except urllib.error.HTTPError as exc:
# _NoRedirect makes urllib RAISE on 3xx (rather than return a response), so
# redirects arrive here. Re-validate the destination on the next loop. 4xx
# (except 429) is a permanent miss → negative-cache; 429/5xx → transient.
if exc.code in (301, 302, 303, 307, 308):
loc = exc.headers.get("Location")
exc.close()
if not loc:
raise _FetchError("redirect without location", permanent=True) from exc
current = urljoin(current, loc)
continue
permanent = 400 <= exc.code < 500 and exc.code != 429
raise _FetchError(f"http {exc.code}", permanent=permanent) from exc
except (urllib.error.URLError, OSError, ValueError) as exc:
raise _FetchError(f"fetch failed: {exc}", permanent=False) from exc
status = getattr(resp, "status", 200) or 200
if status in (301, 302, 303, 307, 308):
loc = resp.headers.get("Location")
resp.close()
if not loc:
raise _FetchError("redirect without location", permanent=True)
current = urljoin(current, loc)
continue
try:
return resp.read(_MAX_FETCH_BYTES + 1), (resp.headers.get("Content-Type") or "")
finally:
@@ -106,11 +128,14 @@ def _encode(data: bytes) -> bytes | None:
dimensions — the caller then REJECTS it (never stores arbitrary bytes)."""
try:
from PIL import Image
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # raise DecompressionBombError past this
im = Image.open(io.BytesIO(data))
im.load() # forces decode → catches truncated/bomb here
if im.width > _MAX_DIM or im.height > _MAX_DIM or im.width < 1 or im.height < 1:
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # backstop; Pillow only WARNS at this, raises ~2x
im = Image.open(io.BytesIO(data)) # lazy: header (size) read without decoding pixels
# Enforce the pixel/dimension ceiling BEFORE load() so a decompression bomb is never
# actually decoded (Pillow's own MAX_IMAGE_PIXELS only warns at the threshold).
if (im.width * im.height > _MAX_PIXELS or im.width > _MAX_DIM or im.height > _MAX_DIM
or im.width < 1 or im.height < 1):
return None
im.load() # decode now (also catches truncated data)
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGBA" if ("A" in im.mode or im.mode == "P") else "RGB")
if im.width > DISPLAY_WIDTH:
@@ -192,13 +217,16 @@ def fetch_and_cache(url: str | None) -> Path | None:
def warm(conn, limit: int = 200) -> int:
"""Pre-fetch display copies for the newest ACCEPTED, CANONICAL articles that have an
image, so the API only ever serves cache hits. Bounded; skips already-cached and
recently-failed URLs. Returns how many it newly cached."""
"""Pre-fetch display copies for the newest ACCEPTED, CANONICAL articles whose SOURCE
is cleared to cache (image_policy='cache'), so the API only ever serves cache hits.
Bounded; skips already-cached and recently-failed URLs. Returns how many it newly
cached. Sources default to 'remote' (hotlink, never re-hosted), so this caches
nothing until a source is explicitly set to 'cache'."""
rows = conn.execute(
"SELECT DISTINCT a.image_url FROM article_scores s JOIN articles a ON a.id = s.article_id "
"WHERE s.accepted=1 AND a.duplicate_of IS NULL AND a.image_url IS NOT NULL "
"AND a.image_url != '' ORDER BY a.id DESC LIMIT ?",
"JOIN sources src ON src.id = a.source_id "
"WHERE s.accepted=1 AND a.duplicate_of IS NULL AND src.image_policy='cache' "
"AND a.image_url IS NOT NULL AND a.image_url != '' ORDER BY a.id DESC LIMIT ?",
(limit,),
).fetchall()
made = 0