diff --git a/finder_commander/app/main.py b/finder_commander/app/main.py index 14bad2e..da42496 100644 --- a/finder_commander/app/main.py +++ b/finder_commander/app/main.py @@ -3,16 +3,37 @@ from __future__ import annotations import json import mimetypes import os +import struct from dataclasses import dataclass from datetime import datetime, timezone from functools import lru_cache from pathlib import Path from fastapi import FastAPI, HTTPException, Request -from fastapi.responses import JSONResponse +from fastapi.responses import FileResponse, JSONResponse APP_NAME = "Finder Commander Remote Agent" DEFAULT_PORT = 8765 +TEXT_PREVIEW_MAX_BYTES = 256 * 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", +} @dataclass(frozen=True) @@ -84,10 +105,11 @@ def require_agent_auth(request: Request) -> None: return authorization = request.headers.get("authorization", "").strip() if authorization != f"Bearer {config.agent_access_token}": - raise HTTPException( + raise_agent_error( status_code=403, - detail={ - "message": "Invalid agent token", + code="invalid_agent_token", + message="Invalid agent token", + extra={ "config_path": str(config.config_path) if config.config_path else None, "client_id": config.client_id or None, "display_name": config.display_name or None, @@ -95,11 +117,18 @@ def require_agent_auth(request: Request) -> None: ) +def raise_agent_error(status_code: int, code: str, message: str, *, extra: dict | None = None) -> None: + detail = {"code": code, "message": message} + if extra: + detail.update(extra) + raise HTTPException(status_code=status_code, detail=detail) + + def get_share_root(share: str) -> Path: config = get_runtime_config() normalized_share = (share or "").strip() if normalized_share not in config.shares: - raise HTTPException(status_code=404, detail="Share not found") + raise_agent_error(404, "path_not_found", "Share not found") return Path(config.shares[normalized_share]).expanduser().resolve(strict=False) @@ -107,7 +136,8 @@ def ensure_within_root(root: Path, candidate: Path) -> Path: try: candidate.relative_to(root) except ValueError as exc: - raise HTTPException(status_code=403, detail="Path escapes share root") from exc + _ = exc + raise_agent_error(403, "path_traversal_detected", "Path escapes share root") return candidate @@ -115,11 +145,11 @@ def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> root = get_share_root(share) normalized = (raw_path or "").strip().replace("\\", "/") if normalized.startswith("/") or any(part == ".." for part in normalized.split("/")): - raise HTTPException(status_code=400, detail="Invalid share-relative path") + raise_agent_error(400, "invalid_request", "Invalid share-relative path") candidate = (root / normalized).resolve(strict=False) candidate = ensure_within_root(root, candidate) if must_exist and not candidate.exists(): - raise HTTPException(status_code=404, detail="Path not found") + raise_agent_error(404, "path_not_found", "Path not found") return candidate @@ -137,6 +167,7 @@ def info_payload(path: Path, *, share: str, raw_path: str) -> dict: stat_result = path.lstat() kind = "directory" if path.is_dir() else "file" mime, _ = mimetypes.guess_type(path.name) + width, height = image_dimensions(path) if path.is_file() else (None, None) return { "share": share, "path": raw_path.strip().replace("\\", "/").strip("/"), @@ -145,6 +176,11 @@ def info_payload(path: Path, *, share: str, raw_path: str) -> dict: "size": None if path.is_dir() else stat_result.st_size, "modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"), "content_type": mime or "application/octet-stream", + "extension": path.suffix.lower() or None, + "width": width, + "height": height, + "owner": None, + "group": None, "config_path": str(get_runtime_config().config_path) if get_runtime_config().config_path else None, } @@ -153,7 +189,8 @@ def list_directory(path: Path, *, show_hidden: bool) -> list[dict]: try: children = list(path.iterdir()) except PermissionError as exc: - raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc + _ = exc + raise_agent_error(403, "forbidden", "Permission denied by operating system") filtered = [] for child in children: if not show_hidden and child.name.startswith("."): @@ -163,6 +200,65 @@ def list_directory(path: Path, *, show_hidden: bool) -> list[dict]: return [directory_entry_payload(child) for child in filtered] +def text_content_type_for_name(name: str) -> str | None: + lowered = (name or "").lower() + special = SPECIAL_TEXT_FILENAMES.get(lowered) + if special: + return special + return TEXT_CONTENT_TYPES.get(Path(name).suffix.lower()) + + +def read_text_preview(path: Path, *, max_bytes: int) -> dict: + size = int(path.stat().st_size) + preview_limit = min(max(1, int(max_bytes)), TEXT_PREVIEW_MAX_BYTES) + with path.open("rb") as handle: + raw = handle.read(preview_limit + 1) + truncated = size > preview_limit or len(raw) > preview_limit + if truncated: + raw = raw[:preview_limit] + if b"\x00" in raw: + raise_agent_error(409, "unsupported_type", "Binary content is not supported for text preview") + try: + content = raw.decode("utf-8") + except UnicodeDecodeError as exc: + _ = exc + raise_agent_error(409, "unsupported_type", "Binary content is not supported for text preview") + return { + "size": size, + "modified": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"), + "encoding": "utf-8", + "truncated": truncated, + "content": content, + } + + +def image_dimensions(path: Path) -> tuple[int | None, int | None]: + suffix = path.suffix.lower() + try: + if suffix == ".png": + with path.open("rb") as handle: + header = handle.read(24) + if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n": + return None, None + return struct.unpack(">II", header[16:24]) + if suffix == ".gif": + with path.open("rb") as handle: + header = handle.read(10) + if len(header) < 10 or header[:6] not in {b"GIF87a", b"GIF89a"}: + return None, None + return struct.unpack(" dict: return info_payload(target, share=share.strip(), raw_path=path) +@app.get("/api/read") +def api_read(request: Request, share: str, path: str = "", max_bytes: int = TEXT_PREVIEW_MAX_BYTES) -> dict: + require_agent_auth(request) + target = resolve_share_path(share, path) + if target.is_dir(): + raise_agent_error(409, "type_conflict", "Source must be a file") + if not target.is_file(): + raise_agent_error(409, "type_conflict", "Unsupported path type for read") + content_type = text_content_type_for_name(target.name) + if content_type is None: + raise_agent_error(409, "unsupported_type", "File type is not supported for text preview") + return { + "name": target.name, + "path": path.strip().replace("\\", "/").strip("/"), + "content_type": content_type, + **read_text_preview(target, max_bytes=max_bytes), + } + + +@app.get("/api/download") +def api_download(request: Request, share: str, path: str = "") -> FileResponse: + require_agent_auth(request) + target = resolve_share_path(share, path) + if target.is_dir(): + raise_agent_error(409, "type_conflict", "Source must be a file") + if not target.is_file(): + raise_agent_error(409, "type_conflict", "Unsupported path type for download") + return FileResponse( + path=target, + media_type=mimetypes.guess_type(target.name)[0] or "application/octet-stream", + filename=target.name, + ) + + @app.exception_handler(HTTPException) async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: return JSONResponse(status_code=exc.status_code, content={"ok": False, "detail": exc.detail}) diff --git a/finder_commander/test_agent_file_endpoints.py b/finder_commander/test_agent_file_endpoints.py new file mode 100644 index 0000000..544e825 --- /dev/null +++ b/finder_commander/test_agent_file_endpoints.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path + +from fastapi import HTTPException +from starlette.requests import Request + +from finder_commander.app import main as agent_main + + +class AgentFileEndpointsTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.share_root = Path(self.temp_dir.name) / "Downloads" + self.share_root.mkdir(parents=True, exist_ok=True) + self.outside_root = Path(self.temp_dir.name) / "Outside" + self.outside_root.mkdir(parents=True, exist_ok=True) + self.config_path = Path(self.temp_dir.name) / "agent.json" + self.config_path.write_text( + json.dumps( + { + "agent_access_token": "agent-secret", + "client_id": "client-123", + "display_name": "Jan MacBook", + "shares": {"downloads": str(self.share_root)}, + } + ), + encoding="utf-8", + ) + os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(self.config_path) + agent_main.get_runtime_config.cache_clear() + + def tearDown(self) -> None: + os.environ.pop("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", None) + agent_main.get_runtime_config.cache_clear() + self.temp_dir.cleanup() + + @staticmethod + def _authorized_request() -> Request: + return Request({"type": "http", "headers": [(b"authorization", b"Bearer agent-secret")]}) + + def test_info_read_and_download_success(self) -> None: + notes = self.share_root / "notes.md" + notes.write_text("# title\nhello\n", encoding="utf-8") + + info_response = agent_main.api_info(self._authorized_request(), share="downloads", path="notes.md") + self.assertEqual(info_response["kind"], "file") + self.assertEqual(info_response["extension"], ".md") + + read_response = agent_main.api_read(self._authorized_request(), share="downloads", path="notes.md", max_bytes=4) + self.assertTrue(read_response["truncated"]) + self.assertEqual(read_response["content"], "# ti") + + download_response = agent_main.api_download(self._authorized_request(), share="downloads", path="notes.md") + self.assertEqual(download_response.media_type, "text/markdown") + self.assertIn('attachment; filename="notes.md"', download_response.headers.get("content-disposition", "")) + + def test_unknown_share_and_escape_outside_root_are_rejected(self) -> None: + outside_file = self.outside_root / "secret.txt" + outside_file.write_text("secret", encoding="utf-8") + (self.share_root / "escape.txt").symlink_to(outside_file) + + with self.assertRaises(HTTPException) as unknown_share: + agent_main.api_info(self._authorized_request(), share="missing", path="notes.md") + self.assertEqual(unknown_share.exception.status_code, 404) + self.assertEqual(unknown_share.exception.detail["code"], "path_not_found") + + with self.assertRaises(HTTPException) as escaped: + agent_main.api_info(self._authorized_request(), share="downloads", path="escape.txt") + self.assertEqual(escaped.exception.status_code, 403) + self.assertEqual(escaped.exception.detail["code"], "path_traversal_detected") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index 9ef1b3c..2cd0e6e 100644 Binary files a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc and b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc differ diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index 1ae7df5..fd280d4 100644 Binary files a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc differ diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 23252d7..97cd676 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -5,10 +5,11 @@ from fastapi.responses import StreamingResponse from starlette.background import BackgroundTask from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse -from backend.app.dependencies import get_archive_download_task_service, get_delete_task_service, get_file_ops_service +from backend.app.dependencies import get_archive_download_task_service, get_delete_task_service, get_file_ops_service, get_remote_file_service from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.file_ops_service import FileOpsService +from backend.app.services.remote_file_service import RemoteFileService router = APIRouter(prefix="/files") @@ -54,7 +55,10 @@ async def view( path: str, for_edit: bool = False, service: FileOpsService = Depends(get_file_ops_service), + remote_service: RemoteFileService = Depends(get_remote_file_service), ) -> ViewResponse: + if remote_service.handles_path(path): + return remote_service.view(path=path, for_edit=for_edit) return service.view(path=path, for_edit=for_edit) @@ -62,7 +66,10 @@ async def view( async def info( path: str, service: FileOpsService = Depends(get_file_ops_service), + remote_service: RemoteFileService = Depends(get_remote_file_service), ) -> FileInfoResponse: + if remote_service.handles_path(path): + return remote_service.info(path=path) return service.info(path=path) @@ -70,8 +77,9 @@ async def info( async def download( path: list[str] = Query(...), service: FileOpsService = Depends(get_file_ops_service), + remote_service: RemoteFileService = Depends(get_remote_file_service), ) -> StreamingResponse: - prepared = service.prepare_download(paths=path) + prepared = remote_service.prepare_download(paths=path) if any(remote_service.handles_path(item) for item in path) else service.prepare_download(paths=path) response = StreamingResponse( prepared["content"], headers=prepared["headers"], @@ -143,7 +151,15 @@ async def pdf( async def image( path: str, service: FileOpsService = Depends(get_file_ops_service), + remote_service: RemoteFileService = Depends(get_remote_file_service), ) -> StreamingResponse: + if remote_service.handles_path(path): + prepared = remote_service.prepare_image_stream(path=path) + return StreamingResponse( + prepared["content"], + headers=prepared["headers"], + media_type=prepared["content_type"], + ) prepared = service.prepare_image_stream(path=path) return StreamingResponse( prepared["content"], diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index a670ebc..ec36f0f 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -22,6 +22,7 @@ from backend.app.services.history_service import HistoryService from backend.app.services.move_task_service import MoveTaskService from backend.app.services.remote_browse_service import RemoteBrowseService from backend.app.services.remote_client_service import RemoteClientService +from backend.app.services.remote_file_service import RemoteFileService from backend.app.services.search_service import SearchService from backend.app.services.settings_service import SettingsService from backend.app.services.task_service import TaskService @@ -187,3 +188,13 @@ async def get_remote_browse_service() -> RemoteBrowseService: agent_auth_scheme=settings.remote_client_agent_auth_scheme, agent_auth_token=settings.remote_client_agent_auth_token, ) + + +async def get_remote_file_service() -> RemoteFileService: + settings: Settings = get_settings() + return RemoteFileService( + remote_client_service=await get_remote_client_service(), + agent_auth_header=settings.remote_client_agent_auth_header, + agent_auth_scheme=settings.remote_client_agent_auth_scheme, + agent_auth_token=settings.remote_client_agent_auth_token, + ) diff --git a/webui/backend/app/services/remote_file_service.py b/webui/backend/app/services/remote_file_service.py new file mode 100644 index 0000000..bfb727d --- /dev/null +++ b/webui/backend/app/services/remote_file_service.py @@ -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()) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index bed6a8c..27b01ec 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/test_api_remote_file_ops_golden.py b/webui/backend/tests/golden/test_api_remote_file_ops_golden.py new file mode 100644 index 0000000..3749cbc --- /dev/null +++ b/webui/backend/tests/golden/test_api_remote_file_ops_golden.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +import asyncio +import base64 +import os +import sys +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.api.errors import AppError +from backend.app.dependencies import get_browse_service, get_remote_file_service +from backend.app.db.remote_client_repository import RemoteClientRepository +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.browse_service import BrowseService +from backend.app.services.remote_client_service import RemoteClientService +from backend.app.services.remote_file_service import RemoteFileService + + +PNG_1X1 = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC" +) + + +class _StubRemoteFileService(RemoteFileService): + def __init__( + self, + remote_client_service: RemoteClientService, + *, + payloads: dict[tuple[str, str, str, str], dict], + streams: dict[tuple[str, str, str], dict], + failing_client_ids: set[str], + ): + super().__init__( + remote_client_service=remote_client_service, + agent_auth_header="Authorization", + agent_auth_scheme="Bearer", + agent_auth_token="agent-secret", + ) + self._payloads = payloads + self._streams = streams + self._failing_client_ids = failing_client_ids + + def _request_json(self, *, client, endpoint_path: str, params: dict[str, str]) -> dict: + if client.client_id in self._failing_client_ids: + 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}, + ) + return self._payloads[(client.client_id, endpoint_path, params["share"], params.get("path", ""))] + + def prepare_download(self, paths: list[str]) -> dict: + resolved = self._resolve_remote_path(paths[0]) + item = self._stream_item(resolved.client.client_id, resolved.share_key, resolved.relative_path, resolved.name) + return { + "content": self._bytes_iter(item["content"]), + "headers": {"Content-Disposition": item["headers"]["content-disposition"]}, + "content_type": item["headers"]["content-type"], + } + + def prepare_image_stream(self, path: str) -> dict: + resolved = self._resolve_remote_path(path) + item = self._stream_item(resolved.client.client_id, resolved.share_key, resolved.relative_path, resolved.name) + return { + "content": self._bytes_iter(item["content"]), + "headers": {"Content-Length": item["headers"]["content-length"]}, + "content_type": item["headers"]["content-type"], + } + + def _stream_item(self, client_id: str, share_key: str, relative_path: str, default_name: str) -> dict: + if client_id in self._failing_client_ids: + raise AppError( + code="remote_client_unreachable", + message=f"Remote client '{default_name}' is unreachable", + status_code=502, + details={"client_id": client_id}, + ) + return self._streams[(client_id, share_key, relative_path)] + + @staticmethod + async def _bytes_iter(payload: bytes): + yield payload + + +class RemoteFileOpsApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.volumes_root = Path(self.temp_dir.name) / "Volumes" + self.volumes_root.mkdir(parents=True, exist_ok=True) + self.storage_root = self.volumes_root / "8TB" + self.storage_root.mkdir(parents=True, exist_ok=True) + local_file = self.storage_root / "local.txt" + local_file.write_text("local", encoding="utf-8") + mtime = 1710000000 + os.utime(local_file, (mtime, mtime)) + + repository = RemoteClientRepository(str(Path(self.temp_dir.name) / "remote-clients.db")) + now_iso = "2026-03-26T12:00:00Z" + repository.upsert_client( + client_id="client-123", + display_name="Jan MacBook", + platform="macos", + agent_version="1.1.0", + endpoint="http://agent.test", + shares=[{"key": "downloads", "label": "Downloads"}], + now_iso=now_iso, + ) + repository.upsert_client( + client_id="broken-client", + display_name="Offline iMac", + platform="macos", + agent_version="1.1.0", + endpoint="http://broken.test", + shares=[{"key": "downloads", "label": "Downloads"}], + now_iso=now_iso, + ) + remote_client_service = RemoteClientService( + repository=repository, + registration_token="secret-token", + offline_timeout_seconds=60, + now=lambda: datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc), + ) + remote_file_service = _StubRemoteFileService( + remote_client_service, + payloads={ + ( + "client-123", + "/api/info", + "downloads", + "notes.md", + ): { + "name": "notes.md", + "kind": "file", + "size": 13, + "modified": "2026-03-26T12:00:00Z", + "content_type": "text/markdown", + "extension": ".md", + "width": None, + "height": None, + "owner": None, + "group": None, + }, + ( + "client-123", + "/api/read", + "downloads", + "notes.md", + ): { + "name": "notes.md", + "content_type": "text/markdown", + "encoding": "utf-8", + "truncated": False, + "size": 13, + "modified": "2026-03-26T12:00:00Z", + "content": "# title\nhello", + }, + }, + streams={ + ( + "client-123", + "downloads", + "notes.md", + ): { + "headers": { + "content-type": "text/markdown; charset=utf-8", + "content-disposition": 'attachment; filename="notes.md"', + "content-length": "13", + }, + "content": b"# title\nhello", + }, + ( + "client-123", + "downloads", + "pixel.png", + ): { + "headers": { + "content-type": "image/png", + "content-disposition": 'attachment; filename="pixel.png"', + "content-length": str(len(PNG_1X1)), + }, + "content": PNG_1X1, + }, + }, + failing_client_ids={"broken-client"}, + ) + browse_service = BrowseService( + path_guard=PathGuard({"storage1": str(self.storage_root)}), + filesystem=FilesystemAdapter(), + ) + + async def _override_remote_file_service() -> RemoteFileService: + return remote_file_service + + async def _override_browse_service() -> BrowseService: + return browse_service + + app.dependency_overrides[get_remote_file_service] = _override_remote_file_service + app.dependency_overrides[get_browse_service] = _override_browse_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _request(self, method: str, url: str, *, params: dict | list[tuple[str, str]] | None = None) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.request(method, url, params=params) + + return asyncio.run(_run()) + + def test_remote_info_view_image_and_download_work(self) -> None: + info_response = self._request("GET", "/api/files/info", params={"path": "/Clients/client-123/downloads/notes.md"}) + self.assertEqual(info_response.status_code, 200) + self.assertEqual( + info_response.json(), + { + "name": "notes.md", + "path": "/Clients/client-123/downloads/notes.md", + "type": "file", + "size": 13, + "modified": "2026-03-26T12:00:00Z", + "root": "/Clients/client-123/downloads", + "extension": ".md", + "content_type": "text/markdown", + "owner": None, + "group": None, + "width": None, + "height": None, + }, + ) + + view_response = self._request("GET", "/api/files/view", params={"path": "/Clients/client-123/downloads/notes.md"}) + self.assertEqual(view_response.status_code, 200) + self.assertEqual(view_response.json()["content"], "# title\nhello") + self.assertEqual(view_response.json()["content_type"], "text/markdown") + + image_response = self._request("GET", "/api/files/image", params={"path": "/Clients/client-123/downloads/pixel.png"}) + self.assertEqual(image_response.status_code, 200) + self.assertEqual(image_response.headers.get("content-type"), "image/png") + self.assertEqual(image_response.content, PNG_1X1) + + download_response = self._request("GET", "/api/files/download", params=[("path", "/Clients/client-123/downloads/notes.md")]) + self.assertEqual(download_response.status_code, 200) + self.assertEqual(download_response.content, b"# title\nhello") + self.assertIn('attachment; filename="notes.md"', download_response.headers.get("content-disposition", "")) + + def test_remote_failure_stays_local_and_volumes_behavior_is_unchanged(self) -> None: + failed_response = self._request("GET", "/api/files/info", params={"path": "/Clients/broken-client/downloads/notes.md"}) + self.assertEqual(failed_response.status_code, 502) + self.assertEqual(failed_response.json()["error"]["code"], "remote_client_unreachable") + + volumes_response = self._request("GET", "/api/browse", params={"path": "/Volumes/8TB"}) + self.assertEqual(volumes_response.status_code, 200) + self.assertEqual(volumes_response.json()["path"], "/Volumes/8TB") + self.assertEqual([item["name"] for item in volumes_response.json()["files"]], ["local.txt"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index e24fa8e..7cc282f 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -459,6 +459,23 @@ function isOpenableSelection(item) { return isImageSelection(item) || isVideoSelection(item); } +function isTextPreviewSelection(item) { + if (!item || item.kind !== "file") { + return false; + } + const lower = (item.name || "").toLowerCase(); + if (lower === "dockerfile" || lower === "containerfile") { + return true; + } + return [".txt", ".log", ".ini", ".cfg", ".conf", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html"].some((suffix) => + lower.endsWith(suffix) + ); +} + +function isRemoteViewableSelection(item) { + return isImageSelection(item) || isTextPreviewSelection(item); +} + function isZipDownloadSelection(items) { return items.length > 1 || (items.length === 1 && items[0].kind === "directory"); } @@ -778,16 +795,17 @@ function openContextMenu(pane, entry, event) { contextMenuState.anchorPath = entry.path; const isMulti = items.length > 1; - const openableSingle = items.length === 1 && (!remoteSelection || items[0].kind === "directory") && isOpenableSelection(items[0]); + const openableSingle = + items.length === 1 && (remoteSelection ? items[0].kind === "directory" || isRemoteViewableSelection(items[0]) : isOpenableSelection(items[0])); const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]); - const downloadableSelection = items.length > 0 && !remoteSelection; + const downloadableSelection = items.length === 1 && items[0].kind === "file"; elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.disabled = !openableSingle; elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection); elements.editButton.disabled = !editableSingle; - elements.downloadButton.classList.toggle("hidden", remoteSelection); + elements.downloadButton.classList.remove("hidden"); elements.downloadButton.disabled = !downloadableSelection; elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection); elements.duplicateButton.classList.remove("hidden"); @@ -798,8 +816,8 @@ function openContextMenu(pane, entry, event) { elements.moveButton.disabled = remoteSelection || items.length === 0; elements.deleteButton.classList.remove("hidden"); elements.deleteButton.disabled = remoteSelection || items.length === 0; - elements.propertiesButton.classList.toggle("hidden", remoteSelection); - elements.propertiesButton.disabled = remoteSelection || items.length === 0; + elements.propertiesButton.classList.remove("hidden"); + elements.propertiesButton.disabled = items.length === 0; const menuWidth = 220; const menuHeight = 120; @@ -960,17 +978,23 @@ async function startDownloadSelected() { setStatus(`Download started: ${task.destination}`); return; } - const { blob, fileName } = await downloadFileRequest(selectedPaths); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = fileName || selected.name; - document.body.append(anchor); - anchor.click(); - anchor.remove(); - URL.revokeObjectURL(url); - markSingleFileDownloadRequested(anchor.download, selected.path); - setStatus(`Download requested: ${anchor.download}`); + let fileName = selected.name; + if (isRemoteBrowsePath(selected.path)) { + fileName = startDirectSingleFileDownload(selected.path, selected.name).fileName || selected.name; + } else { + const response = await downloadFileRequest(selectedPaths); + const url = URL.createObjectURL(response.blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = response.fileName || selected.name; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + fileName = anchor.download || selected.name; + } + markSingleFileDownloadRequested(fileName, selected.path); + setStatus(`Download requested: ${fileName}`); } catch (err) { if (zipDownload) { if (err.code === "download_cancelled") { @@ -1279,6 +1303,18 @@ async function downloadFileRequest(paths) { }; } +function startDirectSingleFileDownload(path, fallbackName) { + const anchor = document.createElement("a"); + anchor.href = `/api/files/download?${new URLSearchParams({ path }).toString()}`; + anchor.download = fallbackName || ""; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + return { + fileName: anchor.download || fallbackName || null, + }; +} + async function createArchiveDownloadTask(paths) { return apiRequest("POST", "/api/files/download/archive-prepare", { paths }); } @@ -2108,7 +2144,8 @@ function updateActionButtons() { const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath); - document.getElementById("view-btn").disabled = remoteBrowse || !exactlyOne || !allFiles; + const remoteViewable = exactlyOne && isRemoteViewableSelection(selectedItems[0] || null); + document.getElementById("view-btn").disabled = remoteBrowse ? !remoteViewable : !exactlyOne || !allFiles; document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne; document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection; @@ -4691,6 +4728,14 @@ function openViewer() { return; } const selected = selectedItems[0]; + if (isRemoteBrowsePath(selected.path)) { + if (isImageSelection(selected)) { + openImageViewer(); + return; + } + openTextViewer(); + return; + } if (isImageSelection(selected)) { openImageViewer(); return; @@ -4792,7 +4837,7 @@ function openCurrentDirectory() { openImageViewer(); return; } - if (isVideoSelection(item)) { + if (!isRemoteBrowsePath(item.path) && isVideoSelection(item)) { openVideoViewer(); } }