Support standalone user API and WebUI routes

Add usr/api as a contained fallback after existing built-in and plugin handlers, preserving current route precedence and security gates.

Serve usr/extensions/webui assets from an authenticated, root-contained namespace while keeping built-in extension paths unchanged.

Cover source precedence, authentication, path traversal, and manifest-to-asset routing with focused regressions.
This commit is contained in:
Alessandro 2026-08-19 12:14:29 +02:00
parent a304c7665f
commit 81fcc24364
5 changed files with 197 additions and 4 deletions

View file

@ -214,7 +214,7 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
return await cached()
# Resolve file path for the handler
# Try built-in api folder first, then plugin api folders
# Try built-in and plugin api folders before the user fallback
handler_cls: type[ApiHandler] | None = None
# Check built-in python/api/<path>.py
@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
if classes:
handler_cls = classes[0]
# Check user api/<path>.py
if handler_cls is None:
user_api_dir = files.get_abs_path(files.USER_DIR, files.API_DIR)
user_file = files.get_abs_path(user_api_dir, f"{path}.py")
if files.is_in_dir(user_file, user_api_dir) and files.exists(user_file):
classes = load_classes_from_file(user_file, ApiHandler)
if classes:
handler_cls = classes[0]
if handler_cls is None:
return Response(f"API endpoint not found: {path}", 404)

View file

@ -45,6 +45,7 @@
## Key Concepts
- Important called helpers/classes observed in the source: `wraps`, `app.add_url_rule`, `watchdog.add_watchdog`, `cls.requires_auth`, `_use_context`, `login.get_credentials_hash`, `files.get_abs_path`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `handler_cls.requires_auth`, `handler_cls.requires_loopback`, `cache.add`, `PrintStyle.debug`, `cache.clear`, `get_settings`, `f`, `is_loopback_address`, `Response`, `redirect`, `files.is_in_dir`.
- HTTP handlers retain built-in `api/` and explicit plugin API precedence, then fall back to standalone `usr/api/`; built-in and user roots are containment-checked, and every loaded handler keeps its declared authentication, CSRF, API-key, loopback, and method gates.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
## Work Guidance

View file

@ -205,6 +205,12 @@ class UiServerRuntime:
handlers.serve_extension_asset,
methods=["GET"],
)
self.webapp.add_url_rule(
"/usr/extensions/webui/<path:asset_path>",
"serve_user_extension_asset",
handlers.serve_user_extension_asset,
methods=["GET"],
)
self._routes_registered = True
def register_transport_handlers(self) -> None:
@ -403,9 +409,19 @@ class UiRouteHandlers:
@requires_auth
async def serve_extension_asset(self, asset_path):
exts = files.get_abs_path("extensions/webui")
path = files.get_abs_path(exts, asset_path)
if not files.is_in_dir(path, exts):
return self._serve_extension_asset(
files.get_abs_path("extensions/webui"), asset_path
)
@requires_auth
async def serve_user_extension_asset(self, asset_path):
return self._serve_extension_asset(
files.get_abs_path(files.USER_DIR, "extensions/webui"), asset_path
)
def _serve_extension_asset(self, extension_dir, asset_path):
path = files.get_abs_path(extension_dir, asset_path)
if not files.is_in_dir(path, extension_dir):
return Response("Access denied", 403)
return send_file(path)

View file

@ -28,6 +28,7 @@
- `async serve_builtin_plugin_asset(self, plugin_name, asset_path)`
- `async serve_plugin_asset(self, plugin_name, asset_path)`
- `async serve_extension_asset(self, asset_path)`
- `async serve_user_extension_asset(self, asset_path)`
- Top-level functions:
- `_positive_int_env(name: str, default: int) -> int`
- `configure_process_environment() -> None`
@ -45,6 +46,7 @@
- Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
- `serve_index()` bootstraps the normalized UI control visibility map, timezone and time-format preferences, and the complete enabled WebUI extension manifest so startup extension discovery requires no per-surface API requests.
- Authenticated extension asset routes serve root-contained files from both `extensions/webui/` and `usr/extensions/webui/`, matching the URLs emitted by the WebUI extension manifest.
- The authenticated `/` route uses `serve_splash()` to return the no-store, self-contained bootstrap document. The authenticated extensionless `/ui/index` route renders the existing index and runtime/user placeholders for the splash to install into the current document without navigation; `/index.html` remains a direct fallback for the same rendering path. The authenticated `/safe` route first returns a no-store, self-contained document that unregisters all origin service workers, then renders the existing index through `serve_index()` when its internal `__direct=1` marker is present; it never initializes the asset bundle or a worker. The authenticated `serve_ui_asset_bundle()` endpoint passes the application entry URL to the generic recursive bundler and supports gzip transfer and payload-specific ETag revalidation while component, extension, and Alpine lifecycles remain unchanged.
- The Starlette HTTP branch applies negotiated gzip to responses of at least 1 KiB at compression level 6 while preserving already encoded responses; Socket.IO remains outside that middleware branch.
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.

165
tests/test_user_routes.py Normal file
View file

