from __future__ import annotations from dataclasses import dataclass from pathlib import PurePosixPath from urllib.parse import urlencode import httpx from backend.app.api.errors import AppError from backend.app.api.schemas import FileInfoResponse, RemoteClientItem, ViewResponse from backend.app.services.remote_browse_service import RemoteBrowseService from backend.app.services.remote_client_service import RemoteClientService REMOTE_TEXT_PREVIEW_MAX_BYTES = 256 * 1024 REMOTE_AGENT_TIMEOUT_SECONDS = 2.0 REMOTE_DOWNLOAD_READ_TIMEOUT_SECONDS = 5.0 REMOTE_STREAM_CHUNK_BYTES = 64 * 1024 TEXT_CONTENT_TYPES = { ".txt": "text/plain", ".log": "text/plain", ".conf": "text/plain", ".ini": "text/plain", ".cfg": "text/plain", ".md": "text/markdown", ".yml": "text/yaml", ".yaml": "text/yaml", ".json": "application/json", ".js": "text/javascript", ".py": "text/x-python", ".css": "text/css", ".html": "text/html", } SPECIAL_TEXT_FILENAMES = { "dockerfile": "text/plain", "containerfile": "text/plain", } IMAGE_CONTENT_TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif", ".bmp": "image/bmp", ".avif": "image/avif", } @dataclass(frozen=True) class RemoteResolvedPath: raw_path: str client: RemoteClientItem share_key: str relative_path: str name: str root_path: str class RemoteFileService: def __init__( self, remote_client_service: RemoteClientService, agent_auth_header: str, agent_auth_scheme: str, agent_auth_token: str, agent_timeout_seconds: float = REMOTE_AGENT_TIMEOUT_SECONDS, text_preview_max_bytes: int = REMOTE_TEXT_PREVIEW_MAX_BYTES, download_read_timeout_seconds: float = REMOTE_DOWNLOAD_READ_TIMEOUT_SECONDS, stream_chunk_bytes: int = REMOTE_STREAM_CHUNK_BYTES, ): 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)) self._text_preview_max_bytes = max(1024, int(text_preview_max_bytes)) self._download_read_timeout_seconds = max(0.1, float(download_read_timeout_seconds)) self._stream_chunk_bytes = max(4096, int(stream_chunk_bytes)) def handles_path(self, path: str) -> bool: return RemoteBrowseService.handles_path(path) def info(self, path: str) -> FileInfoResponse: resolved = self._resolve_remote_path(path, allow_share_root=True) payload = self._request_json( client=resolved.client, endpoint_path="/api/info", params={"share": resolved.share_key, "path": resolved.relative_path}, ) kind = str(payload.get("kind", "")).strip() if kind not in {"file", "directory"}: raise self._invalid_agent_payload(resolved.client, "Remote file info response was invalid") extension = str(payload.get("extension", "") or "").strip() or PurePosixPath(resolved.name).suffix.lower() or None return FileInfoResponse( name=str(payload.get("name", resolved.name)).strip() or resolved.name, path=resolved.raw_path, type=kind, size=self._normalize_optional_int(payload.get("size")), modified=str(payload.get("modified", "")).strip(), root=resolved.root_path, extension=extension, content_type=self._normalize_optional_string(payload.get("content_type")), owner=self._normalize_optional_string(payload.get("owner")), group=self._normalize_optional_string(payload.get("group")), width=self._normalize_optional_int(payload.get("width")), height=self._normalize_optional_int(payload.get("height")), ) def view(self, path: str, *, for_edit: bool = False) -> ViewResponse: if for_edit: raise AppError( code="unsupported_type", message="Remote files are not supported for edit", status_code=409, details={"path": path}, ) resolved = self._resolve_remote_path(path) payload = self._request_json( client=resolved.client, endpoint_path="/api/read", params={ "share": resolved.share_key, "path": resolved.relative_path, "max_bytes": str(self._text_preview_max_bytes), }, ) content = str(payload.get("content", "")) if len(content.encode("utf-8")) > self._text_preview_max_bytes: raise self._invalid_agent_payload(resolved.client, "Remote text preview exceeded the configured limit") return ViewResponse( path=resolved.raw_path, name=str(payload.get("name", resolved.name)).strip() or resolved.name, content_type=str(payload.get("content_type", self._content_type_for_name(resolved.name) or "text/plain")).strip(), encoding=str(payload.get("encoding", "utf-8")).strip() or "utf-8", truncated=bool(payload.get("truncated", False)), size=max(0, int(payload.get("size", 0))), modified=str(payload.get("modified", "")).strip(), content=content, ) def prepare_download(self, paths: list[str]) -> dict: if len(paths) != 1: raise AppError( code="invalid_request", message="Remote downloads support exactly one file per request", status_code=400, ) resolved = self._resolve_remote_path(paths[0]) stream = self._open_stream( client=resolved.client, endpoint_path="/api/download", params={"share": resolved.share_key, "path": resolved.relative_path}, ) content_disposition = stream.headers.get("content-disposition") or f'attachment; filename="{resolved.name}"' headers = {"Content-Disposition": content_disposition} if stream.headers.get("content-length"): headers["Content-Length"] = stream.headers["content-length"] return { "content": self._iter_remote_stream(stream), "headers": headers, "content_type": stream.headers.get("content-type", "application/octet-stream"), } def prepare_image_stream(self, path: str) -> dict: resolved = self._resolve_remote_path(path) content_type = self._image_content_type_for_name(resolved.name) if content_type is None: raise AppError( code="unsupported_type", message="File type is not supported for image viewing", status_code=409, details={"path": path}, ) stream = self._open_stream( client=resolved.client, endpoint_path="/api/download", params={"share": resolved.share_key, "path": resolved.relative_path}, ) headers: dict[str, str] = {} if stream.headers.get("content-length"): headers["Content-Length"] = stream.headers["content-length"] return { "content": self._iter_remote_stream(stream), "headers": headers, "content_type": content_type, } def _resolve_remote_path(self, path: str, *, allow_share_root: bool = False) -> RemoteResolvedPath: normalized = (path or "").strip().rstrip("/") if not self.handles_path(normalized): raise AppError( code="invalid_request", message="Remote path must be under /Clients", status_code=400, details={"path": path}, ) parts = normalized[len(RemoteBrowseService.ROOT_PATH) + 1 :].split("/") if normalized != RemoteBrowseService.ROOT_PATH else [] min_parts = 2 if allow_share_root else 3 if len(parts) < min_parts: raise AppError( code="type_conflict", message="Remote path must reference a file or directory inside a share", status_code=409, details={"path": path}, ) client = self._remote_client_service.get_client(parts[0]) 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_key = parts[1] if not any(share.key == share_key for share in client.shares): 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}, ) relative_path = "/".join(parts[2:]) if not relative_path and not allow_share_root: raise AppError( code="type_conflict", message="Remote file operation requires a path inside the share", status_code=409, details={"path": path}, ) name = parts[-1] if allow_share_root and len(parts) == 2: share = next((item for item in client.shares if item.key == share_key), None) if share is not None: name = share.label return RemoteResolvedPath( raw_path=normalized, client=client, share_key=share_key, relative_path=relative_path, name=name, root_path=f"{RemoteBrowseService.ROOT_PATH}/{client.client_id}/{share_key}", ) def _request_json(self, *, client: RemoteClientItem, endpoint_path: str, params: dict[str, str]) -> dict: url = self._build_url(client.endpoint, endpoint_path, params) timeout = httpx.Timeout(self._agent_timeout_seconds, connect=self._agent_timeout_seconds) try: with httpx.Client(timeout=timeout, headers=self._auth_headers()) as client_http: response = client_http.get(url) except httpx.TimeoutException as exc: raise self._timeout_error(client) from exc except httpx.HTTPError as exc: raise self._unreachable_error(client) from exc self._raise_for_agent_error(client=client, response=response) try: payload = response.json() except ValueError as exc: raise self._invalid_agent_payload(client, "Remote client returned invalid JSON") from exc if not isinstance(payload, dict): raise self._invalid_agent_payload(client, "Remote client returned an invalid response") return payload def _open_stream(self, *, client: RemoteClientItem, endpoint_path: str, params: dict[str, str]) -> httpx.Response: url = self._build_url(client.endpoint, endpoint_path, params) timeout = httpx.Timeout( connect=self._agent_timeout_seconds, read=self._download_read_timeout_seconds, write=self._agent_timeout_seconds, pool=self._agent_timeout_seconds, ) client_http = httpx.Client(timeout=timeout, headers=self._auth_headers()) try: response = client_http.stream("GET", url) response.__enter__() except httpx.TimeoutException as exc: client_http.close() raise self._timeout_error(client) from exc except httpx.HTTPError as exc: client_http.close() raise self._unreachable_error(client) from exc try: self._raise_for_agent_error(client=client, response=response) except Exception: response.close() client_http.close() raise response.extensions["remote_client_http_client"] = client_http return response def _iter_remote_stream(self, response: httpx.Response): client_http = response.extensions.get("remote_client_http_client") try: for chunk in response.iter_bytes(chunk_size=self._stream_chunk_bytes): if chunk: yield chunk finally: response.close() if client_http is not None: client_http.close() def _raise_for_agent_error(self, *, client: RemoteClientItem, response: httpx.Response) -> None: if response.status_code < 400: return code = None message = None detail_payload = None try: payload = response.json() except ValueError: payload = None if isinstance(payload, dict): detail = payload.get("detail") if isinstance(detail, dict): detail_payload = detail code = self._normalize_optional_string(detail.get("code")) message = self._normalize_optional_string(detail.get("message")) elif isinstance(detail, str): message = detail.strip() or None if response.status_code == 400: raise AppError( code=code or "invalid_request", message=message or "Remote request was rejected", status_code=400, details={"client_id": client.client_id}, ) if response.status_code == 403: agent_code = code or "forbidden" if agent_code == "invalid_agent_token": 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}, ) raise AppError( code=agent_code, message=message or "Remote access was denied", status_code=403, details={"client_id": client.client_id}, ) if response.status_code == 404: raise AppError( code=code or "path_not_found", message=message or "Remote path was not found", status_code=404, details={"client_id": client.client_id}, ) if response.status_code == 409: raise AppError( code=code or "type_conflict", message=message or "Remote file operation could not be completed", status_code=409, details={"client_id": client.client_id}, ) raise AppError( code="remote_client_error", message=message or f"Remote client '{client.display_name}' request failed", status_code=502, details={ "client_id": client.client_id, "endpoint": client.endpoint, "status_code": str(response.status_code), "agent_code": code or "", "agent_detail": str(detail_payload or ""), }, ) def _auth_headers(self) -> dict[str, str]: 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, ) return {self._agent_auth_header: f"{self._agent_auth_scheme} {self._agent_auth_token}"} @staticmethod def _build_url(endpoint: str, endpoint_path: str, params: dict[str, str]) -> str: return f"{endpoint.rstrip('/')}{endpoint_path}?{urlencode(params)}" @staticmethod def _timeout_error(client: RemoteClientItem) -> AppError: return 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}, ) @staticmethod def _unreachable_error(client: RemoteClientItem) -> AppError: return 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}, ) @staticmethod def _invalid_agent_payload(client: RemoteClientItem, message: str) -> AppError: return AppError( code="remote_client_error", message=message, status_code=502, details={"client_id": client.client_id, "endpoint": client.endpoint}, ) @staticmethod def _normalize_optional_string(value) -> str | None: normalized = str(value).strip() if value is not None else "" return normalized or None @staticmethod def _normalize_optional_int(value) -> int | None: if value is None or value == "": return None try: return max(0, int(value)) except (TypeError, ValueError): return None @staticmethod def _content_type_for_name(name: str) -> str | None: special_name = SPECIAL_TEXT_FILENAMES.get((name or "").lower()) if special_name: return special_name return TEXT_CONTENT_TYPES.get(PurePosixPath(name).suffix.lower()) @staticmethod def _image_content_type_for_name(name: str) -> str | None: return IMAGE_CONTENT_TYPES.get(PurePosixPath(name).suffix.lower())