refactor(#2695): centralize WebUI static asset resolution
This commit is contained in:
1 parent
f80e641a9a
commit
624e03c60a
7 files changed
+187
-56
No files matched your search
+11
-1
@@ -7598,7 +7598,17 @@ def get_available_models_for_session_visit() -> dict:
|
||||
|
||||
|
||||
# ── Static file path ─────────────────────────────────────────────────────────
|
||||
_INDEX_HTML_PATH = REPO_ROOT / "static" / "index.html"
|
||||
|
||||
|
||||
def get_static_root() -> Path:
|
||||
return REPO_ROOT / "static"
|
||||
|
||||
|
||||
def get_index_html_path() -> Path:
|
||||
return get_static_root() / "index.html"
|
||||
|
||||
|
||||
_INDEX_HTML_PATH = get_index_html_path()
|
||||
|
||||
# ── Thread synchronisation ───────────────────────────────────────────────────
|
||||
LOCK = threading.Lock()
|
||||
|
||||
+9
-8
@@ -2779,7 +2779,6 @@ from api.config import (
|
||||
STREAM_LAST_EVENT_ID,
|
||||
SERVER_START_TIME,
|
||||
_resolve_cli_toolsets,
|
||||
_INDEX_HTML_PATH,
|
||||
get_available_models,
|
||||
get_available_models_for_session_visit,
|
||||
_provider_is_known_or_configured,
|
||||
@@ -2818,6 +2817,7 @@ from api.config import (
|
||||
_cfg_lock,
|
||||
PENDING_BG_TASK_COMPLETIONS,
|
||||
)
|
||||
from api import config as api_config
|
||||
from api.helpers import (
|
||||
require,
|
||||
bad,
|
||||
@@ -11075,7 +11075,7 @@ def _serve_manifest(handler) -> bool:
|
||||
routes so Firefox Android can fetch the manifest when installing from
|
||||
a /session/<id> page. See #2226.
|
||||
"""
|
||||
static_root = Path(__file__).parent.parent / "static"
|
||||
static_root = api_config.get_static_root()
|
||||
manifest_path = (static_root / "manifest.json").resolve()
|
||||
if manifest_path.exists():
|
||||
data = manifest_path.read_bytes()
|
||||
@@ -11136,8 +11136,9 @@ def _render_index_shell_base() -> str:
|
||||
"""
|
||||
from api.updates import WEBUI_VERSION
|
||||
|
||||
st = _INDEX_HTML_PATH.stat()
|
||||
sig = (st.st_size, st.st_mtime_ns)
|
||||
index_path = api_config.get_index_html_path()
|
||||
st = index_path.stat()
|
||||
sig = (index_path, st.st_size, st.st_mtime_ns)
|
||||
with _INDEX_SHELL_CACHE_LOCK:
|
||||
cached = _INDEX_SHELL_CACHE.get("base")
|
||||
if cached and cached[0] == sig:
|
||||
@@ -11146,7 +11147,7 @@ def _render_index_shell_base() -> str:
|
||||
|
||||
version_token = quote(WEBUI_VERSION, safe="")
|
||||
base = (
|
||||
_INDEX_HTML_PATH.read_text(encoding="utf-8")
|
||||
index_path.read_text(encoding="utf-8")
|
||||
.replace("__WEBUI_VERSION__", version_token)
|
||||
.replace("__MAX_UPLOAD_BYTES__", str(MAX_UPLOAD_BYTES))
|
||||
)
|
||||
@@ -11320,7 +11321,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
return _serve_manifest(handler)
|
||||
|
||||
if parsed.path == "/sw.js":
|
||||
static_root = Path(__file__).parent.parent / "static"
|
||||
static_root = api_config.get_static_root()
|
||||
sw_path = (static_root / "sw.js").resolve()
|
||||
if sw_path.exists():
|
||||
# Inject the current git-derived version as the cache name so the
|
||||
@@ -11343,7 +11344,7 @@ def handle_get(handler, parsed) -> bool:
|
||||
return j(handler, {"error": "not found"}, status=404)
|
||||
|
||||
if parsed.path == "/favicon.ico":
|
||||
static_root = Path(__file__).parent.parent / "static"
|
||||
static_root = api_config.get_static_root()
|
||||
ico_path = (static_root / "favicon.ico").resolve()
|
||||
if ico_path.exists() and ico_path.is_file():
|
||||
data = ico_path.read_bytes()
|
||||
@@ -15438,7 +15439,7 @@ _STATIC_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _serve_static(handler, parsed):
|
||||
static_root = (Path(__file__).parent.parent / "static").resolve()
|
||||
static_root = api_config.get_static_root().resolve()
|
||||
# Strip the leading '/static/' prefix, then resolve and sandbox
|
||||
rel = parsed.path[len("/static/") :]
|
||||
static_file = (static_root / rel).resolve()
|
||||
|
||||
@@ -6,6 +6,7 @@ their normal JSON error behavior; this only pins the shell route contract.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
@@ -36,14 +37,19 @@ class _FakeHandler:
|
||||
|
||||
|
||||
class _BrokenIndexPath:
|
||||
def stat(self):
|
||||
return SimpleNamespace(st_size=1, st_mtime_ns=1)
|
||||
|
||||
def read_text(self, *args, **kwargs):
|
||||
raise RuntimeError("simulated index.html read failure")
|
||||
|
||||
|
||||
def test_home_route_internal_error_returns_html_503_not_json(monkeypatch):
|
||||
from api import config as api_config
|
||||
from api import routes
|
||||
|
||||
monkeypatch.setattr(routes, "_INDEX_HTML_PATH", _BrokenIndexPath())
|
||||
monkeypatch.setattr(api_config, "get_index_html_path", lambda: _BrokenIndexPath())
|
||||
monkeypatch.setattr(routes, "_INDEX_SHELL_CACHE", {})
|
||||
|
||||
handler = _FakeHandler()
|
||||
assert routes.handle_get(handler, urlparse("http://example.com/")) is True
|
||||
|
||||
@@ -17,14 +17,16 @@ import os
|
||||
import time
|
||||
from urllib.parse import quote
|
||||
|
||||
import api.config as api_config
|
||||
import api.routes as routes
|
||||
from api.updates import WEBUI_VERSION
|
||||
|
||||
|
||||
def _old_inline_render(csrf_token: str) -> str:
|
||||
"""Reproduce the pre-cache inline render exactly, for equivalence checks."""
|
||||
index_path = api_config.get_index_html_path()
|
||||
return (
|
||||
routes._INDEX_HTML_PATH.read_text(encoding="utf-8")
|
||||
index_path.read_text(encoding="utf-8")
|
||||
.replace("__WEBUI_VERSION__", quote(WEBUI_VERSION, safe=""))
|
||||
.replace("__MAX_UPLOAD_BYTES__", str(routes.MAX_UPLOAD_BYTES))
|
||||
.replace("__CSRF_TOKEN_JSON__", json.dumps(csrf_token))
|
||||
@@ -69,10 +71,10 @@ def test_second_call_returns_cached_object():
|
||||
|
||||
def test_cache_invalidates_on_mtime_change(tmp_path, monkeypatch):
|
||||
# Point the module at a temp copy so we can mutate its mtime safely.
|
||||
src = routes._INDEX_HTML_PATH.read_text(encoding="utf-8")
|
||||
src = api_config.get_index_html_path().read_text(encoding="utf-8")
|
||||
fake = tmp_path / "index.html"
|
||||
fake.write_text(src, encoding="utf-8")
|
||||
monkeypatch.setattr(routes, "_INDEX_HTML_PATH", fake)
|
||||
monkeypatch.setattr(api_config, "get_index_html_path", lambda: fake)
|
||||
# Reset the shared cache so this test is order-independent.
|
||||
monkeypatch.setattr(routes, "_INDEX_SHELL_CACHE", {})
|
||||
|
||||
@@ -85,3 +87,23 @@ def test_cache_invalidates_on_mtime_change(tmp_path, monkeypatch):
|
||||
sig_after = routes._INDEX_SHELL_CACHE["base"][0]
|
||||
|
||||
assert sig_after != sig_before
|
||||
|
||||
|
||||
def test_cache_invalidates_on_index_path_change(tmp_path, monkeypatch):
|
||||
first = tmp_path / "first.html"
|
||||
second = tmp_path / "second.html"
|
||||
first.write_text("alpha __WEBUI_VERSION__ __MAX_UPLOAD_BYTES__ __CSRF_TOKEN_JSON__", encoding="utf-8")
|
||||
second.write_text("bravo __WEBUI_VERSION__ __MAX_UPLOAD_BYTES__ __CSRF_TOKEN_JSON__", encoding="utf-8")
|
||||
stamp = time.time() + 5
|
||||
os.utime(first, (stamp, stamp))
|
||||
os.utime(second, (stamp, stamp))
|
||||
selected = {"path": first}
|
||||
monkeypatch.setattr(api_config, "get_index_html_path", lambda: selected["path"])
|
||||
monkeypatch.setattr(routes, "_INDEX_SHELL_CACHE", {})
|
||||
|
||||
assert "alpha" in routes._render_index_shell_base()
|
||||
selected["path"] = second
|
||||
rendered = routes._render_index_shell_base()
|
||||
|
||||
assert "bravo" in rendered
|
||||
assert "alpha" not in rendered
|
||||
@@ -6,6 +6,7 @@ import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import api.auth as auth
|
||||
import api.config as api_config
|
||||
import api.routes as routes
|
||||
|
||||
|
||||
@@ -152,7 +153,7 @@ def test_login_route_remains_csrf_exempt(monkeypatch):
|
||||
|
||||
|
||||
def test_index_shell_includes_csrf_fetch_and_sendbeacon_injection():
|
||||
src = (routes._INDEX_HTML_PATH).read_text(encoding="utf-8")
|
||||
src = api_config.get_index_html_path().read_text(encoding="utf-8")
|
||||
|
||||
assert "csrfToken:__CSRF_TOKEN_JSON__" in src
|
||||
assert "X-Hermes-CSRF-Token" in src
|
||||
|
||||
@@ -22,6 +22,8 @@ import gzip
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import api.config as api_config
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
"""Minimal request handler stand-in matching tests/test_session_static_assets.py."""
|
||||
@@ -66,24 +68,6 @@ def _serve(routes, path, query="", request_headers=None):
|
||||
return h
|
||||
|
||||
|
||||
def _patch_static_root(monkeypatch, static_root):
|
||||
"""Force _serve_static to read from a temp directory and clear its cache."""
|
||||
from api import routes
|
||||
monkeypatch.setattr(
|
||||
routes, "_serve_static",
|
||||
lambda handler, parsed, _root=static_root, _orig=routes._serve_static: _orig(handler, parsed),
|
||||
)
|
||||
# Tests redirect by writing files to the real static dir's parent layout
|
||||
# via a fixture; instead we monkeypatch the module-level Path computation.
|
||||
# _serve_static derives static_root from `Path(__file__).parent.parent / "static"`,
|
||||
# so we monkeypatch __file__ via a closure that re-resolves with our temp tree.
|
||||
# Simpler: patch the cache and call the real function with a parsed path that
|
||||
# resolves under the real static dir. We use the fixture below instead.
|
||||
|
||||
|
||||
# ── Fixture: build a tiny isolated static tree and rebind paths ───────────
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@@ -100,30 +84,7 @@ def isolated_static(tmp_path, monkeypatch):
|
||||
|
||||
# Patch the cache so cross-test state cannot leak.
|
||||
monkeypatch.setattr(routes, "_STATIC_CACHE", {}, raising=True)
|
||||
|
||||
# _serve_static derives static_root from Path(__file__).parent.parent.
|
||||
# Rebind by monkeypatching Path resolution: we wrap the function so the
|
||||
# caller-visible signature is unchanged.
|
||||
original = routes._serve_static
|
||||
|
||||
def wrapped(handler, parsed):
|
||||
# Trick: temporarily monkeypatch Path so the function sees our temp tree.
|
||||
import api.routes as ar
|
||||
orig_file = ar.__file__
|
||||
# Place a sentinel api/routes.py "next to" tmp_path so the relative
|
||||
# walk lands in our static_root.
|
||||
fake_api_dir = tmp_path / "api"
|
||||
fake_api_dir.mkdir(exist_ok=True)
|
||||
fake_routes = fake_api_dir / "routes.py"
|
||||
if not fake_routes.exists():
|
||||
fake_routes.write_text("# stub for path resolution\n")
|
||||
monkeypatch.setattr(ar, "__file__", str(fake_routes))
|
||||
try:
|
||||
return original(handler, parsed)
|
||||
finally:
|
||||
monkeypatch.setattr(ar, "__file__", orig_file)
|
||||
|
||||
monkeypatch.setattr(routes, "_serve_static", wrapped)
|
||||
monkeypatch.setattr(api_config, "get_static_root", lambda: static_root)
|
||||
yield static_root
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import quote
|
||||
|
||||
import api.config as api_config
|
||||
import api.routes as routes
|
||||
from api.updates import WEBUI_VERSION
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self, request_headers=None):
|
||||
self.status = None
|
||||
self.sent_headers = []
|
||||
self.body = bytearray()
|
||||
self.headers = dict(request_headers or {})
|
||||
self.wfile = self
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, name, value):
|
||||
self.sent_headers.append((name, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def write(self, data):
|
||||
self.body.extend(data)
|
||||
|
||||
def header(self, name):
|
||||
for key, value in self.sent_headers:
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _get(path, request_headers=None):
|
||||
handler = _FakeHandler(request_headers)
|
||||
routes.handle_get(handler, SimpleNamespace(path=path, query=""))
|
||||
return handler
|
||||
|
||||
|
||||
def test_config_owner_returns_checkout_static_root():
|
||||
static_root = ROOT / "static"
|
||||
assert api_config.get_static_root() == static_root
|
||||
assert api_config.get_index_html_path() == static_root / "index.html"
|
||||
|
||||
|
||||
def test_manifest_routes_follow_selected_static_root(tmp_path, monkeypatch):
|
||||
static_root = tmp_path / "static"
|
||||
static_root.mkdir()
|
||||
manifest_path = static_root / "manifest.json"
|
||||
payload = json.dumps({"name": "temp", "display": "standalone"}).encode("utf-8")
|
||||
manifest_path.write_bytes(payload)
|
||||
monkeypatch.setattr(api_config, "get_static_root", lambda: static_root)
|
||||
|
||||
handler = _get("/manifest.json")
|
||||
assert handler.status == 200
|
||||
assert handler.header("Content-Type") == "application/manifest+json; charset=utf-8"
|
||||
assert handler.header("Cache-Control") == "no-store"
|
||||
assert bytes(handler.body) == payload
|
||||
|
||||
session_handler = _get("/session/manifest.webmanifest")
|
||||
assert session_handler.status == 200
|
||||
assert bytes(session_handler.body) == payload
|
||||
|
||||
|
||||
def test_service_worker_and_favicon_follow_selected_static_root(tmp_path, monkeypatch):
|
||||
static_root = tmp_path / "static"
|
||||
static_root.mkdir()
|
||||
sw_path = static_root / "sw.js"
|
||||
sw_path.write_text("const version = '__WEBUI_VERSION__';\n", encoding="utf-8")
|
||||
favicon_path = static_root / "favicon.ico"
|
||||
favicon_path.write_bytes(b"favicon-bytes")
|
||||
monkeypatch.setattr(api_config, "get_static_root", lambda: static_root)
|
||||
|
||||
sw_handler = _get("/sw.js")
|
||||
expected = sw_path.read_text(encoding="utf-8").replace(
|
||||
"__WEBUI_VERSION__", quote(WEBUI_VERSION, safe="")
|
||||
).encode("utf-8")
|
||||
assert sw_handler.status == 200
|
||||
assert sw_handler.header("Service-Worker-Allowed") == "/"
|
||||
assert sw_handler.header("Cache-Control") == "no-store"
|
||||
assert bytes(sw_handler.body) == expected
|
||||
|
||||
favicon_handler = _get("/favicon.ico")
|
||||
assert favicon_handler.status == 200
|
||||
assert favicon_handler.header("Content-Type") == "image/x-icon"
|
||||
assert bytes(favicon_handler.body) == b"favicon-bytes"
|
||||
|
||||
favicon_path.unlink()
|
||||
missing_favicon_handler = _get("/favicon.ico")
|
||||
assert missing_favicon_handler.status == 204
|
||||
|
||||
|
||||
def test_index_shell_and_static_route_use_selected_root(tmp_path, monkeypatch):
|
||||
static_root = tmp_path / "static"
|
||||
static_root.mkdir()
|
||||
|
||||
index_path = static_root / "index.html"
|
||||
index_path.write_bytes(
|
||||
b"<html>__WEBUI_VERSION__ __MAX_UPLOAD_BYTES__ __CSRF_TOKEN_JSON__ temp</html>"
|
||||
)
|
||||
ui_path = static_root / "ui.js"
|
||||
ui_path.write_bytes(b"console.log('temp static');\n")
|
||||
|
||||
monkeypatch.setattr(api_config, "get_static_root", lambda: static_root)
|
||||
monkeypatch.setattr(api_config, "get_index_html_path", lambda: index_path)
|
||||
monkeypatch.setattr(routes, "_INDEX_SHELL_CACHE", {})
|
||||
monkeypatch.setattr(routes, "_STATIC_CACHE", {})
|
||||
|
||||
shell = routes._render_index_shell_base()
|
||||
assert "temp" in shell
|
||||
assert "__WEBUI_VERSION__" not in shell
|
||||
assert "__MAX_UPLOAD_BYTES__" not in shell
|
||||
assert "__CSRF_TOKEN_JSON__" in shell
|
||||
|
||||
temp_static = _get("/static/ui.js")
|
||||
assert temp_static.status == 200
|
||||
assert bytes(temp_static.body) == b"console.log('temp static');\n"
|
||||
assert bytes(temp_static.body) != (ROOT / "static" / "ui.js").read_bytes()
|
||||
|
||||
traversal = _get("/static/../api/routes.py")
|
||||
assert traversal.status == 404
|
||||
Reference in new issue
Block a user