Add Phase 2 remote browse scaffolding for /Clients

This commit is contained in:
kodi
2026-03-27 11:39:26 +01:00
parent 841318c9e2
commit 4062cbf6c8
15 changed files with 635 additions and 31 deletions
+106 -2
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import fnmatch
import html
import json
import mimetypes
import os
import secrets
@@ -9,6 +10,7 @@ import shutil
import stat
import time
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Literal, Optional
@@ -94,6 +96,97 @@ def _now_iso() -> str:
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
@lru_cache(maxsize=1)
def remote_agent_config() -> dict:
config_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip()
if not config_path:
return {}
try:
return json.loads(Path(config_path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def remote_agent_access_token() -> str:
return os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip() or str(
remote_agent_config().get("agent_access_token", "")
).strip()
def remote_agent_shares() -> dict[str, str]:
raw = remote_agent_config().get("shares", {})
if not isinstance(raw, dict):
return {}
normalized: dict[str, str] = {}
for key, value in raw.items():
share_key = str(key).strip()
share_root = str(value).strip()
if share_key and share_root:
normalized[share_key] = share_root
return normalized
def require_remote_agent_auth(request: Request) -> None:
expected_token = remote_agent_access_token()
if not expected_token:
return
authorization = request.headers.get("authorization", "").strip()
if authorization != f"Bearer {expected_token}":
raise HTTPException(status_code=403, detail="Invalid agent token")
def share_root(share: str) -> Path:
shares = remote_agent_shares()
normalized_share = (share or "").strip()
if normalized_share not in shares:
raise HTTPException(status_code=404, detail="Share not found")
return Path(shares[normalized_share]).expanduser().resolve(strict=False)
def ensure_within_share(root: Path, candidate: Path) -> Path:
try:
candidate.relative_to(root)
except ValueError as exc:
raise HTTPException(status_code=403, detail="Path escapes share root") from exc
return candidate
def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path:
root = share_root(share)
normalized_raw_path = (raw_path or "").strip().replace("\\", "/")
if normalized_raw_path.startswith("/") or any(part == ".." for part in normalized_raw_path.split("/")):
raise HTTPException(status_code=400, detail="Invalid share-relative path")
candidate = (root / normalized_raw_path).resolve(strict=False)
candidate = ensure_within_share(root, candidate)
if must_exist and not candidate.exists():
raise HTTPException(status_code=404, detail="Path not found")
return candidate
def remote_entry_payload(path: Path) -> dict:
st = path.lstat()
return {
"name": path.name,
"kind": "directory" if path.is_dir() else "file",
"size": st.st_size,
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
}
def sorted_share_entries(path: Path, show_hidden: bool = False) -> list[dict]:
try:
children = list(path.iterdir())
except PermissionError as exc:
raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc
filtered = []
for child in children:
if not show_hidden and child.name.startswith("."):
continue
filtered.append(child)
filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
return [remote_entry_payload(child) for child in filtered]
def rel_from_home(path: Path) -> str:
return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT))
@@ -314,7 +407,8 @@ async def harden_headers(request: Request, call_next):
@app.get("/health")
def health() -> dict:
def health(request: Request) -> dict:
require_remote_agent_auth(request)
return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)}
@@ -332,7 +426,17 @@ def index(request: Request):
@app.get("/api/list")
def api_list(path: str = "", show_hidden: bool = False) -> dict:
def api_list(request: Request, path: str = "", share: str = "", show_hidden: bool = False) -> dict:
if share.strip():
require_remote_agent_auth(request)
target = resolve_share_path(share, path)
if not target.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")
return {
"share": share.strip(),
"path": path.strip().replace("\\", "/").strip("/"),
"entries": sorted_share_entries(target, show_hidden=show_hidden),
}
target = resolve_user_path(path)
if not target.is_dir():
raise HTTPException(status_code=400, detail="Path is not a directory")