diff --git a/tests/test_static_url.py b/tests/test_static_url.py new file mode 100644 index 00000000..80cebe3a --- /dev/null +++ b/tests/test_static_url.py @@ -0,0 +1,82 @@ +""" +Versioned static URLs (web.config.static_url). + +Browsers keep /static files across updates, so after an upgrade a new template +could run against the previous release's app.js or custom.css. Every CSS/JS +reference goes through static_url(), which appends a hash of the file's +contents, so the URL changes exactly when the file does. +""" + +import hashlib +import os +import re +from pathlib import Path + +import pytest + +# Import the helper module directly, not web.config: several test modules replace +# web.config in sys.modules with a MagicMock at import time. +import web.static_assets as static_assets +from web.static_assets import static_url + +WEB_DIR = Path(__file__).resolve().parents[1] / "web" +TEMPLATES_DIR = WEB_DIR / "templates" + + +@pytest.fixture +def static_dir(tmp_path, monkeypatch): + monkeypatch.setattr(static_assets, "STATIC_DIR", tmp_path) + monkeypatch.setattr(static_assets, "_static_hashes", {}) + return tmp_path + + +def test_url_carries_content_hash(static_dir): + (static_dir / "js").mkdir() + (static_dir / "js" / "app.js").write_bytes(b"console.log(1);") + expected = hashlib.sha1(b"console.log(1);").hexdigest()[:10] + + assert static_url("js/app.js") == f"/static/js/app.js?v={expected}" + + +def test_url_changes_when_file_changes(static_dir): + css = static_dir / "custom.css" + css.write_bytes(b"body { color: red; }") + before = static_url("custom.css") + + css.write_bytes(b"body { color: blue; }") + st = css.stat() + os.utime(css, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000_000)) + after = static_url("custom.css") + + assert before != after + assert after.startswith("/static/custom.css?v=") + + +def test_unchanged_file_keeps_its_url(static_dir): + (static_dir / "a.css").write_bytes(b"a {}") + assert static_url("a.css") == static_url("a.css") + + +def test_missing_file_falls_back_to_plain_url(static_dir): + assert static_url("js/missing.js") == "/static/js/missing.js" + + +def test_registered_as_template_global(): + config_source = (WEB_DIR / "config.py").read_text(encoding="utf-8") + assert "from web.static_assets import static_url" in config_source + assert 'templates.env.globals["static_url"] = static_url' in config_source + + +def test_real_static_files_resolve(): + for path in ("js/app.js", "css/custom.css", "css/plex-theme.css", "js/vendor/htmx.min.js"): + assert re.fullmatch(rf"/static/{re.escape(path)}\?v=[0-9a-f]{{10}}", static_url(path)), path + + +def test_templates_do_not_hardcode_css_or_js_paths(): + """Every CSS/JS include must go through static_url() so updates bust the browser cache.""" + offenders = [] + for template in TEMPLATES_DIR.rglob("*.html"): + for lineno, line in enumerate(template.read_text(encoding="utf-8").splitlines(), 1): + if re.search(r'(href|src)="/static/[^"]+\.(css|js)"', line): + offenders.append(f"{template.relative_to(TEMPLATES_DIR)}:{lineno}") + assert not offenders, "Use {{ static_url('...') }} for: " + ", ".join(offenders) diff --git a/web/config.py b/web/config.py index f9154756..5dfa5a41 100644 --- a/web/config.py +++ b/web/config.py @@ -43,6 +43,11 @@ templates.env.globals["image_tag"] = IMAGE_TAG +# Versioned /static URLs for CSS/JS (see web/static_assets.py) +from web.static_assets import static_url +templates.env.globals["static_url"] = static_url + + def _parse_tag_label(tag: str) -> str: """Derive a short display label from a Docker image tag. diff --git a/web/static_assets.py b/web/static_assets.py new file mode 100644 index 00000000..9d6654eb --- /dev/null +++ b/web/static_assets.py @@ -0,0 +1,33 @@ +"""Versioned URLs for files under web/static. + +Browsers keep /static files across updates, so a new template could run against +last release's app.js or custom.css. ``static_url()`` appends a hash of the +file's contents, so the URL changes exactly when the file does, on Docker, git +checkouts and dev builds alike. Registered as a template global in web.config. + +Kept free of app imports so tests can load it directly (several test modules +replace web.config with a MagicMock). +""" + +import hashlib +from pathlib import Path + +STATIC_DIR = Path(__file__).parent / "static" + +# path -> (mtime_ns, digest); recomputed only when the file changes on disk +_static_hashes: dict = {} + + +def static_url(path: str) -> str: + """Return ``/static/?v=`` for a file under web/static.""" + file_path = STATIC_DIR / path + try: + mtime = file_path.stat().st_mtime_ns + except OSError: + return f"/static/{path}" + cached = _static_hashes.get(path) + if cached is None or cached[0] != mtime: + digest = hashlib.sha1(file_path.read_bytes()).hexdigest()[:10] + cached = (mtime, digest) + _static_hashes[path] = cached + return f"/static/{path}?v={cached[1]}" diff --git a/web/templates/auth/link.html b/web/templates/auth/link.html index a9774082..f08521aa 100644 --- a/web/templates/auth/link.html +++ b/web/templates/auth/link.html @@ -5,8 +5,8 @@ Link Account - PlexCache - - + +