mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
fix: revalidate frontend assets across builds
This commit is contained in:
Executable
+458
@@ -0,0 +1,458 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Browser regression for the two-layer frontend cache contract.
|
||||
|
||||
This harness serves a versioned entry module which imports the real,
|
||||
unversioned ``shared/interactive.js`` module. It loads an old pane build in a
|
||||
real Chrome profile, switches the server to a same-version replacement whose
|
||||
pane has the same byte length and mtime, then performs a normal browser reload
|
||||
without clearing or disabling the HTTP cache.
|
||||
|
||||
The old fixture advertises ``user_turn=0&tool_turn=0`` in its EventSource URL;
|
||||
the current source advertises both capabilities as ``1``. A passing run
|
||||
therefore proves both layers of the contract:
|
||||
|
||||
* the package-versioned entry URL revalidates and may return 304; and
|
||||
* its unversioned transitive pane import revalidates by content and returns the
|
||||
current bytes rather than surviving from the prior build.
|
||||
|
||||
The page also loads representative KaTeX, Highlight.js, and HLS.js assets.
|
||||
Their installed version directories are discovered from ``shared_static`` at
|
||||
runtime, so a normal vendor-version bump requires no harness edit; reload must
|
||||
reuse them under the immutable policy.
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python scripts/asset_cache_e2e.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from recovery_e2e import CDP, _find_chrome, _launch_chrome, _page_ws_url # noqa: E402
|
||||
|
||||
from turnstone import __version__ # noqa: E402
|
||||
from turnstone.core.web_helpers import ( # noqa: E402
|
||||
RevalidatingStaticFiles,
|
||||
version_html,
|
||||
)
|
||||
|
||||
_PAGE_HTML = """<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>ASSET-CACHE-PENDING</title>
|
||||
<!-- VENDORED_ASSET_TAGS -->
|
||||
<script>
|
||||
window.__assetCacheUrls = [];
|
||||
class RecordingEventSource {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSED = 2;
|
||||
constructor(url) {
|
||||
this.url = String(url);
|
||||
this.readyState = RecordingEventSource.CONNECTING;
|
||||
window.__assetCacheUrls.push(this.url);
|
||||
}
|
||||
close() {
|
||||
this.readyState = RecordingEventSource.CLOSED;
|
||||
}
|
||||
}
|
||||
window.EventSource = RecordingEventSource;
|
||||
window.addEventListener("error", (event) => {
|
||||
document.title = "ASSET-CACHE-FAILED-" + String(event.message || "script").slice(0, 80);
|
||||
});
|
||||
window.addEventListener("unhandledrejection", (event) => {
|
||||
document.title = "ASSET-CACHE-FAILED-" + String(event.reason || "promise").slice(0, 80);
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<main id="pane"></main>
|
||||
<script type="module" src="/static/asset_cache_boot.js"
|
||||
onerror="document.title='ASSET-CACHE-FAILED-module-load'"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
_BOOT_JS = """import { InteractivePane } from "/shared/interactive.js";
|
||||
|
||||
const fixtureWorkstreamId = "00000000-0000-0000-0000-000000000001";
|
||||
const pane = new InteractivePane(fixtureWorkstreamId, { base: "" });
|
||||
document.getElementById("pane").appendChild(pane.el);
|
||||
pane.connectSSE(fixtureWorkstreamId);
|
||||
const url = window.__assetCacheUrls.at(-1) || "";
|
||||
const generation = url.includes("user_turn=1") && url.includes("tool_turn=1")
|
||||
? "CURRENT"
|
||||
: url.includes("user_turn=0") && url.includes("tool_turn=0")
|
||||
? "OLD"
|
||||
: "FAILED-CAPABILITIES";
|
||||
window.__assetCacheResult = { generation, url };
|
||||
document.title = "ASSET-CACHE-" + generation;
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class CacheState:
|
||||
phase: str = "old"
|
||||
requests: list[dict[str, Any]] = field(default_factory=list)
|
||||
lock: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
def record(self, item: dict[str, Any]) -> None:
|
||||
with self.lock:
|
||||
self.requests.append(item)
|
||||
|
||||
def matching(self, phase: str, path: str) -> list[dict[str, Any]]:
|
||||
with self.lock:
|
||||
return [
|
||||
item for item in self.requests if item["phase"] == phase and item["path"] == path
|
||||
]
|
||||
|
||||
|
||||
class SwitchingStaticFiles:
|
||||
"""Select one immutable build snapshot at request dispatch time."""
|
||||
|
||||
def __init__(self, state: CacheState, old_dir: Path, current_dir: Path) -> None:
|
||||
self._state = state
|
||||
self._apps = {
|
||||
"old": RevalidatingStaticFiles(directory=str(old_dir)),
|
||||
"current": RevalidatingStaticFiles(directory=str(current_dir)),
|
||||
}
|
||||
|
||||
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
||||
await self._apps[self._state.phase](scope, receive, send)
|
||||
|
||||
|
||||
class RecordingApp:
|
||||
"""Record static HTTP validators and status without perturbing streaming."""
|
||||
|
||||
def __init__(self, app: Any, state: CacheState) -> None:
|
||||
self._app = app
|
||||
self._state = state
|
||||
|
||||
async def __call__(self, scope: Any, receive: Any, send: Any) -> None:
|
||||
path = str(scope.get("path", ""))
|
||||
if scope.get("type") != "http" or not path.startswith(("/static/", "/shared/")):
|
||||
await self._app(scope, receive, send)
|
||||
return
|
||||
|
||||
phase = self._state.phase
|
||||
request_headers = {
|
||||
key.decode("latin-1").lower(): value.decode("latin-1")
|
||||
for key, value in scope.get("headers", [])
|
||||
}
|
||||
|
||||
async def record_send(message: dict[str, Any]) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
response_headers = {
|
||||
key.decode("latin-1").lower(): value.decode("latin-1")
|
||||
for key, value in message.get("headers", [])
|
||||
}
|
||||
self._state.record(
|
||||
{
|
||||
"phase": phase,
|
||||
"path": path,
|
||||
"query": scope.get("query_string", b"").decode("latin-1"),
|
||||
"if_none_match": request_headers.get("if-none-match"),
|
||||
"status": message["status"],
|
||||
"etag": response_headers.get("etag"),
|
||||
"cache_control": response_headers.get("cache-control"),
|
||||
}
|
||||
)
|
||||
await send(message)
|
||||
|
||||
await self._app(scope, receive, record_send)
|
||||
|
||||
|
||||
def _old_interactive(current: bytes) -> bytes:
|
||||
old = current
|
||||
for needle, replacement in (
|
||||
(b'"user_turn=1"', b'"user_turn=0"'),
|
||||
(b'"&tool_turn=1"', b'"&tool_turn=0"'),
|
||||
):
|
||||
if old.count(needle) != 1:
|
||||
raise RuntimeError(f"expected exactly one {needle.decode()} capability literal")
|
||||
old = old.replace(needle, replacement, 1)
|
||||
if len(old) != len(current):
|
||||
raise AssertionError("old and current pane fixtures must have equal byte length")
|
||||
return old
|
||||
|
||||
|
||||
def _discover_vendor_assets(source_shared: Path) -> tuple[str, ...]:
|
||||
selected = (
|
||||
("katex", "katex.min.css"),
|
||||
("katex", "katex.min.js"),
|
||||
("hljs", "highlight.min.js"),
|
||||
("hls", "hls.min.js"),
|
||||
)
|
||||
paths = []
|
||||
for library, filename in selected:
|
||||
matches = sorted(source_shared.glob(f"{library}-*/{filename}"))
|
||||
if not matches:
|
||||
raise RuntimeError(f"no vendored {library} asset named {filename} was found")
|
||||
paths.extend(f"/shared/{match.relative_to(source_shared).as_posix()}" for match in matches)
|
||||
return tuple(paths)
|
||||
|
||||
|
||||
def _vendor_tags(vendor_paths: tuple[str, ...]) -> str:
|
||||
tags = []
|
||||
for path in vendor_paths:
|
||||
if path.endswith(".css"):
|
||||
tags.append(f'<link rel="stylesheet" href="{path}">')
|
||||
else:
|
||||
tags.append(f'<script src="{path}"></script>')
|
||||
return "\n ".join(tags)
|
||||
|
||||
|
||||
def _prepare_builds(scratch: Path) -> tuple[Path, Path, Path, tuple[str, ...]]:
|
||||
import turnstone
|
||||
|
||||
package_dir = Path(turnstone.__file__).resolve().parent
|
||||
source_shared = package_dir / "shared_static"
|
||||
vendor_paths = _discover_vendor_assets(source_shared)
|
||||
old_shared = scratch / "old" / "shared"
|
||||
current_shared = scratch / "current" / "shared"
|
||||
static_dir = scratch / "static"
|
||||
shutil.copytree(source_shared, old_shared)
|
||||
shutil.copytree(source_shared, current_shared)
|
||||
static_dir.mkdir()
|
||||
(static_dir / "asset_cache_boot.js").write_text(_BOOT_JS, encoding="utf-8")
|
||||
|
||||
current_asset = current_shared / "interactive.js"
|
||||
old_asset = old_shared / "interactive.js"
|
||||
current = current_asset.read_bytes()
|
||||
old_asset.write_bytes(_old_interactive(current))
|
||||
|
||||
# Reproduce the metadata collision which defeated Starlette's default ETag.
|
||||
fixed_mtime_ns = 1_700_000_000_123_456_789
|
||||
for asset in (old_asset, current_asset):
|
||||
os.utime(asset, ns=(fixed_mtime_ns, fixed_mtime_ns))
|
||||
old_stat = old_asset.stat()
|
||||
current_stat = current_asset.stat()
|
||||
if (old_stat.st_size, old_stat.st_mtime_ns) != (
|
||||
current_stat.st_size,
|
||||
current_stat.st_mtime_ns,
|
||||
):
|
||||
raise AssertionError("pane fixture size/mtime collision was not preserved")
|
||||
return old_shared, current_shared, static_dir, vendor_paths
|
||||
|
||||
|
||||
def _make_app(
|
||||
state: CacheState,
|
||||
old_dir: Path,
|
||||
current_dir: Path,
|
||||
static_dir: Path,
|
||||
vendor_paths: tuple[str, ...],
|
||||
) -> Any:
|
||||
from starlette.applications import Starlette
|
||||
from starlette.responses import HTMLResponse
|
||||
from starlette.routing import Mount, Route
|
||||
|
||||
async def page(_request: Any) -> HTMLResponse:
|
||||
page_html = _PAGE_HTML.replace("<!-- VENDORED_ASSET_TAGS -->", _vendor_tags(vendor_paths))
|
||||
return HTMLResponse(
|
||||
version_html(page_html),
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
|
||||
app = Starlette(
|
||||
routes=[
|
||||
Route("/asset-cache-e2e", page),
|
||||
Mount(
|
||||
"/static",
|
||||
app=RevalidatingStaticFiles(directory=str(static_dir)),
|
||||
),
|
||||
Mount(
|
||||
"/shared",
|
||||
app=SwitchingStaticFiles(state, old_dir, current_dir),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return RecordingApp(app, state)
|
||||
|
||||
|
||||
def _start_server(app: Any) -> tuple[Any, threading.Thread, socket.socket, str]:
|
||||
import uvicorn
|
||||
|
||||
sock = socket.socket()
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
sock.listen(128)
|
||||
port = int(sock.getsockname()[1])
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning", lifespan="off")
|
||||
)
|
||||
thread = threading.Thread(
|
||||
target=server.run,
|
||||
kwargs={"sockets": [sock]},
|
||||
name="asset-cache-e2e-server",
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
deadline = time.monotonic() + 10
|
||||
while not server.started and thread.is_alive() and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if not server.started:
|
||||
server.should_exit = True
|
||||
thread.join(timeout=2)
|
||||
sock.close()
|
||||
raise RuntimeError("asset cache test server did not start")
|
||||
return server, thread, sock, f"http://127.0.0.1:{port}"
|
||||
|
||||
|
||||
def _wait_for_generation(cdp: CDP, expected: str, timeout: float = 20) -> dict[str, str]:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_title = ""
|
||||
while time.monotonic() < deadline:
|
||||
last_title = cdp.title()
|
||||
result = cdp.evaluate("window.__assetCacheResult || null")
|
||||
if isinstance(result, dict) and result.get("generation") == expected:
|
||||
return {"generation": str(result["generation"]), "url": str(result["url"])}
|
||||
if last_title.startswith("ASSET-CACHE-FAILED"):
|
||||
raise RuntimeError(last_title)
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError(f"expected {expected}, last title was {last_title!r}")
|
||||
|
||||
|
||||
def _one(state: CacheState, phase: str, path: str) -> dict[str, Any]:
|
||||
requests = state.matching(phase, path)
|
||||
if len(requests) != 1:
|
||||
raise AssertionError(f"expected one {phase} request for {path}, got {requests!r}")
|
||||
return requests[0]
|
||||
|
||||
|
||||
def _verify_trace(state: CacheState, vendor_paths: tuple[str, ...]) -> tuple[str, list[str]]:
|
||||
entry_path = "/static/asset_cache_boot.js"
|
||||
pane_path = "/shared/interactive.js"
|
||||
old_entry = _one(state, "old", entry_path)
|
||||
current_entry = _one(state, "current", entry_path)
|
||||
old_pane = _one(state, "old", pane_path)
|
||||
current_pane = _one(state, "current", pane_path)
|
||||
|
||||
expected_query = f"v={__version__}"
|
||||
if old_entry["query"] != expected_query or current_entry["query"] != expected_query:
|
||||
raise AssertionError("entry URL did not retain the same package version across builds")
|
||||
if old_entry["status"] != 200 or old_pane["status"] != 200:
|
||||
raise AssertionError("old build did not populate the browser cache")
|
||||
if current_entry["status"] != 304 or not current_entry["if_none_match"]:
|
||||
raise AssertionError(f"versioned entry did not revalidate to 304: {current_entry!r}")
|
||||
if current_pane["status"] != 200 or not current_pane["if_none_match"]:
|
||||
raise AssertionError(
|
||||
f"transitive pane did not revalidate to current bytes: {current_pane!r}"
|
||||
)
|
||||
if old_pane["etag"] == current_pane["etag"]:
|
||||
raise AssertionError("content-derived pane validators did not change")
|
||||
if current_pane["cache_control"] != "no-cache":
|
||||
raise AssertionError("transitive pane lost its revalidation policy")
|
||||
|
||||
vendor_trace = []
|
||||
immutable = "public, max-age=31536000, immutable"
|
||||
for path in vendor_paths:
|
||||
old_vendor = _one(state, "old", path)
|
||||
if old_vendor["query"] or old_vendor["status"] != 200:
|
||||
raise AssertionError(f"versioned vendor URL was rewritten or failed: {old_vendor!r}")
|
||||
if old_vendor["cache_control"] != immutable:
|
||||
raise AssertionError(f"versioned vendor asset was not immutable: {old_vendor!r}")
|
||||
revisits = state.matching("current", path)
|
||||
if revisits:
|
||||
if len(revisits) != 1 or revisits[0]["status"] not in (200, 304):
|
||||
raise AssertionError(f"unexpected vendor reload trace: {revisits!r}")
|
||||
if revisits[0]["cache_control"] != immutable:
|
||||
raise AssertionError(f"vendor reload lost immutable policy: {revisits[0]!r}")
|
||||
vendor_trace.append(f"{path}: revisited-{revisits[0]['status']}")
|
||||
else:
|
||||
vendor_trace.append(f"{path}: cache-hit")
|
||||
verdict = f"ASSET-CACHE-READY-entry304-pane200-user1-tool1-vendor{len(vendor_paths)}"
|
||||
return verdict, vendor_trace
|
||||
|
||||
|
||||
def _stop_process(proc: Any) -> None:
|
||||
if proc.poll() is not None:
|
||||
return
|
||||
proc.terminate()
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=5)
|
||||
if proc.poll() is None:
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
proc.wait(timeout=2)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
chrome = _find_chrome()
|
||||
if not chrome:
|
||||
print("ASSET-CACHE-FAILED-no-chrome")
|
||||
return 2
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="turnstone-asset-cache-e2e-") as raw_scratch:
|
||||
scratch = Path(raw_scratch)
|
||||
old_dir, current_dir, static_dir, vendor_paths = _prepare_builds(scratch)
|
||||
state = CacheState()
|
||||
server, server_thread, sock, base_url = _start_server(
|
||||
_make_app(state, old_dir, current_dir, static_dir, vendor_paths)
|
||||
)
|
||||
chrome_proc = None
|
||||
cdp = None
|
||||
try:
|
||||
chrome_proc, cdp_port = _launch_chrome(chrome, scratch / "chrome-profile")
|
||||
cdp = CDP(_page_ws_url(cdp_port))
|
||||
cdp.cmd("Page.enable")
|
||||
cdp.cmd("Runtime.enable")
|
||||
cdp.cmd("Network.enable")
|
||||
cdp.cmd("Page.navigate", {"url": f"{base_url}/asset-cache-e2e"})
|
||||
old_result = _wait_for_generation(cdp, "OLD")
|
||||
|
||||
state.phase = "current"
|
||||
cdp.cmd("Page.reload", {"ignoreCache": False})
|
||||
current_result = _wait_for_generation(cdp, "CURRENT")
|
||||
|
||||
if "user_turn=0" not in old_result["url"] or "tool_turn=0" not in old_result["url"]:
|
||||
raise AssertionError(f"old pane did not expose old capabilities: {old_result!r}")
|
||||
if (
|
||||
"user_turn=1" not in current_result["url"]
|
||||
or "tool_turn=1" not in current_result["url"]
|
||||
):
|
||||
raise AssertionError(
|
||||
f"reloaded pane did not expose current capabilities: {current_result!r}"
|
||||
)
|
||||
|
||||
verdict, vendor_trace = _verify_trace(state, vendor_paths)
|
||||
cdp.evaluate(f"document.title = {json.dumps(verdict)}")
|
||||
print(verdict)
|
||||
print(f" old EventSource: {old_result['url']}")
|
||||
print(f" current EventSource: {current_result['url']}")
|
||||
for item in vendor_trace:
|
||||
print(f" vendor: {item}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
print(f"ASSET-CACHE-FAILED-{type(exc).__name__}: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
if cdp is not None:
|
||||
cdp.close()
|
||||
if chrome_proc is not None:
|
||||
_stop_process(chrome_proc)
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=10)
|
||||
with contextlib.suppress(OSError):
|
||||
sock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -185,8 +185,8 @@ esac
|
||||
|
||||
echo ""
|
||||
echo "NOTE: If you added a NEW library (not just updating a version), also update"
|
||||
echo " the _ASSET_RE regex in turnstone/core/web_helpers.py — its negative lookahead"
|
||||
echo " skips vendored directories to avoid double-versioning static asset URLs."
|
||||
echo " _VERSIONED_VENDOR_DIR in turnstone/core/web_helpers.py — it controls both"
|
||||
echo " HTML version rewriting and immutable static-response caching."
|
||||
echo ""
|
||||
echo "Verify the update:"
|
||||
echo " git diff --stat"
|
||||
|
||||
@@ -91,6 +91,8 @@ class TestServerVersioning:
|
||||
def test_shared_static_unversioned(self, client):
|
||||
resp = client.get("/shared/base.css")
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["cache-control"] == "no-cache"
|
||||
assert resp.headers["etag"]
|
||||
|
||||
|
||||
class TestConsoleVersioning:
|
||||
@@ -147,3 +149,5 @@ class TestConsoleVersioning:
|
||||
resp = client.get("/static/app.js")
|
||||
body = resp.text
|
||||
assert "/v1/api/cluster" in body
|
||||
assert resp.headers["cache-control"] == "no-cache"
|
||||
assert resp.headers["etag"]
|
||||
|
||||
@@ -1587,6 +1587,168 @@ class TestConsoleProxy:
|
||||
mock_collector.get_node_detail.return_value = None
|
||||
resp = client.get("/node/unknown/static/app.js")
|
||||
assert resp.status_code == 404
|
||||
assert resp.headers["cache-control"] == "no-store"
|
||||
|
||||
@pytest.mark.parametrize("mount", ["static", "shared"])
|
||||
@pytest.mark.parametrize(
|
||||
"suffix",
|
||||
[
|
||||
"%2e%2e/%2e%2e/v1/api/workstreams/private/history",
|
||||
"%2E%2E/%2E%2E/v1/api/workstreams/private/history",
|
||||
"%2e%2e%2f%2e%2e%2fv1%2fapi%2fworkstreams",
|
||||
"%5c..%5c..%5cv1%5capi%5cworkstreams",
|
||||
],
|
||||
)
|
||||
def test_proxy_static_rejects_encoded_traversal_before_upstream(self, mount, suffix):
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Route
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.console import server as csrv
|
||||
|
||||
handler = csrv.proxy_static if mount == "static" else csrv.proxy_shared_static
|
||||
app = Starlette(
|
||||
routes=[Route(f"/node/{{node_id}}/{mount}/{{path:path}}", endpoint=handler)]
|
||||
)
|
||||
app.state.proxy_client = MagicMock()
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get(f"/node/node-a/{mount}/katex-0.18.4/{suffix}")
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.headers["cache-control"] == "no-store"
|
||||
app.state.proxy_client.get.assert_not_called()
|
||||
|
||||
def test_proxy_static_percent_encodes_each_valid_path_segment(self, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
from turnstone.console import server as csrv
|
||||
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
content=b"asset",
|
||||
request=httpx.Request("GET", "http://n:1/static/nested/asset"),
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def _mock_get(url, *, headers):
|
||||
calls.append((url, headers))
|
||||
return upstream
|
||||
|
||||
proxy_client = MagicMock(spec=httpx.AsyncClient)
|
||||
proxy_client.get = MagicMock(side_effect=_mock_get)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(proxy_client=proxy_client)),
|
||||
path_params={"node_id": "node-a", "path": "nested/asset name?#.js"},
|
||||
headers={},
|
||||
)
|
||||
monkeypatch.setattr(csrv, "_proxy_auth_headers", lambda request: {})
|
||||
monkeypatch.setattr(csrv, "_get_server_url", lambda request, node_id: "http://n:1")
|
||||
|
||||
resp = asyncio.run(csrv.proxy_static(request))
|
||||
|
||||
assert calls == [("http://n:1/static/nested/asset%20name%3F%23.js", {})]
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["cache-control"] == "no-cache"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "mount", "path"),
|
||||
[
|
||||
("proxy_static", "static", "app.js"),
|
||||
("proxy_shared_static", "shared", "interactive.js"),
|
||||
],
|
||||
)
|
||||
def test_proxy_static_forwards_conditional_request_and_validators(
|
||||
self, monkeypatch, handler_name, mount, path
|
||||
):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
|
||||
from turnstone.console import server as csrv
|
||||
|
||||
upstream = httpx.Response(
|
||||
304,
|
||||
headers={
|
||||
"etag": '"current-build"',
|
||||
"last-modified": "Wed, 12 Aug 2026 12:00:00 GMT",
|
||||
},
|
||||
request=httpx.Request("GET", f"http://n:1/{mount}/{path}"),
|
||||
)
|
||||
calls = []
|
||||
|
||||
async def _mock_get(url, *, headers):
|
||||
calls.append((url, headers))
|
||||
return upstream
|
||||
|
||||
proxy_client = MagicMock(spec=httpx.AsyncClient)
|
||||
proxy_client.get = MagicMock(side_effect=_mock_get)
|
||||
request = SimpleNamespace(
|
||||
app=SimpleNamespace(state=SimpleNamespace(proxy_client=proxy_client)),
|
||||
path_params={"node_id": "node-a", "path": path},
|
||||
headers={"if-none-match": '"current-build"'},
|
||||
)
|
||||
monkeypatch.setattr(csrv, "_proxy_auth_headers", lambda request: {"X-Auth": "test"})
|
||||
monkeypatch.setattr(csrv, "_get_server_url", lambda request, node_id: "http://n:1")
|
||||
|
||||
resp = asyncio.run(getattr(csrv, handler_name)(request))
|
||||
|
||||
assert calls == [
|
||||
(
|
||||
f"http://n:1/{mount}/{path}",
|
||||
{"X-Auth": "test", "if-none-match": '"current-build"'},
|
||||
)
|
||||
]
|
||||
assert resp.status_code == 304
|
||||
assert resp.headers["cache-control"] == "no-cache"
|
||||
assert resp.headers["etag"] == '"current-build"'
|
||||
assert resp.headers["last-modified"] == "Wed, 12 Aug 2026 12:00:00 GMT"
|
||||
|
||||
@pytest.mark.parametrize("status_code", [404, 500])
|
||||
def test_proxy_vendor_static_error_is_never_immutable(self, status_code):
|
||||
import httpx
|
||||
|
||||
from turnstone.console.server import _proxy_static_response
|
||||
|
||||
upstream = httpx.Response(
|
||||
status_code,
|
||||
content=b"transient failure",
|
||||
request=httpx.Request("GET", "http://n:1/shared/katex-0.18.4/missing.css"),
|
||||
)
|
||||
|
||||
resp = _proxy_static_response(upstream, "katex-0.18.4/missing.css")
|
||||
|
||||
assert resp.status_code == status_code
|
||||
assert resp.headers["cache-control"] == "no-store"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("upstream_policy", "expected"),
|
||||
[
|
||||
("no-store", "no-store"),
|
||||
("private, max-age=600", "private, max-age=600"),
|
||||
("public, max-age=0, must-revalidate", "public, max-age=0, must-revalidate"),
|
||||
("public, max-age=60", "public, max-age=60"),
|
||||
],
|
||||
)
|
||||
def test_proxy_vendor_static_preserves_stricter_upstream_policy(
|
||||
self, upstream_policy, expected
|
||||
):
|
||||
import httpx
|
||||
|
||||
from turnstone.console.server import _proxy_static_response
|
||||
|
||||
upstream = httpx.Response(
|
||||
200,
|
||||
content=b"asset",
|
||||
headers={"cache-control": upstream_policy},
|
||||
request=httpx.Request("GET", "http://n:1/shared/katex-0.18.4/katex.js"),
|
||||
)
|
||||
|
||||
resp = _proxy_static_response(upstream, "katex-0.18.4/katex.js")
|
||||
|
||||
assert resp.headers["cache-control"] == expected
|
||||
|
||||
def test_proxy_api_unknown_node_returns_404(self, client, mock_collector):
|
||||
mock_collector.get_node_detail.return_value = None
|
||||
@@ -2447,6 +2609,7 @@ class TestProxySharedStatic:
|
||||
client = TestClient(app, raise_server_exceptions=False, headers=_TEST_AUTH_HEADERS)
|
||||
resp = client.get("/node/unknown/shared/base.css")
|
||||
assert resp.status_code == 404
|
||||
assert resp.headers["cache-control"] == "no-store"
|
||||
client.close()
|
||||
|
||||
|
||||
|
||||
@@ -26,6 +26,8 @@ def client():
|
||||
|
||||
|
||||
def test_valid_ws_id_injects_data_attr(client):
|
||||
from turnstone import __version__
|
||||
|
||||
ws_id = "a" * 32
|
||||
resp = client.get(f"/coordinator/{ws_id}")
|
||||
assert resp.status_code == 200
|
||||
@@ -35,9 +37,27 @@ def test_valid_ws_id_injects_data_attr(client):
|
||||
assert f'data-ws-id="{ws_id}"' in body
|
||||
# Template placeholder is fully substituted.
|
||||
assert "{{WS_ID}}" not in body
|
||||
# Sanity: the shared static imports are wired.
|
||||
assert "/shared/base.css" in body
|
||||
assert "/static/coordinator/coordinator.js" in body
|
||||
# First-party tags are versioned; version-named vendor assets stay stable.
|
||||
assert f"/shared/base.css?v={__version__}" in body
|
||||
assert f"/static/coordinator/coordinator.css?v={__version__}" in body
|
||||
assert "/shared/katex-0.18.4/katex.min.css?v=" not in body
|
||||
# Inline module imports are outside version_html's src/href boundary. The
|
||||
# static route's no-cache policy makes this URL revalidate on every reload.
|
||||
assert 'from "/static/coordinator/coordinator.js"' in body
|
||||
|
||||
|
||||
def test_coordinator_page_revalidates_with_etag(client):
|
||||
ws_id = "b" * 32
|
||||
first = client.get(f"/coordinator/{ws_id}")
|
||||
assert first.headers["cache-control"] == "no-cache"
|
||||
assert first.headers["etag"]
|
||||
|
||||
unchanged = client.get(
|
||||
f"/coordinator/{ws_id}", headers={"If-None-Match": first.headers["etag"]}
|
||||
)
|
||||
assert unchanged.status_code == 304
|
||||
assert unchanged.headers["cache-control"] == "no-cache"
|
||||
assert unchanged.headers["etag"] == first.headers["etag"]
|
||||
|
||||
|
||||
def test_non_hex_ws_id_returns_400(client):
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestVersionHtml:
|
||||
def test_app_css_gets_version(self):
|
||||
@@ -54,6 +58,13 @@ class TestVersionHtml:
|
||||
result = version_html(html)
|
||||
assert result == html # unchanged
|
||||
|
||||
def test_vendor_prefix_lookalike_is_still_versioned(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
html = '<script src="/shared/hls-2-player.js"></script>'
|
||||
result = version_html(html)
|
||||
assert "/shared/hls-2-player.js?v=" in result
|
||||
|
||||
def test_external_urls_not_modified(self):
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
@@ -116,6 +127,140 @@ class TestVersionHtml:
|
||||
assert result == html # unchanged — already has query string
|
||||
|
||||
|
||||
class TestRevalidatingStaticFiles:
|
||||
def test_same_versioned_url_revalidates_after_asset_changes(self, tmp_path):
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone import __version__
|
||||
from turnstone.core.web_helpers import RevalidatingStaticFiles
|
||||
|
||||
asset = tmp_path / "app.js"
|
||||
old_body = b"export const generation = 'old';"
|
||||
new_body = b"export const generation = 'new';"
|
||||
assert len(old_body) == len(new_body)
|
||||
asset.write_bytes(old_body)
|
||||
original_stat = asset.stat()
|
||||
app = Starlette(
|
||||
routes=[Mount("/static", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
url = f"/static/app.js?v={__version__}"
|
||||
first = client.get(url)
|
||||
assert first.status_code == 200
|
||||
assert first.headers["cache-control"] == "no-cache"
|
||||
assert "last-modified" not in first.headers
|
||||
old_etag = first.headers["etag"]
|
||||
|
||||
asset.write_bytes(new_body)
|
||||
os.utime(
|
||||
asset,
|
||||
ns=(original_stat.st_atime_ns, original_stat.st_mtime_ns),
|
||||
)
|
||||
changed_stat = asset.stat()
|
||||
assert changed_stat.st_size == original_stat.st_size
|
||||
assert changed_stat.st_mtime_ns == original_stat.st_mtime_ns
|
||||
|
||||
changed = client.get(url, headers={"If-None-Match": old_etag})
|
||||
assert changed.status_code == 200
|
||||
assert changed.content == new_body
|
||||
assert changed.headers["etag"] != old_etag
|
||||
assert changed.headers["cache-control"] == "no-cache"
|
||||
assert "last-modified" not in changed.headers
|
||||
|
||||
stale_date_only = client.get(
|
||||
url,
|
||||
headers={"If-Modified-Since": "Wed, 31 Dec 9999 23:59:59 GMT"},
|
||||
)
|
||||
assert stale_date_only.status_code == 200
|
||||
assert stale_date_only.content == new_body
|
||||
|
||||
unchanged = client.get(url, headers={"If-None-Match": changed.headers["etag"]})
|
||||
assert unchanged.status_code == 304
|
||||
assert unchanged.headers["cache-control"] == "no-cache"
|
||||
|
||||
def test_version_named_vendor_asset_is_immutable(self, tmp_path):
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.core.web_helpers import RevalidatingStaticFiles
|
||||
|
||||
vendor_dir = tmp_path / "katex-0.18.4"
|
||||
vendor_dir.mkdir()
|
||||
(vendor_dir / "katex.min.css").write_text(".katex {}", encoding="utf-8")
|
||||
app = Starlette(
|
||||
routes=[Mount("/shared", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/shared/katex-0.18.4/katex.min.css")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["cache-control"] == "public, max-age=31536000, immutable"
|
||||
assert resp.headers["etag"]
|
||||
|
||||
def test_missing_asset_is_not_cached(self, tmp_path):
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from turnstone.core.web_helpers import RevalidatingStaticFiles
|
||||
|
||||
app = Starlette(
|
||||
routes=[Mount("/shared", app=RevalidatingStaticFiles(directory=str(tmp_path)))]
|
||||
)
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/shared/not-deployed-yet.js")
|
||||
|
||||
assert resp.status_code == 404
|
||||
assert resp.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
class TestStaticAssetCacheControl:
|
||||
@pytest.mark.parametrize(
|
||||
("path", "expected"),
|
||||
[
|
||||
("interactive.js", "no-cache"),
|
||||
("hls-2-player.js", "no-cache"),
|
||||
("katex-0.18.4/katex.min.css", "public, max-age=31536000, immutable"),
|
||||
("hljs-11.11.1/highlight.min.js", "public, max-age=31536000, immutable"),
|
||||
("katex-0.18.4/../private.json", "no-store"),
|
||||
(r"katex-0.18.4\..\private.json", "no-store"),
|
||||
("nested//asset.js", "no-store"),
|
||||
],
|
||||
)
|
||||
def test_policy_requires_a_canonical_exact_vendor_path(self, path, expected):
|
||||
from turnstone.core.web_helpers import static_asset_cache_control
|
||||
|
||||
assert static_asset_cache_control(path) == expected
|
||||
|
||||
def test_every_packaged_versioned_vendor_directory_uses_the_shared_policy(self):
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import turnstone
|
||||
from turnstone.core.web_helpers import static_asset_cache_control, version_html
|
||||
|
||||
shared_dir = Path(turnstone.__file__).resolve().parent / "shared_static"
|
||||
versioned_dir = re.compile(r"^[a-z][a-z0-9_-]*-\d+(?:\.\d+)+$")
|
||||
vendor_dirs = sorted(
|
||||
path.name
|
||||
for path in shared_dir.iterdir()
|
||||
if path.is_dir() and versioned_dir.fullmatch(path.name)
|
||||
)
|
||||
assert vendor_dirs
|
||||
|
||||
for directory in vendor_dirs:
|
||||
asset_path = f"{directory}/asset.js"
|
||||
assert static_asset_cache_control(asset_path) == "public, max-age=31536000, immutable"
|
||||
html = f'<script src="/shared/{asset_path}"></script>'
|
||||
assert version_html(html) == html
|
||||
|
||||
|
||||
class TestLatin1SafeFilename:
|
||||
"""Content-Disposition filename sanitizer — must yield a value that is
|
||||
both latin-1 encodable (Starlette) and control-char free (h11)."""
|
||||
|
||||
+67
-41
@@ -14,6 +14,7 @@ import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -38,7 +39,6 @@ from starlette.background import BackgroundTask
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from turnstone.api.console_spec import build_console_spec
|
||||
from turnstone.api.docs import make_docs_handler, make_openapi_handler
|
||||
@@ -110,8 +110,12 @@ from turnstone.core.skill_field_validation import SKILL_RUNTIME_CONFIG_FIELDS
|
||||
from turnstone.core.skill_kind import SkillKind
|
||||
from turnstone.core.skill_parser import MAX_SKILL_DESCRIPTION_LEN
|
||||
from turnstone.core.web_helpers import (
|
||||
RevalidatingStaticFiles,
|
||||
is_safe_static_asset_path,
|
||||
read_json_or_400,
|
||||
require_storage_or_503,
|
||||
static_asset_cache_control,
|
||||
version_html,
|
||||
)
|
||||
from turnstone.core.workstream import (
|
||||
Workstream,
|
||||
@@ -147,10 +151,6 @@ _HTML_ETAG = ""
|
||||
|
||||
|
||||
def _load_static() -> None:
|
||||
import hashlib
|
||||
|
||||
from turnstone.core.web_helpers import version_html
|
||||
|
||||
global _HTML, _HTML_ETAG
|
||||
_HTML = version_html((_STATIC_DIR / "index.html").read_text(encoding="utf-8"))
|
||||
_HTML_ETAG = '"' + hashlib.md5(_HTML.encode()).hexdigest()[:16] + '"' # noqa: S324
|
||||
@@ -3032,52 +3032,66 @@ async def proxy_index(request: Request) -> Response:
|
||||
return JSONResponse({"error": "Node unreachable"}, status_code=502)
|
||||
|
||||
|
||||
async def proxy_static(request: Request) -> Response:
|
||||
"""GET /node/{node_id}/static/{path} — proxy static files."""
|
||||
def _proxy_static_request_headers(request: Request) -> dict[str, str]:
|
||||
"""Build upstream headers for a cache-aware static asset request."""
|
||||
headers = _proxy_auth_headers(request)
|
||||
for name in ("if-none-match", "if-modified-since"):
|
||||
value = request.headers.get(name)
|
||||
if value:
|
||||
headers[name] = value
|
||||
return headers
|
||||
|
||||
|
||||
def _proxy_static_response(resp: httpx.Response, path: str) -> Response:
|
||||
"""Preserve upstream validators and apply the local static cache policy."""
|
||||
cache_control = "no-store"
|
||||
if resp.status_code in (200, 304):
|
||||
cache_control = resp.headers.get("cache-control") or static_asset_cache_control(path)
|
||||
headers = {"Cache-Control": cache_control}
|
||||
for name in ("content-type", "etag", "last-modified"):
|
||||
value = resp.headers.get(name)
|
||||
if value:
|
||||
headers[name] = value
|
||||
return Response(content=resp.content, status_code=resp.status_code, headers=headers)
|
||||
|
||||
|
||||
def _static_proxy_error(message: str, status_code: int) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
{"error": message}, status_code=status_code, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
|
||||
|
||||
async def _proxy_static_mount(request: Request, mount: str) -> Response:
|
||||
"""Proxy one validated static mount without URL-normalization ambiguity."""
|
||||
node_id = request.path_params["node_id"]
|
||||
path = request.path_params["path"]
|
||||
if not is_safe_static_asset_path(path):
|
||||
return _static_proxy_error("Invalid static asset path", 400)
|
||||
server_url = _get_server_url(request, node_id)
|
||||
if not server_url:
|
||||
return JSONResponse({"error": "Node not found"}, status_code=404)
|
||||
return _static_proxy_error("Node not found", 404)
|
||||
|
||||
encoded_path = "/".join(urllib.parse.quote(segment, safe="") for segment in path.split("/"))
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{server_url}/static/{path}",
|
||||
headers=_proxy_auth_headers(request),
|
||||
)
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
f"{server_url}/{mount}/{encoded_path}",
|
||||
headers=_proxy_static_request_headers(request),
|
||||
)
|
||||
return _proxy_static_response(resp, path)
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("Proxy static error for %s/%s: %s", node_id, path, exc)
|
||||
return JSONResponse({"error": "Node unreachable"}, status_code=502)
|
||||
log.debug("Proxy %s error for %s/%s: %s", mount, node_id, path, exc)
|
||||
return _static_proxy_error("Node unreachable", 502)
|
||||
|
||||
|
||||
async def proxy_static(request: Request) -> Response:
|
||||
"""GET /node/{node_id}/static/{path} — proxy static files."""
|
||||
return await _proxy_static_mount(request, "static")
|
||||
|
||||
|
||||
async def proxy_shared_static(request: Request) -> Response:
|
||||
"""GET /node/{node_id}/shared/{path} — proxy shared static files."""
|
||||
node_id = request.path_params["node_id"]
|
||||
path = request.path_params["path"]
|
||||
server_url = _get_server_url(request, node_id)
|
||||
if not server_url:
|
||||
return JSONResponse({"error": "Node not found"}, status_code=404)
|
||||
|
||||
client: httpx.AsyncClient = request.app.state.proxy_client
|
||||
try:
|
||||
resp = await client.get(
|
||||
f"{server_url}/shared/{path}",
|
||||
headers=_proxy_auth_headers(request),
|
||||
)
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type", "application/octet-stream"),
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
log.debug("Proxy shared static error for %s/%s: %s", node_id, path, exc)
|
||||
return JSONResponse({"error": "Node unreachable"}, status_code=502)
|
||||
return await _proxy_static_mount(request, "shared")
|
||||
|
||||
|
||||
# Auth endpoints the console handles locally instead of forwarding to
|
||||
@@ -3965,14 +3979,18 @@ async def coordinator_page(request: Request) -> Response:
|
||||
if not template_path.is_file():
|
||||
return JSONResponse({"error": "coordinator UI template missing"}, status_code=500)
|
||||
try:
|
||||
body = template_path.read_text(encoding="utf-8")
|
||||
body = version_html(template_path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
return JSONResponse({"error": "failed to read coordinator UI template"}, status_code=500)
|
||||
# Inject the ws_id as an HTML attribute. ws_id passed the
|
||||
# ``_VALID_WS_ID_RE`` gate above (hex only) so there's nothing
|
||||
# to HTML-escape; leave the replacement simple.
|
||||
body = body.replace("{{WS_ID}}", ws_id)
|
||||
return Response(body, media_type="text/html; charset=utf-8")
|
||||
etag = '"' + hashlib.md5(body.encode()).hexdigest()[:16] + '"' # noqa: S324
|
||||
headers = {"Cache-Control": "no-cache", "ETag": etag}
|
||||
if request.headers.get("If-None-Match") == etag:
|
||||
return Response(status_code=304, headers=headers)
|
||||
return HTMLResponse(body, headers=headers)
|
||||
|
||||
|
||||
_CHILDREN_PAGE_LIMIT = 200
|
||||
@@ -16471,8 +16489,16 @@ def create_app(
|
||||
Route("/metrics", console_metrics_endpoint),
|
||||
Route("/openapi.json", _openapi_handler),
|
||||
Route("/docs", _docs_handler),
|
||||
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
|
||||
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"),
|
||||
Mount(
|
||||
"/static",
|
||||
app=RevalidatingStaticFiles(directory=str(_STATIC_DIR)),
|
||||
name="static",
|
||||
),
|
||||
Mount(
|
||||
"/shared",
|
||||
app=RevalidatingStaticFiles(directory=str(_SHARED_DIR)),
|
||||
name="shared",
|
||||
),
|
||||
# Coordinator one-pane UI — the route serves a single
|
||||
# index.html template with the ws_id injected via data-ws-id
|
||||
# so coordinator.js can pull it without an extra round-trip.
|
||||
|
||||
@@ -3,15 +3,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.responses import FileResponse, PlainTextResponse, Response
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
from starlette.types import Scope
|
||||
|
||||
|
||||
def latin1_safe_filename(name: str, *, fallback: str = "attachment") -> str:
|
||||
@@ -463,6 +471,103 @@ def cors_middleware(origins: list[str]) -> Middleware:
|
||||
# Static asset cache-busting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Keep this directory definition shared by the HTML rewriter and static response
|
||||
# policy. These directories already carry their library version in the URL,
|
||||
# so their contents can be cached immutably; every other first-party asset must
|
||||
# revalidate because ES-module imports do not inherit the entry module's ?v=.
|
||||
_VERSIONED_VENDOR_DIR = r"(?:katex|hljs|hls|mermaid)-\d+(?:\.\d+)+"
|
||||
_VERSIONED_VENDOR_PATH_RE = re.compile(rf"^{_VERSIONED_VENDOR_DIR}/")
|
||||
|
||||
|
||||
def is_safe_static_asset_path(path: str) -> bool:
|
||||
"""Return whether *path* is a canonical mount-relative asset path.
|
||||
|
||||
Starlette decodes percent escapes before populating ``{path:path}``, and
|
||||
HTTPX normalizes dot segments when it builds an outbound URL. Reject the
|
||||
ambiguous forms before a console proxy can leave its static mount.
|
||||
"""
|
||||
return (
|
||||
bool(path)
|
||||
and "\\" not in path
|
||||
and all(segment not in {"", ".", ".."} for segment in path.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def static_asset_cache_control(path: str) -> str:
|
||||
"""Return the cache policy for an asset path relative to its mount."""
|
||||
if not is_safe_static_asset_path(path):
|
||||
return "no-store"
|
||||
if _VERSIONED_VENDOR_PATH_RE.match(path):
|
||||
return "public, max-age=31536000, immutable"
|
||||
return "no-cache"
|
||||
|
||||
|
||||
class RevalidatingStaticFiles(StaticFiles):
|
||||
"""StaticFiles with correctness-first caching for first-party assets.
|
||||
|
||||
First-party files use a content-derived ETag and no Last-Modified header.
|
||||
``no-cache`` therefore revalidates by content on reload, even when two
|
||||
builds share a package version, byte length, and filesystem timestamp.
|
||||
Version-named vendor directories retain immutable caching.
|
||||
"""
|
||||
|
||||
@cached_property
|
||||
def _content_etags(self) -> dict[str, tuple[tuple[int, int, int], str]]:
|
||||
"""Lazily allocate the bounded per-static-tree validator cache."""
|
||||
return {}
|
||||
|
||||
def _content_etag(
|
||||
self,
|
||||
full_path: str | os.PathLike[str],
|
||||
stat_result: os.stat_result,
|
||||
) -> str:
|
||||
path = os.fspath(full_path)
|
||||
signature = (stat_result.st_mtime_ns, stat_result.st_ctime_ns, stat_result.st_size)
|
||||
cached = self._content_etags.get(path)
|
||||
if cached is not None and cached[0] == signature:
|
||||
return cached[1]
|
||||
with open(path, "rb") as asset:
|
||||
digest = hashlib.file_digest(asset, "sha256").hexdigest()
|
||||
etag = f'"sha256-{digest}"'
|
||||
self._content_etags[path] = (signature, etag)
|
||||
return etag
|
||||
|
||||
def file_response(
|
||||
self,
|
||||
full_path: str | os.PathLike[str],
|
||||
stat_result: os.stat_result,
|
||||
scope: Scope,
|
||||
status_code: int = 200,
|
||||
) -> Response:
|
||||
asset_path = self.get_path(scope)
|
||||
if static_asset_cache_control(asset_path) != "no-cache":
|
||||
return super().file_response(full_path, stat_result, scope, status_code)
|
||||
|
||||
request_headers = Headers(scope=scope)
|
||||
response = FileResponse(full_path, status_code=status_code, stat_result=stat_result)
|
||||
etag = self._content_etag(full_path, stat_result)
|
||||
response.headers["ETag"] = etag
|
||||
del response.headers["Last-Modified"]
|
||||
if self.is_not_modified(response.headers, request_headers):
|
||||
return Response(status_code=304, headers={"ETag": etag})
|
||||
return response
|
||||
|
||||
async def get_response(self, path: str, scope: Scope) -> Response:
|
||||
try:
|
||||
response = await super().get_response(path, scope)
|
||||
except HTTPException as exc:
|
||||
if exc.status_code != 404:
|
||||
raise
|
||||
return PlainTextResponse(
|
||||
"Not Found", status_code=404, headers={"Cache-Control": "no-store"}
|
||||
)
|
||||
cache_control = (
|
||||
"no-store" if response.status_code >= 400 else static_asset_cache_control(path)
|
||||
)
|
||||
response.headers["Cache-Control"] = cache_control
|
||||
return response
|
||||
|
||||
|
||||
# Matches src="/static/..." and href="/shared/..." (and vice-versa) but skips
|
||||
# vendored libraries whose directory names already contain a version number
|
||||
# (e.g. katex-0.16.44/, hljs-11.11.1/) and URLs that already have a query
|
||||
@@ -470,7 +575,7 @@ def cors_middleware(origins: list[str]) -> Middleware:
|
||||
_ASSET_RE = re.compile(
|
||||
r'(?P<attr>(?:src|href)=")'
|
||||
r"(?P<path>/(?:static|shared)/)"
|
||||
r"(?!(?:katex|hljs|hls|mermaid)-\d)"
|
||||
rf"(?!{_VERSIONED_VENDOR_DIR}/)"
|
||||
r'(?P<file>[^"?]+)"'
|
||||
)
|
||||
|
||||
@@ -480,7 +585,7 @@ def version_html(html: str) -> str:
|
||||
|
||||
Vendored libraries with version-bearing directory names are skipped.
|
||||
URLs that already contain a query string are left unchanged.
|
||||
Called once at startup when loading HTML into memory.
|
||||
Safe to call either at startup or while rendering a dynamic template.
|
||||
"""
|
||||
from turnstone import __version__
|
||||
|
||||
|
||||
+11
-3
@@ -42,7 +42,6 @@ from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
|
||||
from starlette.routing import Mount, Route
|
||||
from starlette.staticfiles import StaticFiles
|
||||
|
||||
from turnstone import __version__
|
||||
from turnstone.api.docs import make_docs_handler, make_openapi_handler
|
||||
@@ -114,6 +113,7 @@ from turnstone.core.session_ui_base import (
|
||||
)
|
||||
from turnstone.core.tools import TOOLS # noqa: F401 — available for introspection
|
||||
from turnstone.core.trajectory import final_assistant_text
|
||||
from turnstone.core.web_helpers import RevalidatingStaticFiles
|
||||
from turnstone.core.web_helpers import version_html as _version_html
|
||||
from turnstone.core.workstream import (
|
||||
Workstream,
|
||||
@@ -5802,8 +5802,16 @@ def create_app(
|
||||
Route("/metrics", metrics_endpoint),
|
||||
Route("/openapi.json", _openapi_handler),
|
||||
Route("/docs", _docs_handler),
|
||||
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
|
||||
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"),
|
||||
Mount(
|
||||
"/static",
|
||||
app=RevalidatingStaticFiles(directory=str(_STATIC_DIR)),
|
||||
name="static",
|
||||
),
|
||||
Mount(
|
||||
"/shared",
|
||||
app=RevalidatingStaticFiles(directory=str(_SHARED_DIR)),
|
||||
name="shared",
|
||||
),
|
||||
],
|
||||
middleware=_build_middleware(cors_origins),
|
||||
lifespan=_lifespan,
|
||||
|
||||
Reference in New Issue
Block a user