Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions tests/test_static_url.py
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions web/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
33 changes: 33 additions & 0 deletions web/static_assets.py
Original file line number Diff line number Diff line change
@@ -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/<path>?v=<content hash>`` 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]}"
4 changes: 2 additions & 2 deletions web/templates/auth/link.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Link Account - PlexCache</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/vendor/lucide.min.js"></script>
<link rel="stylesheet" href="/static/css/plex-theme.css">
<script src="{{ static_url('js/vendor/lucide.min.js') }}"></script>
<link rel="stylesheet" href="{{ static_url('css/plex-theme.css') }}">
<style>
body {
margin: 0;
Expand Down
4 changes: 2 additions & 2 deletions web/templates/auth/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In - PlexCache-D</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
<script src="/static/js/vendor/lucide.min.js"></script>
<link rel="stylesheet" href="/static/css/plex-theme.css">
<script src="{{ static_url('js/vendor/lucide.min.js') }}"></script>
<link rel="stylesheet" href="{{ static_url('css/plex-theme.css') }}">
<style>
body {
margin: 0;
Expand Down
12 changes: 6 additions & 6 deletions web/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">

<!-- Lucide Icons (vendored) -->
<script src="/static/js/vendor/lucide.min.js"></script>
<script src="{{ static_url('js/vendor/lucide.min.js') }}"></script>

<!-- Custom Plex Theme -->
<link rel="stylesheet" href="/static/css/plex-theme.css">
<link rel="stylesheet" href="/static/css/custom.css">
<link rel="stylesheet" href="{{ static_url('css/plex-theme.css') }}">
<link rel="stylesheet" href="{{ static_url('css/custom.css') }}">

<!-- HTMX (vendored) -->
<script src="/static/js/vendor/htmx.min.js"></script>
<script src="/static/js/vendor/htmx-ws.js"></script>
<script src="{{ static_url('js/vendor/htmx.min.js') }}"></script>
<script src="{{ static_url('js/vendor/htmx-ws.js') }}"></script>

<!-- Apply saved theme and sidebar state immediately to prevent flash -->
<script>
Expand Down Expand Up @@ -164,7 +164,7 @@
</div>

<!-- Custom JavaScript -->
<script src="/static/js/app.js"></script>
<script src="{{ static_url('js/app.js') }}"></script>
<script>
// Initialize Lucide icons
lucide.createIcons();
Expand Down
2 changes: 1 addition & 1 deletion web/templates/settings/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,5 @@ <h1>Settings</h1>
</div>
{% endblock %}

<script src="/static/js/settings_search.js" defer></script>
<script src="{{ static_url('js/settings_search.js') }}" defer></script>
{% endblock %}
2 changes: 1 addition & 1 deletion web/templates/settings/libraries.html
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ <h3>Remove path mapping</h3>
</div>
</div>

<script src="/static/js/path-browser.js"></script>
<script src="{{ static_url('js/path-browser.js') }}"></script>
<script>
function validatePathInput(input) {
var target = document.querySelector(input.dataset.validateTarget);
Expand Down
2 changes: 1 addition & 1 deletion web/templates/settings/users.html
Original file line number Diff line number Diff line change
Expand Up @@ -202,5 +202,5 @@ <h2>Remote User Watchlists</h2>
}
};
</script>
<script src="/static/js/path-browser.js"></script>
<script src="{{ static_url('js/path-browser.js') }}"></script>
{% endblock %}
6 changes: 3 additions & 3 deletions web/templates/setup/base_setup.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">

<!-- Lucide Icons (vendored) -->
<script src="/static/js/vendor/lucide.min.js"></script>
<script src="{{ static_url('js/vendor/lucide.min.js') }}"></script>

<!-- Custom Plex Theme -->
<link rel="stylesheet" href="/static/css/plex-theme.css">
<link rel="stylesheet" href="{{ static_url('css/plex-theme.css') }}">

<!-- HTMX (vendored) -->
<script src="/static/js/vendor/htmx.min.js"></script>
<script src="{{ static_url('js/vendor/htmx.min.js') }}"></script>

<style>
/* Setup wizard specific styles - Plex themed */
Expand Down
2 changes: 1 addition & 1 deletion web/templates/setup/step3.html
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ <h2>Libraries & Paths</h2>
{% endblock %}

{% block scripts %}
<script src="/static/js/path-browser.js"></script>
<script src="{{ static_url('js/path-browser.js') }}"></script>
<script>
let mappingCount = {{ path_mappings | length if path_mappings else 0 }};
const isDocker = {{ 'true' if is_docker else 'false' }};
Expand Down
Loading