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
+11 -1
View File
@@ -3,14 +3,24 @@ from __future__ import annotations
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry
from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.security.path_guard import PathGuard
from backend.app.services.remote_browse_service import RemoteBrowseService
class BrowseService:
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
def __init__(
self,
path_guard: PathGuard,
filesystem: FilesystemAdapter,
remote_browse_service: RemoteBrowseService | None = None,
):
self._path_guard = path_guard
self._filesystem = filesystem
self._remote_browse_service = remote_browse_service
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
if self._remote_browse_service and self._remote_browse_service.handles_path(path):
return self._remote_browse_service.browse(path=path, show_hidden=show_hidden)
if self._path_guard.is_virtual_volumes_path(path):
directories = [
DirectoryEntry(name=item["name"], path=item["path"], modified="")
@@ -0,0 +1,201 @@
from __future__ import annotations
from urllib.parse import urlencode
import httpx
from backend.app.api.errors import AppError
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry, RemoteClientItem
from backend.app.services.remote_client_service import RemoteClientService
class RemoteBrowseService:
ROOT_PATH = "/Clients"
def __init__(
self,
remote_client_service: RemoteClientService,
agent_auth_header: str,
agent_auth_scheme: str,
agent_auth_token: str,
agent_timeout_seconds: float = 2.0,
):
self._remote_client_service = remote_client_service
self._agent_auth_header = (agent_auth_header or "Authorization").strip() or "Authorization"
self._agent_auth_scheme = (agent_auth_scheme or "Bearer").strip() or "Bearer"
self._agent_auth_token = (agent_auth_token or "").strip()
self._agent_timeout_seconds = max(0.1, float(agent_timeout_seconds))
@classmethod
def handles_path(cls, path: str) -> bool:
normalized = (path or "").strip()
return normalized == cls.ROOT_PATH or normalized.startswith(f"{cls.ROOT_PATH}/")
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
parts = self._path_parts(path)
if not parts:
return self._browse_clients_root()
if len(parts) == 1:
return self._browse_client(parts[0])
return self._browse_remote_share(parts[0], parts[1], parts[2:], show_hidden)
@classmethod
def _path_parts(cls, path: str) -> list[str]:
normalized = (path or "").strip().rstrip("/")
if normalized == cls.ROOT_PATH:
return []
return normalized[len(cls.ROOT_PATH) + 1 :].split("/")
def _browse_clients_root(self) -> BrowseResponse:
clients = self._remote_client_service.list_clients().items
directories = [
DirectoryEntry(
name=client.display_name,
path=f"{self.ROOT_PATH}/{client.client_id}",
modified=client.last_seen or client.updated_at,
)
for client in clients
]
return BrowseResponse(path=self.ROOT_PATH, directories=directories, files=[])
def _browse_client(self, client_id: str) -> BrowseResponse:
client = self._remote_client_service.get_client(client_id)
directories = [
DirectoryEntry(
name=share.label,
path=f"{self.ROOT_PATH}/{client.client_id}/{share.key}",
modified=client.last_seen or client.updated_at,
)
for share in client.shares
]
return BrowseResponse(path=f"{self.ROOT_PATH}/{client.client_id}", directories=directories, files=[])
def _browse_remote_share(
self,
client_id: str,
share_key: str,
relative_parts: list[str],
show_hidden: bool,
) -> BrowseResponse:
client = self._remote_client_service.get_client(client_id)
if client.status != "online":
raise AppError(
code="remote_client_unavailable",
message=f"Remote client '{client.display_name}' is offline",
status_code=503,
details={"client_id": client.client_id, "status": client.status},
)
share = next((item for item in client.shares if item.key == share_key), None)
if share is None:
raise AppError(
code="path_not_found",
message="Remote share was not found",
status_code=404,
details={"client_id": client.client_id, "share_key": share_key},
)
if not self._agent_auth_token:
raise AppError(
code="remote_client_agent_auth_not_configured",
message="Remote client agent auth token is not configured",
status_code=503,
details={"client_id": client.client_id},
)
base_path = f"{self.ROOT_PATH}/{client.client_id}/{share.key}"
relative_path = "/".join(relative_parts)
agent_payload = self._fetch_remote_listing(client=client, share_key=share.key, relative_path=relative_path, show_hidden=show_hidden)
directories: list[DirectoryEntry] = []
files: list[FileEntry] = []
for entry in agent_payload.get("entries", []):
if not isinstance(entry, dict):
continue
name = str(entry.get("name", "")).strip()
kind = str(entry.get("kind", "")).strip()
if not name or kind not in {"directory", "file"}:
continue
child_path = f"{base_path}/{name}"
modified = str(entry.get("modified", "") or "")
if kind == "directory":
directories.append(DirectoryEntry(name=name, path=child_path, modified=modified))
continue
size = entry.get("size", 0)
try:
normalized_size = max(0, int(size))
except (TypeError, ValueError):
normalized_size = 0
files.append(FileEntry(name=name, path=child_path, size=normalized_size, modified=modified))
response_path = base_path if not relative_path else f"{base_path}/{relative_path}"
return BrowseResponse(path=response_path, directories=directories, files=files)
def _fetch_remote_listing(
self,
*,
client: RemoteClientItem,
share_key: str,
relative_path: str,
show_hidden: bool,
) -> dict:
normalized_endpoint = client.endpoint.rstrip("/")
query = urlencode({"share": share_key, "path": relative_path, "show_hidden": str(show_hidden).lower()})
url = f"{normalized_endpoint}/api/list?{query}"
headers = {self._agent_auth_header: f"{self._agent_auth_scheme} {self._agent_auth_token}"}
timeout = httpx.Timeout(self._agent_timeout_seconds, connect=self._agent_timeout_seconds)
try:
with httpx.Client(timeout=timeout, headers=headers) as client_http:
response = client_http.get(url)
except httpx.TimeoutException as exc:
raise AppError(
code="remote_client_timeout",
message=f"Remote client '{client.display_name}' timed out",
status_code=504,
details={"client_id": client.client_id, "endpoint": client.endpoint},
) from exc
except httpx.HTTPError as exc:
raise AppError(
code="remote_client_unreachable",
message=f"Remote client '{client.display_name}' is unreachable",
status_code=502,
details={"client_id": client.client_id, "endpoint": client.endpoint},
) from exc
if response.status_code == 404:
raise AppError(
code="path_not_found",
message="Remote path was not found",
status_code=404,
details={"client_id": client.client_id, "share_key": share_key},
)
if response.status_code in {401, 403}:
raise AppError(
code="remote_client_forbidden",
message=f"Remote client '{client.display_name}' rejected authentication",
status_code=502,
details={"client_id": client.client_id, "endpoint": client.endpoint},
)
if response.status_code >= 400:
raise AppError(
code="remote_client_error",
message=f"Remote client '{client.display_name}' browse failed",
status_code=502,
details={"client_id": client.client_id, "endpoint": client.endpoint, "status_code": str(response.status_code)},
)
try:
payload = response.json()
except ValueError as exc:
raise AppError(
code="remote_client_error",
message=f"Remote client '{client.display_name}' returned invalid JSON",
status_code=502,
details={"client_id": client.client_id, "endpoint": client.endpoint},
) from exc
if not isinstance(payload, dict):
raise AppError(
code="remote_client_error",
message=f"Remote client '{client.display_name}' returned an invalid response",
status_code=502,
details={"client_id": client.client_id, "endpoint": client.endpoint},
)
return payload
@@ -27,14 +27,30 @@ class RemoteClientService:
self._now = now or (lambda: datetime.now(tz=timezone.utc))
def list_clients(self) -> RemoteClientListResponse:
now = self._now()
self._repository.mark_stale_clients_offline(
cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)),
now_iso=self._to_iso(now),
)
self._refresh_stale_statuses()
items = [RemoteClientItem(**row) for row in self._repository.list_clients()]
return RemoteClientListResponse(items=items)
def get_client(self, client_id: str) -> RemoteClientItem:
normalized_client_id = (client_id or "").strip()
if not normalized_client_id:
raise AppError(
code="invalid_request",
message="client_id is required",
status_code=400,
details={"client_id": client_id},
)
self._refresh_stale_statuses()
item = self._repository.get_client(normalized_client_id)
if item is None:
raise AppError(
code="path_not_found",
message="Remote client was not found",
status_code=404,
details={"client_id": normalized_client_id},
)
return RemoteClientItem(**item)
def register_client(self, authorization: str | None, request: RemoteClientRegisterRequest) -> RemoteClientItem:
self._require_registration_auth(authorization)
payload = self._normalize_register_request(request)
@@ -123,6 +139,13 @@ class RemoteClientService:
"shares": shares,
}
def _refresh_stale_statuses(self) -> None:
now = self._now()
self._repository.mark_stale_clients_offline(
cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)),
now_iso=self._to_iso(now),
)
@staticmethod
def _to_iso(value: datetime) -> str:
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")