Add Phase 3 remote read-only file operations
Introduce dedicated remote file facade for /Clients paths, add agent read/download endpoints, enable remote view/properties/download/image preview in the web UI, and keep remote write operations disabled.
This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
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())
|
||||
Reference in New Issue
Block a user