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:
+13
-4
@@ -98,13 +98,17 @@ _TOP_BAR_CSS = """
|
||||
"""
|
||||
|
||||
# Burger toggle + signed-in avatar (read from the SPA's localStorage cache, same as HubBar).
|
||||
# Parity with HubBar: Escape closes the menu, and crossing back to desktop width resets it.
|
||||
_TOP_BAR_JS = """<script>
|
||||
(function(){
|
||||
var b=document.querySelector('[data-burger]'), m=document.querySelector('[data-menu]');
|
||||
function close(){ if(m) m.setAttribute('hidden',''); if(b){ b.classList.remove('open'); b.setAttribute('aria-expanded','false'); } }
|
||||
if(b&&m){ b.addEventListener('click',function(){
|
||||
if(m.hasAttribute('hidden')){ m.removeAttribute('hidden'); b.classList.add('open'); b.setAttribute('aria-expanded','true'); }
|
||||
else { m.setAttribute('hidden',''); b.classList.remove('open'); b.setAttribute('aria-expanded','false'); }
|
||||
else { close(); }
|
||||
}); }
|
||||
document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
|
||||
if(window.matchMedia){ var mq=window.matchMedia('(min-width:721px)'); if(mq.addEventListener) mq.addEventListener('change',function(e){ if(e.matches) close(); }); }
|
||||
try{
|
||||
var u=JSON.parse(localStorage.getItem('goodnews:auth_user')||'null');
|
||||
if(u&&u.avatar_url){
|
||||
@@ -117,12 +121,14 @@ _TOP_BAR_JS = """<script>
|
||||
})();
|
||||
</script>"""
|
||||
|
||||
# Single-history Back: return to where you came from in-app, else home (mirrors HubShell).
|
||||
# Single-history Back (mirrors HubShell): go back ONLY when we arrived from our own origin,
|
||||
# else go home — never bounce the reader off to an external referrer.
|
||||
_BACK_JS = """<script>
|
||||
(function(){
|
||||
var b=document.querySelector('[data-back]'); if(!b) return;
|
||||
b.addEventListener('click',function(){
|
||||
if(document.referrer && history.length>1){ history.back(); } else { location.href='/'; }
|
||||
var same=false; try{ same=!!document.referrer && new URL(document.referrer).origin===location.origin; }catch(e){}
|
||||
if(same && history.length>1){ history.back(); } else { location.href='/'; }
|
||||
});
|
||||
})();
|
||||
</script>"""
|
||||
@@ -165,8 +171,11 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
|
||||
# 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.
|
||||
# Served from our cache (/api/img/<id>); if it isn't cached yet / fails, drop the
|
||||
# element so the page degrades to the clean text unfurl rather than a broken icon.
|
||||
media = (
|
||||
f'<img class="media" src="/api/img/{aid}" alt="" referrerpolicy="no-referrer">'
|
||||
f'<img class="media" src="/api/img/{aid}" alt="" referrerpolicy="no-referrer" '
|
||||
f'onerror="this.remove()">'
|
||||
if image else ""
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user