diff --git a/soapbox/app.py b/soapbox/app.py index cdbe15a..3a156e5 100644 --- a/soapbox/app.py +++ b/soapbox/app.py @@ -109,8 +109,9 @@ def _serve_public(cfg: Config, path: str): return redirect(request.path + "/", code=301) path = f"{path}index.html" if path else "index.html" resp = send_from_directory(root, path) - if path.startswith("theme/"): - # URL-en inneholder innholdshash (?v=), så filene kan caches lenge + if path.startswith("theme/") and request.args.get("v"): + # URL-en inneholder innholdshash (?v=), så filen kan caches «for alltid». + # Uten hash (f.eks. url("favicon.svg") inne i CSS) gjelder vanlig cache. resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" elif "." in os.path.basename(path) and not path.endswith(".html"): resp.headers["Cache-Control"] = "public, max-age=3600" diff --git a/soapbox/build.py b/soapbox/build.py index 8f814d3..d170549 100644 --- a/soapbox/build.py +++ b/soapbox/build.py @@ -5,9 +5,10 @@ Utdata er rene filer som kan serveres av hva som helst – appen selv, Caddy eller en CDN. Alle lenker i genererte sider er relative. Malene får en ``rel()``-funksjon -som regner ut stien fra *denne* siden til et mål, så ``rel("theme/style.css")`` -blir ``theme/style.css`` på forsiden og ``../../theme/style.css`` i et -gjesteinnlegg. Dermed kan hele ``public/`` flyttes til hvilken som helst +som regner ut stien fra *denne* siden til et mål, så ``rel("post2/")`` blir +``post2/`` på forsiden og ``../../post2/`` i et gjesteinnlegg. Temafiler +refereres med ``asset("theme/style.css")``, som i tillegg legger på en +innholdshash (``?v=…``) for cache-busting. Dermed kan hele ``public/`` flyttes til hvilken som helst sub-path uten rebuild. Unntaket er ``feed.xml``, der RSS krever absolutte URL-er; den bruker ``SOAPBOX_SITE_URL``. """ @@ -71,22 +72,24 @@ def build_site(cfg: Config) -> int: for d in reversed(cfg.theme_dirs): if (d / "static").is_dir(): shutil.copytree(d / "static", tmp / "theme", dirs_exist_ok=True) - versions = ( - { - f"theme/{f.relative_to(tmp / 'theme').as_posix()}": hashlib.sha256( - f.read_bytes() - ).hexdigest()[:8] - for f in (tmp / "theme").rglob("*") - if f.is_file() - } - if (tmp / "theme").is_dir() - else {} - ) + versions = { + f"theme/{f.relative_to(tmp / 'theme').as_posix()}": hashlib.sha256( + f.read_bytes() + ).hexdigest()[:8] + for f in (tmp / "theme").rglob("*") + if f.is_file() + } def render(template: str, page_path: str, **ctx) -> str: + def rel(target: str) -> str: + return relative_to(page_path, target) + def asset(target: str) -> str: - url = relative_to(page_path, target) - return f"{url}?v={versions[target]}" if target in versions else url + # Feil raskt: en skrivefeil i temaet skal synes ved lagring, ikke + # ende som en 404 som er cachet i et år. + if target not in versions: + raise ValueError(f"asset(): fant ikke temafilen {target!r}") + return f"{rel(target)}?v={versions[target]}" return env.get_template(template).render( site=site, @@ -94,7 +97,7 @@ def build_site(cfg: Config) -> int: source_url=SOURCE_URL, site_url=cfg.site_url, # absolutt; kun for Open Graph/canonical page_path=page_path, - rel=lambda target: relative_to(page_path, target), + rel=rel, now=datetime.now(UTC), **ctx, ) diff --git a/tests/test_app.py b/tests/test_app.py index ad1ea11..74562b3 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -65,6 +65,8 @@ def test_full_flow_owner(client, cfg): assert re.search(r'href="\.\./theme/favicon\.svg\?v=[0-9a-f]{8}"', html) r = client.get(f"/theme/style.css?v={m.group(1)}") assert r.status_code == 200 and "immutable" in r.headers["Cache-Control"] + # uten hash (f.eks. url("favicon.svg") fra CSS) skal cachen være kort + assert "immutable" not in client.get("/theme/favicon.svg").headers["Cache-Control"] assert 'href="../"' in html # intern lenke omskrevet til relativ assert client.get("/grønt-er-skjønt/mitt-bilde.png").status_code == 200 front = client.get("/").text @@ -108,7 +110,7 @@ def test_guest_flow_and_isolation(client, cfg): assert (cfg.content_dir / "guests/gjest1/post-1/index.md").exists() html = client.get("/gjest1/post-1/").text - assert re.search(r'href="\.\./\.\./theme/style\.css\?v=', html) + assert re.search(r'href="\.\./\.\./theme/style\.css\?v=[0-9a-f]{8}"', html) assert "Gjest En" in html assert "Post 1" in client.get("/gjest1/").text assert "Post 1" in client.get("/").text # gjesteinnlegg vises også på forsiden @@ -149,7 +151,7 @@ def test_subpath_mount(tmp_path): r = client.post("/blog/admin/new", data={"csrf": csrf(client, "/blog")}) assert r.headers["Location"].startswith("/blog/admin/edit/") html = client.get("/blog/").text - assert re.search(r'href="theme/style\.css\?v=', html) + assert re.search(r'href="theme/style\.css\?v=[0-9a-f]{8}"', html) assert re.search(r'href="admin/"', html) assert client.get("/blog/theme/style.css").status_code == 200 @@ -443,3 +445,14 @@ def test_asset_hash_changes_with_content(client, cfg): client.post("/admin/settings", data={"csrf": token, "title": "T", "theme": "green2"}) after = re.search(r"style\.css\?v=([0-9a-f]{8})", client.get("/").text).group(1) assert before != after + + +def test_asset_unknown_file_fails_build(client, cfg): + theme = cfg.content_dir / "themes" / "feil" / "templates" + theme.mkdir(parents=True) + (theme / "index.html").write_text("{{ asset('theme/finnes-ikke.css') }}") + login(client) + import pytest + + with pytest.raises(ValueError, match="finnes-ikke"): + client.post("/admin/settings", data={"csrf": csrf(client), "title": "T", "theme": "feil"})