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