@ -0,0 +1,165 @@
import threading
from flask import Flask
from helpers import cache, files, login, plugins, subagents
from helpers.api import CACHE_AREA, register_api_route
from helpers.extension import get_webui_extension_manifest
from helpers.ui_server import UiServerRuntime
WEBUI_MANIFEST_CACHE_AREA = "webui_extension_manifest(extensions)(plugins)"
def _new_app(name: str) -> Flask:
app = Flask(name, static_folder=None)
app.secret_key = "test-secret"
return app
def _api_handler_source(source: str) -> str:
return f"""from helpers.api import ApiHandler
class Handler(ApiHandler):
@classmethod
def get_methods(cls):
return ["GET"]
async def process(self, input, request):
return {{"source": {source!r}}}
"""
def test_http_dispatches_contained_user_api_handler(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
user_api_dir = tmp_path / "usr" / "api"
user_api_dir.mkdir(parents=True)
handler_source = _api_handler_source("user")
(user_api_dir / "ping.py").write_text(handler_source, encoding="utf-8")
(tmp_path / "usr" / "escaped.py").write_text(
handler_source, encoding="utf-8"
)
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
cache.clear(CACHE_AREA)
try:
app = _new_app("test_user_api_route")
app.add_url_rule("/", "serve_index", lambda: "")
app.add_url_rule("/login", "login_handler", lambda: "")
register_api_route(app, threading.RLock())
client = app.test_client()
assert client.get("/api/ping").status_code == 302
with client.session_transaction() as session:
session["authentication"] = "credential-hash"
session["csrf_token"] = "csrf-token"
response = client.get("/api/ping", headers={"X-CSRF-Token": "csrf-token"})
assert response.status_code == 200
assert response.get_json() == {"source": "user"}
with app.test_request_context("/api/../escaped", method="GET"):
denied = app.ensure_sync(app.view_functions["api_dispatch"])("../escaped")
assert denied.status_code == 404
finally:
cache.clear(CACHE_AREA)
def test_existing_api_sources_keep_precedence(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
builtin_file = tmp_path / "api" / "shared.py"
builtin_file.parent.mkdir(parents=True)
builtin_file.write_text(_api_handler_source("builtin"), encoding="utf-8")
user_api_dir = tmp_path / "usr" / "api"
(user_api_dir / "plugins" / "demo").mkdir(parents=True)
(user_api_dir / "shared.py").write_text(
_api_handler_source("user"), encoding="utf-8"
)
(user_api_dir / "plugins" / "demo" / "ping.py").write_text(
_api_handler_source("user"), encoding="utf-8"
)
plugin_dir = tmp_path / "plugins" / "demo"
(plugin_dir / "api").mkdir(parents=True)
(plugin_dir / "api" / "ping.py").write_text(
_api_handler_source("plugin"), encoding="utf-8"
)
monkeypatch.setattr(
plugins,
"find_plugin_dir",
lambda name: str(plugin_dir) if name == "demo" else None,
)
cache.clear(CACHE_AREA)
try:
app = _new_app("test_existing_api_precedence")
app.add_url_rule("/", "serve_index", lambda: "")
app.add_url_rule("/login", "login_handler", lambda: "")
register_api_route(app, threading.RLock())
client = app.test_client()
with client.session_transaction() as session:
session["authentication"] = "credential-hash"
session["csrf_token"] = "csrf-token"
headers = {"X-CSRF-Token": "csrf-token"}
assert client.get("/api/shared", headers=headers).get_json() == {
"source": "builtin"
}
assert client.get("/api/plugins/demo/ping", headers=headers).get_json() == {
"source": "plugin"
}
finally:
cache.clear(CACHE_AREA)
def test_user_webui_manifest_asset_is_served_from_its_declared_url(
tmp_path, monkeypatch
) -> None:
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
extension_root = tmp_path / "usr" / "extensions" / "webui"
extension_file = extension_root / "route-probe" / "probe.js"
extension_file.parent.mkdir(parents=True)
extension_file.write_text("export default true;", encoding="utf-8")
builtin_extension_file = (
tmp_path / "extensions" / "webui" / "route-probe" / "probe.js"
)
builtin_extension_file.parent.mkdir(parents=True)
builtin_extension_file.write_text("export default false;", encoding="utf-8")
(extension_root.parent / "escaped.js").write_text("secret", encoding="utf-8")
monkeypatch.setattr(subagents, "get_paths", lambda *_args, **_kwargs: [str(extension_root)])
cache.clear(WEBUI_MANIFEST_CACHE_AREA)
try:
manifest = get_webui_extension_manifest(agent=None)
asset_url = manifest["js"]["route-probe"][0]
assert asset_url == "/usr/extensions/webui/route-probe/probe.js"
app = _new_app("test_user_webui_extension_route")
runtime = UiServerRuntime(
app, None, None, threading.RLock(), {} # type: ignore[arg-type]
)
runtime.register_http_routes()
client = app.test_client()
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
assert client.get(asset_url).status_code == 302
monkeypatch.setattr(login, "get_credentials_hash", lambda: None)
builtin_response = client.get("/extensions/webui/route-probe/probe.js")
assert builtin_response.status_code == 200
assert builtin_response.get_data(as_text=True) == "export default false;"
response = client.get(asset_url)
assert response.status_code == 200
assert response.get_data(as_text=True) == "export default true;"
with app.test_request_context("/usr/extensions/webui/../escaped.js"):
denied = app.ensure_sync(
app.view_functions["serve_user_extension_asset"]
)("../escaped.js")
assert denied.status_code == 403
finally:
cache.clear(WEBUI_MANIFEST_CACHE_AREA)