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