From 4c430e19823bde92ba0933ef2192c773c65a859d Mon Sep 17 00:00:00 2001 From: Dymas Date: Tue, 26 May 2026 10:50:31 +0200 Subject: [PATCH] Refine remote config and path browsing --- README.md | 14 +++++++++++++- config_page.py | 32 ++++++++++++++++++++++++++++++-- http_handler.py | 7 ++++++- test_app.py | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 672fae9..4a10bcb 100644 --- a/README.md +++ b/README.md @@ -322,11 +322,21 @@ ANI_CLI_WEB_AUTH_PASSWORD=choose-a-long-random-password Non-local browser visits are redirected to a sign-in form automatically when no valid session cookie is present. +Remote browser writes use same-origin `Origin` checks. If you run the app behind a reverse proxy, make sure the browser reaches it through the same host you expect the backend to validate. + API clients can authenticate with: - `Authorization: Basic ` -The browser login stores a persistent HTTP-only session cookie, so you usually only need to sign in once per browser. +The browser login stores a persistent HTTP-only session token, so you usually only need to sign in once per browser. + +Remote path access can be limited to explicit server-approved roots with: + +```sh +ANI_CLI_WEB_REMOTE_PATH_ROOTS=/downloads:/srv/jellyfin-tv:/srv/jellyfin-movies +``` + +Those roots are added to the remote path picker alongside the configured download and Jellyfin library folders. You can also set the same credentials directly in `.ani-cli-web/config.json`: @@ -371,6 +381,7 @@ ani-cli-web/.ani-cli-web/ Main contents: - `config.json` +- `remote-sessions.json` - `state.sqlite3` - `thumbnails/` - AniDB title cache data @@ -391,6 +402,7 @@ Environment variables: - `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients - `ANI_CLI_WEB_AUTH_USERNAME`: single remote-login username - `ANI_CLI_WEB_AUTH_PASSWORD`: single remote-login password +- `ANI_CLI_WEB_REMOTE_PATH_ROOTS`: optional `:`-separated allowlist of remote-browse roots - `ANI_CLI_WEB_DEBUG`: enable debug logging - `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory - `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup diff --git a/config_page.py b/config_page.py index 804f18b..9b8c394 100644 --- a/config_page.py +++ b/config_page.py @@ -357,6 +357,18 @@ CONFIG_HTML = r""" gap: 8px; min-height: 220px; } + .browser-roots { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--line); + } + .browser-roots .ghost.active { + background: rgba(255, 255, 255, 0.08); + border-color: var(--line-strong); + color: var(--text); + } .browser-entry { display: flex; align-items: center; @@ -724,7 +736,8 @@ CONFIG_HTML = r""" mode: "dir", path: "", parent_path: "", - home_path: "" + home_path: "", + root_paths: [] }; pathBrowserOverlay.hidden = true; pathBrowserList.innerHTML = ""; @@ -739,6 +752,20 @@ CONFIG_HTML = r""" pathBrowserCurrentPath.textContent = data.path || ""; pathBrowserStatus.textContent = ""; pathBrowserList.innerHTML = ""; + const roots = Array.isArray(data.root_paths) ? data.root_paths : []; + if (roots.length > 1) { + const rootGroup = document.createElement("div"); + rootGroup.className = "browser-roots"; + for (const rootPath of roots) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `ghost${rootPath === data.path ? " active" : ""}`; + button.textContent = rootPath; + button.addEventListener("click", () => loadPathBrowser(rootPath)); + rootGroup.appendChild(button); + } + pathBrowserList.appendChild(rootGroup); + } if (!Array.isArray(data.entries) || !data.entries.length) { const empty = document.createElement("div"); empty.className = "muted"; @@ -784,7 +811,8 @@ CONFIG_HTML = r""" mode, path: $(targetId).value.trim() || "", parent_path: "", - home_path: "" + home_path: "", + root_paths: [] }; pathBrowserOverlay.hidden = false; await loadPathBrowser(state.pathBrowser.path); diff --git a/http_handler.py b/http_handler.py index e8fd352..95732f5 100644 --- a/http_handler.py +++ b/http_handler.py @@ -133,10 +133,12 @@ def browse_filesystem(path_value="", mode="dir", allowed_roots=None): parent_path = str(resolved.parent if resolved.parent != resolved else resolved) if allowed_roots and not path_is_within_roots(parent_path, allowed_roots): parent_path = str(resolved) + root_paths = [str(Path(root)) for root in (allowed_roots or [])] return { "path": str(resolved), "parent_path": parent_path, "home_path": str(allowed_roots[0] if allowed_roots else Path.home()), + "root_paths": root_paths, "mode": browse_mode, "entries": entries, } @@ -731,7 +733,10 @@ class Handler(BaseHTTPRequestHandler): def update_config(self, payload): payload = Handler.require_json_object(self, payload) - config = normalize_config(payload) + current = Handler._context(self).get_config_snapshot() + merged = dict(current) + merged.update(payload) + config = normalize_config(merged) for key, label in ( ("download_dir", "download directory"), ("jellyfin_tv_dir", "Jellyfin TV directory"), diff --git a/test_app.py b/test_app.py index 04065dc..db35d0a 100644 --- a/test_app.py +++ b/test_app.py @@ -2689,6 +2689,8 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_payload["path"], allowed_root) self.assertEqual(handler.json_payload["home_path"], allowed_root) + self.assertEqual(handler.json_payload["root_paths"][0], allowed_root) + self.assertIn(allowed_root, handler.json_payload["root_paths"]) def test_remote_path_roots_include_explicit_allowlist(self): with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root: @@ -2705,6 +2707,31 @@ class HandlerRouteTests(unittest.TestCase): self.assertIn(Path(base_root).resolve(), roots) self.assertIn(Path(extra_root).resolve(), roots) + def test_remote_filesystem_browse_returns_all_allowed_root_paths(self): + with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root: + APP.save_runtime_config({"download_dir": base_root, "mode": "sub", "quality": "best"}) + encoded = base64.b64encode(b"demo:secret-pass").decode("ascii") + handler = DummyHandler("/api/fs/browse?path=&mode=dir") + handler.client_address = ("192.168.1.25", 8421) + handler.headers["Authorization"] = f"Basic {encoded}" + + with mock.patch.dict( + os.environ, + { + "ANI_CLI_WEB_ALLOW_REMOTE": "1", + "ANI_CLI_WEB_AUTH_USERNAME": "demo", + "ANI_CLI_WEB_AUTH_PASSWORD": "secret-pass", + "ANI_CLI_WEB_REMOTE_PATH_ROOTS": extra_root, + }, + clear=False, + ): + APP.Handler.do_GET(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["root_paths"][:2], [extra_root, base_root]) + self.assertIn(extra_root, handler.json_payload["root_paths"]) + self.assertIn(base_root, handler.json_payload["root_paths"]) + def test_config_post_accepts_discord_webhook_fields(self): body = b'{"download_dir":"/tmp/example","mode":"sub","quality":"best","discord_webhook_url":"https://discord.com/api/webhooks/123456/test-token","discord_webhook_events":["runtime_error","watchlist_refresh_failed","bad"]}' handler = DummyHandler("/api/config", body=body, content_length=len(body)) @@ -2723,6 +2750,25 @@ class HandlerRouteTests(unittest.TestCase): self.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST) self.assertIn("official discord webhook host", handler.error_message.lower()) + def test_config_post_partial_update_preserves_existing_paths(self): + with tempfile.TemporaryDirectory() as existing_root: + APP.save_runtime_config( + { + "download_dir": existing_root, + "mode": "sub", + "quality": "best", + "jellyfin_tv_dir": existing_root, + } + ) + body = b'{"mode":"dub"}' + handler = DummyHandler("/api/config", body=body, content_length=len(body)) + APP.Handler.do_POST(handler) + + self.assertEqual(handler.json_status, HTTPStatus.OK) + self.assertEqual(handler.json_payload["download_dir"], existing_root) + self.assertEqual(handler.json_payload["jellyfin_tv_dir"], existing_root) + self.assertEqual(handler.json_payload["mode"], "dub") + def test_remote_config_post_rejects_download_dir_outside_allowed_roots(self): with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root: APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) @@ -3058,6 +3104,7 @@ class TemplateHelperTests(unittest.TestCase): self.assertIn('class="ghost browse-path-btn"', APP.CONFIG_HTML) self.assertIn('id="pathBrowserOverlay"', APP.CONFIG_HTML) self.assertIn('/api/fs/browse?path=${encodeURIComponent(path || state.pathBrowser.path || "")}', APP.CONFIG_HTML) + self.assertIn('rootGroup.className = "browser-roots";', APP.CONFIG_HTML) def test_search_page_sends_watchlist_auto_download_series(self): self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML)