From 7cfd8fae7e233ae015feafca578382e1af2e082a Mon Sep 17 00:00:00 2001 From: Brandon Haney <121782102+Brandon-Haney@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:16:10 -0500 Subject: [PATCH 1/2] Version static CSS/JS URLs by content hash 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 include now goes through a static_url() template global that appends a hash of the file's contents, so the URL changes exactly when the file does. Works the same for Docker images, git checkouts and dev builds. A test fails if a template references a CSS/JS file under /static directly. --- tests/test_static_url.py | 77 +++++++++++++++++++++++++++ web/config.py | 26 +++++++++ web/templates/auth/link.html | 4 +- web/templates/auth/login.html | 4 +- web/templates/base.html | 12 ++--- web/templates/settings/index.html | 2 +- web/templates/settings/libraries.html | 2 +- web/templates/settings/users.html | 2 +- web/templates/setup/base_setup.html | 6 +-- web/templates/setup/step3.html | 2 +- 10 files changed, 120 insertions(+), 17 deletions(-) create mode 100644 tests/test_static_url.py diff --git a/tests/test_static_url.py b/tests/test_static_url.py new file mode 100644 index 00000000..fbfbddd0 --- /dev/null +++ b/tests/test_static_url.py @@ -0,0 +1,77 @@ +""" +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 web.config as web_config +from web.config import static_url + +TEMPLATES_DIR = Path(web_config.TEMPLATES_DIR) + + +@pytest.fixture +def static_dir(tmp_path, monkeypatch): + monkeypatch.setattr(web_config, "STATIC_DIR", tmp_path) + monkeypatch.setattr(web_config, "_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(): + assert web_config.templates.env.globals["static_url"] is static_url + + +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..fa5150aa 100644 --- a/web/config.py +++ b/web/config.py @@ -43,6 +43,32 @@ templates.env.globals["image_tag"] = IMAGE_TAG +# Versioned static URLs. Browsers keep /static files across updates, so a new +# template could run against last release's app.js or custom.css. The query +# string is a hash of the file's contents, so the URL changes exactly when the +# file does, on Docker, git checkouts and dev builds alike. +_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: + import hashlib + digest = hashlib.sha1(file_path.read_bytes()).hexdigest()[:10] + cached = (mtime, digest) + _static_hashes[path] = cached + return f"/static/{path}?v={cached[1]}" + + +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/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 - - + +