Add Phase 2 remote browse scaffolding for /Clients
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user