Refine remote config and path browsing
This commit is contained in:
@@ -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 <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`:
|
||||
|
||||
@@ -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
|
||||
|
||||
+30
-2
@@ -357,6 +357,18 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
mode: "dir",
|
||||
path: "",
|
||||
parent_path: "",
|
||||
home_path: ""
|
||||
home_path: "",
|
||||
root_paths: []
|
||||
};
|
||||
pathBrowserOverlay.hidden = true;
|
||||
pathBrowserList.innerHTML = "";
|
||||
@@ -739,6 +752,20 @@ CONFIG_HTML = r"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
mode,
|
||||
path: $(targetId).value.trim() || "",
|
||||
parent_path: "",
|
||||
home_path: ""
|
||||
home_path: "",
|
||||
root_paths: []
|
||||
};
|
||||
pathBrowserOverlay.hidden = false;
|
||||
await loadPathBrowser(state.pathBrowser.path);
|
||||
|
||||
+6
-1
@@ -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"),
|
||||
|
||||
+47
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user