From 9778dc6c33e2e0514deccb8028402f5a96f722d4 Mon Sep 17 00:00:00 2001 From: kodi Date: Fri, 27 Mar 2026 15:16:01 +0100 Subject: [PATCH] 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. --- finder_commander/app/main.py | 148 +++++- finder_commander/test_agent_file_endpoints.py | 79 ++++ .../__pycache__/dependencies.cpython-313.pyc | Bin 8978 -> 9431 bytes .../__pycache__/routes_files.cpython-313.pyc | Bin 8699 -> 9909 bytes webui/backend/app/api/routes_files.py | 20 +- webui/backend/app/dependencies.py | 11 + .../app/services/remote_file_service.py | 432 ++++++++++++++++++ webui/backend/data/tasks.db | Bin 421888 -> 421888 bytes .../golden/test_api_remote_file_ops_golden.py | 269 +++++++++++ webui/html/app.js | 81 +++- 10 files changed, 1011 insertions(+), 29 deletions(-) create mode 100644 finder_commander/test_agent_file_endpoints.py create mode 100644 webui/backend/app/services/remote_file_service.py create mode 100644 webui/backend/tests/golden/test_api_remote_file_ops_golden.py 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 9ef1b3c15d3ee34f3cf6a013fa1c683459f36034..2cd0e6e865f1a3cf0e8eb9e8aecf237c56f59658 100644 GIT binary patch delta 2690 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 delta 2886 zcmZ`*OKcNY6rJ(kj(@S^Ph!Wh~Z3>FY=xH?mhSW z?wiT&H~-#i-Y}UA9ISW0{U#B2T{XM;>sQ-v4Es5O6SUL*sQ?KuUN_x56(m8%>!(|$ zT1l&)izu5p!64L(@}w=&HhY-f(%IFj zM>&Ez7^#OMW>k%?QK6*@H6Z(G#Rgte8@(7;cui=lLJrEvb_r>6?GiewKw%jas(?DH zKtnR9s{-n-0u2L5nz^2ct8@lqLT?pnT9YY9`PU0%V{z*DIQcJ1(Nr|z=}@JVa|(LT>EJc=ywwrbf&p7| z3PFPnDPPe!WfB|7)||418&NLsA?=|rc{sqYYuh}NpeY!ZpiQx8?t_e2(9S2tSSp_h zsp*T_UY)8`4DGkMC(skLOFEK*uACm31^IsXW#d3t(+r%|^|Ph@fu;RseBIK%ZW%l` z`K!fGM;#ve)Ha~LFkzXfrSlaM(vD7Isx+uotA|Wh|evByR0#jBIOw(g7?;fT*gs2)Ip;AxVQBcWt zYjf4hiMW_1*ib@myMA-*MQ&TwH{F3YdD{M?YGo$AKoVejp8n~cG#&?TRnwPPik%W+ zsLp5>KoC;Ut_DvRSpvN0w zrIV>xB1zJKy+MESO!CL6*L&?S%NeUF)QazC#QB^Uk1nO-iG_q152*^OY;0MSCXbc; z9w4`k2O%g$a+Ch!J$RP&(~2_I)F$8x8Y1TMBt>dr11~KGv2B5nEN<mEKa( zuj-SDZ0i}e zp@MDs z6_xOOVzw34@GNRLvzKX#I! 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 bed6a8c779f5c93eee3a607f0b23aee7df7f91a5..27b01ecadd40c152729364302fe57edb7e32970f 100644 GIT binary patch delta 894 zcma))Pe>F|7{F(~nQ>=k$6d`z7mJksn$7Oa?9A@$29VQ zs9S;{B!$5XPtmD^e~_qm3$bgGLIibCZ<42|4qc*Vc7sqnczNIZ{{P4u^upuzTmA}JwJ3W`OKsv4S_00Z0QkGP#qeY5eh zG?J1~igxE19yfNtDpYtDiAQ!nr-^sWoA$POCCpw056N?L+ys~9+BpUF#=(bMrXKiBqP2Dq$SEi4IvR=tJGGDmypW{2%Y-RtBj6c~ zK^odHo1suGe#Q_V(fHLGV$JuHW{5H#TOR{F;-b(z^jE{PF=*~F^E zykz`N2|bbtn|dlE>YAyGYD%{3n`(*~)sj?M4Ji@H0>g)GtwgxgI|AaMsE8BnbPQdcCNnD`4vUiUWu delta 430 zcmZp8AldLha)LCY?nD`9M%|4G73Zf%6)|(MFfcHvPB(~Wl4`oJ?E)iX0XtA&5~B{o zb{+>N2}XX#DU4OTd^}%xZt)zDWHz1NxSdI4y0asb^K_|LCdFxKEUZk7>$k6SWD??@ z-Vn(oIXyX^$!U6TBoohc-dHBV>2GJTvTwKdWje&f&hv$#8)UFZ023dh7=Imq3cnY> z0p~f+Eu6DB8#vQAeK?J#Cj>G{Zg&l2TF=eIw{E&Z0ISI41OYja10}aR#xnh3oL*SO z9LQy2Wnf`tY8b^R57HspzBZm|``UQsZ~>NPrcDgn1qzti8QJ$REoSOsD%>ts$ZWz0 zG$sRN4Da^+Ma-^enVne$r?We-)N^n$a9rb9!_mAsP@tJ*`tr*x8rv;hSx&!Vwq@g( z&Thb}yWPxybpzYdEgb12jF1wU9-wi}qSUU@0P!P&*YAIp1_XAut{cP`f%E^W>( vpgBdH+q*Q`w8i+0tqjerj4btxOfAd}45uHqXVXIy+y394?UOz~s=_D$t}u6z 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(); } }