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.
|
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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user