Files
upbeatBytes/goodnews/share.py
T
thejayman77 a55ba185a8 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>
2026-06-30 12:19:57 -04:00

463 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Server-rendered share/landing page for /a/<id>.
A *pointer* page (never a republished article): the story's own title, "why it's
uplifting" note, and image, wrapped with rich OpenGraph/Twitter meta and a
prominent link to the original source. Social scrapers don't run JS, so this is
plain server-rendered HTML. All dynamic values are HTML-escaped.
"""
from __future__ import annotations
from html import escape
def _tag(name: str, content: str | None, attr: str = "property") -> str:
if not content:
return ""
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
# --- Shared top bar -----------------------------------------------------------------
# A static replica of the SPA's HubBar so server-rendered share pages (/a/<id>, the
# digest) carry the SAME toolbar as the rest of the site. These pages can't run the
# Svelte component, so this is kept in sync with frontend/src/lib/components/HubBar.svelte
# (+ HubShell's borderless Back) BY HAND — change both together. `active` highlights a
# section ('' = none, as on an article). The account glyph is the signed-out state;
# _TOP_BAR_JS swaps in the cached avatar for signed-in readers, just like HubBar.
_TOP_NAV = (("/", "Home", "home"), ("/news", "News", "news"),
("/play", "Games", "games"), ("/art", "Art", "art"))
def _nav_links(active: str) -> str:
return "".join(f'<a class="{"on" if k == active else ""}" href="{href}">{label}</a>'
for href, label, k in _TOP_NAV)
def _top_bar_html(active: str = "") -> str:
return (
'<header class="bar">'
'<a class="brand" href="/" aria-label="upbeatBytes home"><img src="/logo.svg" alt="upbeatBytes"></a>'
'<div class="bar-end">'
f'<nav class="nav">{_nav_links(active)}<span class="nav-soon">Entertainment</span></nav>'
'<a class="acct" href="/account" aria-label="Your account">'
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">'
'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg></a>'
'<button class="burger" type="button" aria-label="Menu" aria-expanded="false" data-burger>'
'<span></span><span></span><span></span></button>'
'</div></header>'
f'<div class="menu-wrap" data-menu hidden><nav class="menu">{_nav_links(active)}'
'<span class="menu-soon">Entertainment <em>soon</em></span></nav></div>'
)
def _back_link_html(label: str = "Back") -> str:
return ('<button class="hb-back" type="button" data-back aria-label="Go back">'
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" '
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
f'<path d="M15 18l-6-6 6-6"/></svg>{label}</button>')
# Ported verbatim from HubBar.svelte's <style> (+ HubShell's .back), scoped to the same
# class names so the bar looks pixel-identical to the SPA regardless of page palette.
_TOP_BAR_CSS = """
/* reserve the scrollbar gutter so the bar doesn't jump left-right between pages */
html { scrollbar-gutter: stable; }
@font-face { font-family:'Hanken Grotesk'; src:url('/fonts/hanken-var.woff2') format('woff2'); font-weight:400 700; font-style:normal; font-display:swap; }
header.bar { display:flex; align-items:center; justify-content:space-between; max-width:1180px; width:100%; margin:0 auto; box-sizing:border-box; padding:26px clamp(18px,5vw,44px) 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; background:none; border:none; }
header.bar .brand { display:block; line-height:0; }
header.bar .brand img { height:48px; width:auto; display:block; }
.bar-end { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); }
.nav { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); font-size:16.5px; font-weight:500; }
.nav a { color:#6b6256; text-decoration:none; }
.nav a.on { color:#23201b; }
.nav a:hover { color:#0083ad; }
.nav-soon { color:#b3a890; }
.acct { width:32px; height:32px; border-radius:50%; border:1.5px solid #e6c9a0; background:#FCEFD7; display:flex; align-items:center; justify-content:center; flex:none; text-decoration:none; }
.acct.hasimg { background:none; overflow:hidden; padding:0; }
.acct:hover { background:#fbe6c4; }
.burger { display:none; flex-direction:column; align-items:center; justify-content:center; gap:4px; width:40px; height:40px; border-radius:11px; border:1.5px solid #e6c9a0; background:#FCEFD7; cursor:pointer; padding:0; flex:none; }
.burger:hover { background:#fbe6c4; }
.burger span { width:18px; height:2px; border-radius:2px; background:#7a6a52; transition:transform .2s ease, opacity .15s ease; }
.burger.open span:nth-child(1) { transform:translateY(6px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity:0; }
.burger.open span:nth-child(3) { transform:translateY(-6px) rotate(-45deg); }
.menu-wrap { max-width:1180px; width:100%; margin:10px auto 0; box-sizing:border-box; padding:0 clamp(18px,5vw,44px); font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; }
.menu { display:flex; flex-direction:column; background:#fff; border:1px solid #f2e7d3; border-radius:14px; overflow:hidden; box-shadow:0 14px 34px -20px rgba(60,50,30,.4); }
.menu a, .menu .menu-soon { padding:14px 18px; font-size:16px; font-weight:500; text-decoration:none; color:#6b6256; border-top:1px solid #f3ece0; }
.menu a:first-child { border-top:none; }
.menu a.on { color:#23201b; }
.menu a:hover { background:#FFF9EF; color:#0083ad; }
.menu-soon { display:flex; align-items:center; justify-content:space-between; color:#b3a890; }
.menu-soon em { font-style:normal; font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#c3b69c; }
.hb-back { display:inline-flex; align-items:center; gap:6px; margin:0 0 clamp(14px,3vw,24px); background:none; border:none; cursor:pointer; padding:6px 10px 6px 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; font-size:14px; font-weight:600; color:#6b6256; transition:color .15s ease; }
.hb-back:hover { color:#0083ad; }
.hb-back svg { transition:transform .15s ease; }
.hb-back:hover svg { transform:translateX(-2px); }
@media (max-width:720px) { .nav { display:none; } .burger { display:flex; } }
@media (min-width:721px) { .menu-wrap { display:none !important; } }
"""
# 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 { 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){
var a=document.querySelector('.acct');
if(a){ var img=document.createElement('img'); img.src=u.avatar_url; img.alt=''; img.referrerPolicy='no-referrer';
img.style.cssText='width:32px;height:32px;border-radius:999px;object-fit:cover';
a.classList.add('hasimg'); a.textContent=''; a.appendChild(img); }
}
}catch(e){}
})();
</script>"""
# 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(){
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>"""
def render_share_page(article: dict, base_url: str, summary: str | None = None,
explanation: dict | None = None) -> str:
aid = article["id"]
title = (article.get("title") or "upbeatBytes").strip()
why = (article.get("reason_text") or article.get("description")
or "A calm, constructive story worth your attention.").strip()
source = (article.get("source_name") or "the source").strip()
source_id = article.get("source_id")
# Link the source name into the app's publication feed for that source.
source_html = (
f'<a class="src srclink" href="/news?source={source_id}">{escape(source)}</a>'
if source_id else f'<div class="src">{escape(source)}</div>'
)
src_url = article.get("canonical_url") or base_url
image = article.get("image_url")
page_url = f"{base_url}/a/{aid}"
# With an image: a large-image card. Without: a clean text unfurl (title +
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
# can replace this later.)
twitter_card = "summary_large_image" if image else "summary"
meta = "\n".join(filter(None, [
_tag("og:site_name", "upbeatBytes"),
_tag("og:type", "article"),
_tag("og:title", title),
_tag("og:description", why),
_tag("og:url", page_url),
_tag("og:image", image),
_tag("twitter:card", twitter_card, attr="name"),
_tag("twitter:title", title, attr="name"),
_tag("twitter:description", why, attr="name"),
_tag("twitter:image", image, attr="name"),
]))
# 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'onerror="this.remove()">'
if image else ""
)
raw_tags = (article.get("tags") or "")
chips = "".join(
f'<span class="chip">{escape(t.replace("-", " "))}</span>'
for t in raw_tags.split(",") if t
)
groupings = f'<div class="chips">{chips}</div>' if chips else ""
if summary:
summary_block = f'<p class="summary" id="summary">{escape(summary)}</p>'
else:
pending = (
f"✦ Were putting together a quick summary — in the meantime, "
f"read the full story at {escape(source)} below."
)
summary_block = f'<p class="summary pending" id="summary">{pending}</p>'
# The structured "Why it belongs" editorial section — only when the LLM gave a
# clean three-part read; otherwise the single reason line is the calm fallback.
def _why_row(lbl: str, text: str) -> str:
return f'<div class="why-row"><span class="lbl">{lbl}</span><p>{escape(text)}</p></div>'
if explanation:
why_block = (
'<div class="why" id="why">'
+ _why_row("What happened", explanation["what_happened"])
+ _why_row("Why it matters", explanation["why_matters"])
+ _why_row("Why it belongs here", explanation["why_belongs"])
+ '</div>'
)
else:
why_block = f'<div class="why" id="why"><div class="why-row"><span class="lbl">Why its here</span><p>{escape(why)}</p></div></div>'
# Quietly poll until BOTH the summary and the structured read are cached, then
# swap each in (older summaries get topped up with the section on first view).
poll = ""
if not summary or not explanation:
poll = f"""<script>
(function(){{
var id={aid}, box=document.getElementById('summary'), n=0;
function row(l,t){{
var d=document.createElement('div'); d.className='why-row';
var s=document.createElement('span'); s.className='lbl'; s.textContent=l;
var p=document.createElement('p'); p.textContent=t;
d.appendChild(s); d.appendChild(p); return d;
}}
function go(){{
n++;
fetch('/api/summary/'+id).then(function(r){{return r.json();}}).then(function(d){{
var done=true;
if(d&&d.summary){{ box.textContent=d.summary; box.className='summary'; }} else {{ done=false; }}
if(d&&d.explanation){{
var w=document.getElementById('why');
if(w){{ w.innerHTML=''; w.appendChild(row('What happened',d.explanation.what_happened));
w.appendChild(row('Why it matters',d.explanation.why_matters));
w.appendChild(row('Why it belongs here',d.explanation.why_belongs)); }}
}} else {{ done=false; }}
if(!done && n<12){{ setTimeout(go,2500); }}
}}).catch(function(){{ if(n<12) setTimeout(go,3000); }});
}}
go();
}})();
</script>"""
# Fire a privacy-respecting 'source_click' when the reader heads to the source
# (reuses the SPA's anonymous visitor token from same-origin localStorage).
src_click = f"""<script>
(function(){{
var b=document.querySelector('[data-src-click]'); if(!b) return;
b.addEventListener('click',function(){{
try{{
var v=localStorage.getItem('goodnews:visitor')||'';
var body=JSON.stringify({{kind:'source_click',article_id:{aid},visitor:v}});
if(navigator.sendBeacon) navigator.sendBeacon('/api/events',new Blob([body],{{type:'application/json'}}));
}}catch(e){{}}
}});
}})();
</script>"""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)} · upbeatBytes</title>
<meta name="description" content="{escape(why)}">
<link rel="canonical" href="{escape(page_url)}">
<link rel="icon" href="/favicon.svg">
{meta}
<style>
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
line-height:1.6; }}
{_TOP_BAR_CSS}
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
.media {{ width:100%; height:auto; display:block; max-height:380px; object-fit:cover; }}
.body {{ padding:24px 26px 28px; }}
.src {{ color:var(--muted); font-size:.82rem; text-transform:uppercase; letter-spacing:.08em; }}
a.srclink {{ text-decoration:none; cursor:pointer; border-bottom:1px dotted transparent; }}
a.srclink:hover {{ color:var(--accent-deep); border-bottom-color:var(--line); }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600;
font-size:1.7rem; line-height:1.2; margin:6px 0 14px; }}
.summary {{ font-size:1.05rem; color:var(--ink); margin:0 0 18px; }}
.summary.pending {{ color:var(--muted); font-style:italic; }}
.why {{ color:#3b4754; font-size:.92rem; margin:0 0 22px;
border-left:2px solid var(--accent); padding-left:14px; }}
.why-row {{ margin:0 0 12px; }}
.why-row:last-child {{ margin-bottom:0; }}
.why-row p {{ margin:0; }}
.why .lbl {{ display:block; text-transform:uppercase; letter-spacing:.08em; font-size:.7rem;
color:var(--accent-deep); font-weight:600; margin-bottom:2px; }}
.chips {{ display:flex; flex-wrap:wrap; gap:7px; margin:0 0 22px; }}
.chip {{ background:#e0eef3; color:var(--accent-deep); border-radius:999px; padding:3px 11px;
font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; }}
.actions {{ display:flex; flex-wrap:wrap; gap:12px; align-items:center; }}
.primary {{ background:var(--accent); color:#fff; text-decoration:none; font-weight:600;
padding:12px 20px; border-radius:999px; display:inline-block; }}
.primary:hover {{ background:var(--accent-deep); }}
.secondary {{ color:var(--accent-deep); text-decoration:none; font-size:.92rem;
background:none; border:none; cursor:pointer; font-family:inherit; padding:0; }}
.secondary:hover {{ text-decoration:underline; }}
.note {{ color:var(--muted); font-size:.8rem; margin-top:22px; }}
</style>
</head>
<body>
{_top_bar_html()}
<main class="wrap">
{_back_link_html()}
<article class="card">
{media}
<div class="body">
{source_html}
<h1>{escape(title)}</h1>
{summary_block}
{why_block}
{groupings}
<div class="actions">
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
<button class="secondary" type="button" data-share>Copy link</button>
<a class="secondary" href="/news">Explore upbeatBytes →</a>
</div>
<p class="note">upbeatBytes summarizes in its own words and links to the original publisher — it doesn't host the article.</p>
</div>
</article>
</main>
<script>
(function(){{
try{{
var v=localStorage.getItem('goodnews:visitor');
if(!v){{v=crypto.randomUUID?crypto.randomUUID():String(Math.random()).slice(2)+Date.now();localStorage.setItem('goodnews:visitor',v);}}
function beacon(o){{var b=JSON.stringify(o);if(navigator.sendBeacon)navigator.sendBeacon('/api/events',new Blob([b],{{type:'application/json'}}));}}
beacon({{kind:'summary_viewed',article_id:{aid},visitor:v}});
// This page is server-rendered (outside the Svelte app), so the SPA's daily
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
var t=new Date().toISOString().slice(0,10);
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
}}catch(e){{}}
}})();
</script>
{src_click}
<script>
(function(){{
var s=document.querySelector('[data-share]'); if(!s) return;
s.addEventListener('click',function(){{
var url=location.href;
if(navigator.share){{ navigator.share({{title:document.title,url:url}}).catch(function(){{}}); }}
else if(navigator.clipboard){{ navigator.clipboard.writeText(url).then(function(){{ s.textContent='Copied!'; setTimeout(function(){{s.textContent='Copy link';}},1500); }}); }}
}});
}})();
</script>
{_BACK_JS}
{_TOP_BAR_JS}
{poll}
</body>
</html>"""
def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> str:
"""A shareable, indexable 'today's good news, summarized' page."""
lead_img = next((i.get("image_url") for i in items if i.get("image_url")), None)
intro = "The day's calm, constructive news — summarized in plain English, so you can get the gist and go deeper only if you want."
page_url = f"{base_url}/today"
cards = ""
for it in items:
aid = it["id"]
title = escape((it.get("title") or "").strip())
source = escape((it.get("source_name") or "the source").strip())
src_url = escape(it.get("canonical_url") or base_url)
gist = escape((it.get("summary") or it.get("reason_text") or it.get("description") or "").strip())
cards += (
'<article class="item">'
f'<a class="t" href="/a/{aid}"><h2>{title}</h2></a>'
f'<div class="src">{source}</div>'
f'<p class="gist">{gist}</p>'
f'<div class="links"><a href="/a/{aid}">Read the summary</a>'
f'<a href="{src_url}" rel="noopener">Full story at {source} →</a></div>'
"</article>"
)
meta = "\n".join(filter(None, [
_tag("og:site_name", "upbeatBytes"),
_tag("og:type", "website"),
_tag("og:title", "Today's good news, summarized"),
_tag("og:description", intro),
_tag("og:url", page_url),
_tag("og:image", lead_img),
_tag("twitter:card", "summary_large_image" if lead_img else "summary", attr="name"),
_tag("twitter:title", "Today's good news, summarized", attr="name"),
_tag("twitter:description", intro, attr="name"),
_tag("twitter:image", lead_img, attr="name"),
]))
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Today's good news, summarized · upbeatBytes</title>
<meta name="description" content="{escape(intro)}">
<link rel="canonical" href="{page_url}">
<link rel="icon" href="/favicon.svg">
{meta}
<style>
:root {{ --accent:#0083ad; --accent-deep:#006b8e; --ink:#16263a; --muted:#5d6b78;
--bg:#f7f4ec; --surface:#fffdf8; --line:#e8e3d8; }}
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
{_TOP_BAR_CSS}
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
.lede {{ color:var(--muted); margin:0 0 26px; }}
.item {{ border-top:1px solid var(--line); padding:20px 0; }}
.item .t {{ text-decoration:none; color:var(--ink); }}
.item h2 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:1.3rem; margin:0 0 4px; }}
.item .t:hover h2 {{ color:var(--accent-deep); }}
.item .src {{ color:var(--muted); font-size:.78rem; text-transform:uppercase; letter-spacing:.07em; margin-bottom:8px; }}
.item .gist {{ margin:0 0 10px; }}
.item .links {{ display:flex; flex-wrap:wrap; gap:16px; font-size:.9rem; }}
.item .links a {{ color:var(--accent-deep); text-decoration:none; }}
.item .links a:hover {{ text-decoration:underline; }}
.more {{ margin-top:28px; }}
.more a {{ display:inline-block; background:var(--accent); color:#fff; text-decoration:none;
padding:11px 20px; border-radius:999px; font-weight:600; }}
</style>
</head>
<body>
{_top_bar_html()}
<main class="wrap">
<h1>Today's good news</h1>
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
{cards}
<p class="more"><a href="/news">Browse more on upbeatBytes →</a></p>
</main>
{_TOP_BAR_JS}
</body>
</html>"""
def render_not_found(base_url: str) -> str:
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Story not found · upbeatBytes</title><link rel="icon" href="/favicon.svg">
<style>
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:14px; text-align:center; padding:40px;
background:#f7f4ec; color:#16263a;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; }}
a {{ color:#006b8e; }}
</style></head>
<body>
<img src="/logo.svg" alt="upbeatBytes" style="height:44px">
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
<p style="color:#5d6b78">It may have moved on — the good news refreshes often.</p>
<a href="/">← Back to upbeatBytes</a>
</body></html>"""