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:
@@ -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()
|
||||||
Binary file not shown.
Binary file not shown.
@@ -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"],
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user