Add Phase 3 remote read-only file operations

Introduce dedicated remote file facade for /Clients paths, add agent read/download endpoints, enable remote view/properties/download/image preview in the web UI, and keep remote write operations disabled.
This commit is contained in:
kodi
2026-03-27 15:16:01 +01:00
parent 2fa4a0b291
commit 9778dc6c33
10 changed files with 1011 additions and 29 deletions
+139 -9
View File
@@ -3,16 +3,37 @@ from __future__ import annotations
import json import json
import mimetypes import mimetypes
import os import os
import struct
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import FileResponse, JSONResponse
APP_NAME = "Finder Commander Remote Agent" APP_NAME = "Finder Commander Remote Agent"
DEFAULT_PORT = 8765 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) @dataclass(frozen=True)
@@ -84,10 +105,11 @@ def require_agent_auth(request: Request) -> None:
return return
authorization = request.headers.get("authorization", "").strip() authorization = request.headers.get("authorization", "").strip()
if authorization != f"Bearer {config.agent_access_token}": if authorization != f"Bearer {config.agent_access_token}":
raise HTTPException( raise_agent_error(
status_code=403, status_code=403,
detail={ code="invalid_agent_token",
"message": "Invalid agent token", message="Invalid agent token",
extra={
"config_path": str(config.config_path) if config.config_path else None, "config_path": str(config.config_path) if config.config_path else None,
"client_id": config.client_id or None, "client_id": config.client_id or None,
"display_name": config.display_name 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: def get_share_root(share: str) -> Path:
config = get_runtime_config() config = get_runtime_config()
normalized_share = (share or "").strip() normalized_share = (share or "").strip()
if normalized_share not in config.shares: 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) return Path(config.shares[normalized_share]).expanduser().resolve(strict=False)
@@ -107,7 +136,8 @@ def ensure_within_root(root: Path, candidate: Path) -> Path:
try: try:
candidate.relative_to(root) candidate.relative_to(root)
except ValueError as exc: 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 return candidate
@@ -115,11 +145,11 @@ def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) ->
root = get_share_root(share) root = get_share_root(share)
normalized = (raw_path or "").strip().replace("\\", "/") normalized = (raw_path or "").strip().replace("\\", "/")
if normalized.startswith("/") or any(part == ".." for part in normalized.split("/")): 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 = (root / normalized).resolve(strict=False)
candidate = ensure_within_root(root, candidate) candidate = ensure_within_root(root, candidate)
if must_exist and not candidate.exists(): 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 return candidate
@@ -137,6 +167,7 @@ def info_payload(path: Path, *, share: str, raw_path: str) -> dict:
stat_result = path.lstat() stat_result = path.lstat()
kind = "directory" if path.is_dir() else "file" kind = "directory" if path.is_dir() else "file"
mime, _ = mimetypes.guess_type(path.name) mime, _ = mimetypes.guess_type(path.name)
width, height = image_dimensions(path) if path.is_file() else (None, None)
return { return {
"share": share, "share": share,
"path": raw_path.strip().replace("\\", "/").strip("/"), "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, "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"), "modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
"content_type": mime or "application/octet-stream", "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, "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: try:
children = list(path.iterdir()) children = list(path.iterdir())
except PermissionError as exc: 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 = [] filtered = []
for child in children: for child in children:
if not show_hidden and child.name.startswith("."): 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] 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("<HH", header[6:10])
if suffix == ".bmp":
with path.open("rb") as handle:
header = handle.read(26)
if len(header) < 26 or header[:2] != b"BM":
return None, None
width, height = struct.unpack("<ii", header[18:26])
return abs(width), abs(height)
except (OSError, ValueError, struct.error):
return None, None
return None, None
app = FastAPI(title=APP_NAME) app = FastAPI(title=APP_NAME)
@@ -220,6 +316,40 @@ def api_info(request: Request, share: str, path: str = "") -> dict:
return info_payload(target, share=share.strip(), raw_path=path) 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) @app.exception_handler(HTTPException)
async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse: async def http_exception_handler(_: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse(status_code=exc.status_code, content={"ok": False, "detail": exc.detail}) return JSONResponse(status_code=exc.status_code, content={"ok": False, "detail": exc.detail})
@@ -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()
+18 -2
View File
@@ -5,10 +5,11 @@ from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask 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.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.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.delete_task_service import DeleteTaskService from backend.app.services.delete_task_service import DeleteTaskService
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.remote_file_service import RemoteFileService
router = APIRouter(prefix="/files") router = APIRouter(prefix="/files")
@@ -54,7 +55,10 @@ async def view(
path: str, path: str,
for_edit: bool = False, for_edit: bool = False,
service: FileOpsService = Depends(get_file_ops_service), service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> ViewResponse: ) -> 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) return service.view(path=path, for_edit=for_edit)
@@ -62,7 +66,10 @@ async def view(
async def info( async def info(
path: str, path: str,
service: FileOpsService = Depends(get_file_ops_service), service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> FileInfoResponse: ) -> FileInfoResponse:
if remote_service.handles_path(path):
return remote_service.info(path=path)
return service.info(path=path) return service.info(path=path)
@@ -70,8 +77,9 @@ async def info(
async def download( async def download(
path: list[str] = Query(...), path: list[str] = Query(...),
service: FileOpsService = Depends(get_file_ops_service), service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> StreamingResponse: ) -> 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( response = StreamingResponse(
prepared["content"], prepared["content"],
headers=prepared["headers"], headers=prepared["headers"],
@@ -143,7 +151,15 @@ async def pdf(
async def image( async def image(
path: str, path: str,
service: FileOpsService = Depends(get_file_ops_service), service: FileOpsService = Depends(get_file_ops_service),
remote_service: RemoteFileService = Depends(get_remote_file_service),
) -> StreamingResponse: ) -> 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) prepared = service.prepare_image_stream(path=path)
return StreamingResponse( return StreamingResponse(
prepared["content"], prepared["content"],
+11
View File
@@ -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.move_task_service import MoveTaskService
from backend.app.services.remote_browse_service import RemoteBrowseService from backend.app.services.remote_browse_service import RemoteBrowseService
from backend.app.services.remote_client_service import RemoteClientService 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.search_service import SearchService
from backend.app.services.settings_service import SettingsService from backend.app.services.settings_service import SettingsService
from backend.app.services.task_service import TaskService 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_scheme=settings.remote_client_agent_auth_scheme,
agent_auth_token=settings.remote_client_agent_auth_token, 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,
)
@@ -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())
Binary file not shown.
@@ -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()
+57 -12
View File
@@ -459,6 +459,23 @@ function isOpenableSelection(item) {
return isImageSelection(item) || isVideoSelection(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) { function isZipDownloadSelection(items) {
return items.length > 1 || (items.length === 1 && items[0].kind === "directory"); return items.length > 1 || (items.length === 1 && items[0].kind === "directory");
} }
@@ -778,16 +795,17 @@ function openContextMenu(pane, entry, event) {
contextMenuState.anchorPath = entry.path; contextMenuState.anchorPath = entry.path;
const isMulti = items.length > 1; 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 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.scope.textContent = isMulti ? "Multi-selection" : "Single item";
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.classList.toggle("hidden", isMulti);
elements.openButton.disabled = !openableSingle; elements.openButton.disabled = !openableSingle;
elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection); elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection);
elements.editButton.disabled = !editableSingle; elements.editButton.disabled = !editableSingle;
elements.downloadButton.classList.toggle("hidden", remoteSelection); elements.downloadButton.classList.remove("hidden");
elements.downloadButton.disabled = !downloadableSelection; elements.downloadButton.disabled = !downloadableSelection;
elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection); elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection);
elements.duplicateButton.classList.remove("hidden"); elements.duplicateButton.classList.remove("hidden");
@@ -798,8 +816,8 @@ function openContextMenu(pane, entry, event) {
elements.moveButton.disabled = remoteSelection || items.length === 0; elements.moveButton.disabled = remoteSelection || items.length === 0;
elements.deleteButton.classList.remove("hidden"); elements.deleteButton.classList.remove("hidden");
elements.deleteButton.disabled = remoteSelection || items.length === 0; elements.deleteButton.disabled = remoteSelection || items.length === 0;
elements.propertiesButton.classList.toggle("hidden", remoteSelection); elements.propertiesButton.classList.remove("hidden");
elements.propertiesButton.disabled = remoteSelection || items.length === 0; elements.propertiesButton.disabled = items.length === 0;
const menuWidth = 220; const menuWidth = 220;
const menuHeight = 120; const menuHeight = 120;
@@ -960,17 +978,23 @@ async function startDownloadSelected() {
setStatus(`Download started: ${task.destination}`); setStatus(`Download started: ${task.destination}`);
return; return;
} }
const { blob, fileName } = await downloadFileRequest(selectedPaths); let fileName = selected.name;
const url = URL.createObjectURL(blob); 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"); const anchor = document.createElement("a");
anchor.href = url; anchor.href = url;
anchor.download = fileName || selected.name; anchor.download = response.fileName || selected.name;
document.body.append(anchor); document.body.append(anchor);
anchor.click(); anchor.click();
anchor.remove(); anchor.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
markSingleFileDownloadRequested(anchor.download, selected.path); fileName = anchor.download || selected.name;
setStatus(`Download requested: ${anchor.download}`); }
markSingleFileDownloadRequested(fileName, selected.path);
setStatus(`Download requested: ${fileName}`);
} catch (err) { } catch (err) {
if (zipDownload) { if (zipDownload) {
if (err.code === "download_cancelled") { 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) { async function createArchiveDownloadTask(paths) {
return apiRequest("POST", "/api/files/download/archive-prepare", { paths }); return apiRequest("POST", "/api/files/download/archive-prepare", { paths });
} }
@@ -2108,7 +2144,8 @@ function updateActionButtons() {
const exactlyOne = count === 1; const exactlyOne = count === 1;
const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file");
const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath); 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("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne; document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne;
document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection; document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection;
@@ -4691,6 +4728,14 @@ function openViewer() {
return; return;
} }
const selected = selectedItems[0]; const selected = selectedItems[0];
if (isRemoteBrowsePath(selected.path)) {
if (isImageSelection(selected)) {
openImageViewer();
return;
}
openTextViewer();
return;
}
if (isImageSelection(selected)) { if (isImageSelection(selected)) {
openImageViewer(); openImageViewer();
return; return;
@@ -4792,7 +4837,7 @@ function openCurrentDirectory() {
openImageViewer(); openImageViewer();
return; return;
} }
if (isVideoSelection(item)) { if (!isRemoteBrowsePath(item.path) && isVideoSelection(item)) {
openVideoViewer(); openVideoViewer();
} }
} }