diff --git a/helpers/api.py b/helpers/api.py index 351ba4716..6047f3eb9 100644 --- a/helpers/api.py +++ b/helpers/api.py @@ -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/.py @@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None: if classes: handler_cls = classes[0] + # Check user api/.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) diff --git a/helpers/api.py.dox.md b/helpers/api.py.dox.md index d9d6bb546..5de8d844c 100644 --- a/helpers/api.py.dox.md +++ b/helpers/api.py.dox.md @@ -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 diff --git a/helpers/ui_server.py b/helpers/ui_server.py index 2b8d57601..aa3f3586b 100644 --- a/helpers/ui_server.py +++ b/helpers/ui_server.py @@ -205,6 +205,12 @@ class UiServerRuntime: handlers.serve_extension_asset, methods=["GET"], ) + self.webapp.add_url_rule( + "/usr/extensions/webui/", + "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) diff --git a/helpers/ui_server.py.dox.md b/helpers/ui_server.py.dox.md index 80d42395e..0af0da6e8 100644 --- a/helpers/ui_server.py.dox.md +++ b/helpers/ui_server.py.dox.md @@ -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. diff --git a/tests/test_user_routes.py b/tests/test_user_routes.py new file mode 100644 index 000000000..5432510ad --- /dev/null +++ b/tests/test_user_routes.py @@ -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)