Refine remote config and path browsing

This commit is contained in:
Dymas
2026-05-26 10:50:31 +02:00
parent 83497d8ff0
commit 4c430e1982
4 changed files with 96 additions and 4 deletions
+13 -1
View File
@@ -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. 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: API clients can authenticate with:
- `Authorization: Basic <base64(username:password)>` - `Authorization: Basic <base64(username:password)>`
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`: 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: Main contents:
- `config.json` - `config.json`
- `remote-sessions.json`
- `state.sqlite3` - `state.sqlite3`
- `thumbnails/` - `thumbnails/`
- AniDB title cache data - AniDB title cache data
@@ -391,6 +402,7 @@ Environment variables:
- `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients - `ANI_CLI_WEB_ALLOW_REMOTE`: allow non-loopback clients
- `ANI_CLI_WEB_AUTH_USERNAME`: single remote-login username - `ANI_CLI_WEB_AUTH_USERNAME`: single remote-login username
- `ANI_CLI_WEB_AUTH_PASSWORD`: single remote-login password - `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_DEBUG`: enable debug logging
- `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory - `ANI_CLI_WEB_STATE_ROOT`: override the project-local state directory
- `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup - `UPDATE_ON_START`: run `sudo ani-cli --update` in the container before startup
+30 -2
View File
@@ -357,6 +357,18 @@ CONFIG_HTML = r"""<!doctype html>
gap: 8px; gap: 8px;
min-height: 220px; 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 { .browser-entry {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -724,7 +736,8 @@ CONFIG_HTML = r"""<!doctype html>
mode: "dir", mode: "dir",
path: "", path: "",
parent_path: "", parent_path: "",
home_path: "" home_path: "",
root_paths: []
}; };
pathBrowserOverlay.hidden = true; pathBrowserOverlay.hidden = true;
pathBrowserList.innerHTML = ""; pathBrowserList.innerHTML = "";
@@ -739,6 +752,20 @@ CONFIG_HTML = r"""<!doctype html>
pathBrowserCurrentPath.textContent = data.path || ""; pathBrowserCurrentPath.textContent = data.path || "";
pathBrowserStatus.textContent = ""; pathBrowserStatus.textContent = "";
pathBrowserList.innerHTML = ""; 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) { if (!Array.isArray(data.entries) || !data.entries.length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "muted"; empty.className = "muted";
@@ -784,7 +811,8 @@ CONFIG_HTML = r"""<!doctype html>
mode, mode,
path: $(targetId).value.trim() || "", path: $(targetId).value.trim() || "",
parent_path: "", parent_path: "",
home_path: "" home_path: "",
root_paths: []
}; };
pathBrowserOverlay.hidden = false; pathBrowserOverlay.hidden = false;
await loadPathBrowser(state.pathBrowser.path); await loadPathBrowser(state.pathBrowser.path);
+6 -1
View File
@@ -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) parent_path = str(resolved.parent if resolved.parent != resolved else resolved)
if allowed_roots and not path_is_within_roots(parent_path, allowed_roots): if allowed_roots and not path_is_within_roots(parent_path, allowed_roots):
parent_path = str(resolved) parent_path = str(resolved)
root_paths = [str(Path(root)) for root in (allowed_roots or [])]
return { return {
"path": str(resolved), "path": str(resolved),
"parent_path": parent_path, "parent_path": parent_path,
"home_path": str(allowed_roots[0] if allowed_roots else Path.home()), "home_path": str(allowed_roots[0] if allowed_roots else Path.home()),
"root_paths": root_paths,
"mode": browse_mode, "mode": browse_mode,
"entries": entries, "entries": entries,
} }
@@ -731,7 +733,10 @@ class Handler(BaseHTTPRequestHandler):
def update_config(self, payload): def update_config(self, payload):
payload = Handler.require_json_object(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 ( for key, label in (
("download_dir", "download directory"), ("download_dir", "download directory"),
("jellyfin_tv_dir", "Jellyfin TV directory"), ("jellyfin_tv_dir", "Jellyfin TV directory"),
+47
View File
@@ -2689,6 +2689,8 @@ class HandlerRouteTests(unittest.TestCase):
self.assertEqual(handler.json_status, HTTPStatus.OK) self.assertEqual(handler.json_status, HTTPStatus.OK)
self.assertEqual(handler.json_payload["path"], allowed_root) self.assertEqual(handler.json_payload["path"], allowed_root)
self.assertEqual(handler.json_payload["home_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): def test_remote_path_roots_include_explicit_allowlist(self):
with tempfile.TemporaryDirectory() as base_root, tempfile.TemporaryDirectory() as extra_root: 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(base_root).resolve(), roots)
self.assertIn(Path(extra_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): 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"]}' 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)) 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.assertEqual(handler.error_status, HTTPStatus.BAD_REQUEST)
self.assertIn("official discord webhook host", handler.error_message.lower()) 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): def test_remote_config_post_rejects_download_dir_outside_allowed_roots(self):
with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root: with tempfile.TemporaryDirectory() as allowed_root, tempfile.TemporaryDirectory() as blocked_root:
APP.save_runtime_config({"download_dir": allowed_root, "mode": "sub", "quality": "best"}) 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('class="ghost browse-path-btn"', APP.CONFIG_HTML)
self.assertIn('id="pathBrowserOverlay"', 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('/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): def test_search_page_sends_watchlist_auto_download_series(self):
self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML) self.assertIn('auto_download_series: $("seasonInput").value || "1"', APP.INDEX_HTML)