Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54e56ab0d8 | |||
| 9778dc6c33 | |||
| 2fa4a0b291 | |||
| 4062cbf6c8 | |||
| 841318c9e2 | |||
| 684f52be4d |
@@ -18,7 +18,7 @@ RUN mkdir -p /app/backend /app/html /app/conf /Volumes/8TB /Volumes/8TB_RAID1
|
|||||||
|
|
||||||
# Installeer een lichtgewicht Python API framework (FastAPI)
|
# Installeer een lichtgewicht Python API framework (FastAPI)
|
||||||
# We gebruiken --break-system-packages omdat we in een container zitten
|
# We gebruiken --break-system-packages omdat we in een container zitten
|
||||||
RUN pip3 install fastapi uvicorn python-multipart --break-system-packages
|
RUN pip3 install fastapi uvicorn python-multipart httpx --break-system-packages
|
||||||
|
|
||||||
# Exposeer de poort voor de webinterface
|
# Exposeer de poort voor de webinterface
|
||||||
EXPOSE 8030
|
EXPOSE 8030
|
||||||
|
|||||||
+279
-441
@@ -1,522 +1,360 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import fnmatch
|
import json
|
||||||
import html
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import secrets
|
import struct
|
||||||
import shutil
|
from dataclasses import dataclass
|
||||||
import stat
|
from datetime import datetime, timezone
|
||||||
import time
|
from functools import lru_cache
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, Optional
|
|
||||||
|
|
||||||
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
APP_NAME = "Finder Commander"
|
APP_NAME = "Finder Commander Remote Agent"
|
||||||
HOME_ROOT = Path.home().resolve()
|
DEFAULT_PORT = 8765
|
||||||
TRASH_DIR = HOME_ROOT / ".Trash"
|
TEXT_PREVIEW_MAX_BYTES = 256 * 1024
|
||||||
MAX_TEXT_PREVIEW_BYTES = 2 * 1024 * 1024
|
TEXT_CONTENT_TYPES = {
|
||||||
CSRF_TOKEN = secrets.token_urlsafe(32)
|
".txt": "text/plain",
|
||||||
|
".log": "text/plain",
|
||||||
app = FastAPI(title=APP_NAME)
|
".conf": "text/plain",
|
||||||
app.mount("/static", StaticFiles(directory=str(Path(__file__).parent / "static")), name="static")
|
".ini": "text/plain",
|
||||||
templates = Jinja2Templates(directory=str(Path(__file__).parent / "templates"))
|
".cfg": "text/plain",
|
||||||
|
".md": "text/markdown",
|
||||||
|
".yml": "text/yaml",
|
||||||
class PathsPayload(BaseModel):
|
".yaml": "text/yaml",
|
||||||
paths: list[str] = Field(default_factory=list)
|
".json": "application/json",
|
||||||
destination_dir: Optional[str] = None
|
".js": "text/javascript",
|
||||||
|
".py": "text/x-python",
|
||||||
|
".css": "text/css",
|
||||||
class RenamePayload(BaseModel):
|
".html": "text/html",
|
||||||
path: str
|
}
|
||||||
new_name: str
|
SPECIAL_TEXT_FILENAMES = {
|
||||||
|
"dockerfile": "text/plain",
|
||||||
|
"containerfile": "text/plain",
|
||||||
class DeletePayload(BaseModel):
|
|
||||||
paths: list[str] = Field(default_factory=list)
|
|
||||||
mode: Literal["trash", "permanent"] = "trash"
|
|
||||||
|
|
||||||
|
|
||||||
class CommandPayload(BaseModel):
|
|
||||||
command: str
|
|
||||||
cwd: str = ""
|
|
||||||
|
|
||||||
PathsPayload.model_rebuild()
|
|
||||||
RenamePayload.model_rebuild()
|
|
||||||
DeletePayload.model_rebuild()
|
|
||||||
CommandPayload.model_rebuild()
|
|
||||||
|
|
||||||
TEXT_SUFFIXES = {
|
|
||||||
".md",
|
|
||||||
".txt",
|
|
||||||
".py",
|
|
||||||
".js",
|
|
||||||
".ts",
|
|
||||||
".tsx",
|
|
||||||
".jsx",
|
|
||||||
".css",
|
|
||||||
".html",
|
|
||||||
".json",
|
|
||||||
".yaml",
|
|
||||||
".yml",
|
|
||||||
".toml",
|
|
||||||
".ini",
|
|
||||||
".env",
|
|
||||||
".log",
|
|
||||||
".xml",
|
|
||||||
".sh",
|
|
||||||
".zsh",
|
|
||||||
".bash",
|
|
||||||
".c",
|
|
||||||
".cpp",
|
|
||||||
".h",
|
|
||||||
".java",
|
|
||||||
".go",
|
|
||||||
".rs",
|
|
||||||
".sql",
|
|
||||||
".conf",
|
|
||||||
".service",
|
|
||||||
".container",
|
|
||||||
".network",
|
|
||||||
".pod",
|
|
||||||
".kube",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentRuntimeConfig:
|
||||||
|
config_path: Path | None
|
||||||
|
agent_access_token: str
|
||||||
|
shares: dict[str, str]
|
||||||
|
display_name: str
|
||||||
|
endpoint: str
|
||||||
|
client_id: str
|
||||||
|
platform: str
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_config_paths() -> list[Path]:
|
||||||
def rel_from_home(path: Path) -> str:
|
candidates: list[Path] = []
|
||||||
return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT))
|
env_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip()
|
||||||
|
if env_path:
|
||||||
|
candidates.append(Path(env_path).expanduser().resolve(strict=False))
|
||||||
|
base_dir = Path(__file__).resolve().parents[1]
|
||||||
|
candidates.append(base_dir / "remote_client_agent.launchd.json")
|
||||||
|
candidates.append(base_dir / "remote_client_agent.example.json")
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _load_raw_config() -> tuple[Path | None, dict]:
|
||||||
def ensure_within_home(candidate: Path) -> Path:
|
for candidate in _candidate_config_paths():
|
||||||
try:
|
if candidate.is_file():
|
||||||
candidate.relative_to(HOME_ROOT)
|
try:
|
||||||
except ValueError as exc:
|
raw = json.loads(candidate.read_text(encoding="utf-8"))
|
||||||
raise HTTPException(status_code=403, detail="Path escapes home directory") from exc
|
except ValueError as exc:
|
||||||
return candidate
|
raise RuntimeError(f"Invalid JSON in config file: {candidate}") from exc
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise RuntimeError(f"Config file must contain a JSON object: {candidate}")
|
||||||
|
return candidate.resolve(strict=False), raw
|
||||||
|
return None, {}
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_runtime_config() -> AgentRuntimeConfig:
|
||||||
|
config_path, raw = _load_raw_config()
|
||||||
|
shares_raw = raw.get("shares", {})
|
||||||
|
shares: dict[str, str] = {}
|
||||||
|
if isinstance(shares_raw, dict):
|
||||||
|
for key, value in shares_raw.items():
|
||||||
|
normalized_key = str(key).strip()
|
||||||
|
normalized_value = str(value).strip()
|
||||||
|
if normalized_key and normalized_value:
|
||||||
|
shares[normalized_key] = normalized_value
|
||||||
|
|
||||||
def sanitize_name(name: str) -> str:
|
return AgentRuntimeConfig(
|
||||||
name = (name or "").strip()
|
config_path=config_path,
|
||||||
if not name or name in {".", ".."} or "/" in name:
|
agent_access_token=os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip()
|
||||||
raise HTTPException(status_code=400, detail="Invalid name")
|
or str(raw.get("agent_access_token", "")).strip(),
|
||||||
return name
|
shares=shares,
|
||||||
|
display_name=str(raw.get("display_name", "")).strip(),
|
||||||
|
endpoint=str(raw.get("public_endpoint", raw.get("endpoint", ""))).strip(),
|
||||||
|
client_id=str(raw.get("client_id", "")).strip(),
|
||||||
|
platform=str(raw.get("platform", "macos")).strip() or "macos",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_agent_auth(request: Request) -> None:
|
||||||
def resolve_user_path(raw_path: Optional[str], *, must_exist: bool = True) -> Path:
|
config = get_runtime_config()
|
||||||
raw_path = (raw_path or "").strip()
|
if not config.agent_access_token:
|
||||||
candidate = (HOME_ROOT / raw_path).resolve(strict=False)
|
|
||||||
candidate = ensure_within_home(candidate)
|
|
||||||
if must_exist and not candidate.exists():
|
|
||||||
raise HTTPException(status_code=404, detail="Path not found")
|
|
||||||
return candidate
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def check_origin(request: Request) -> None:
|
|
||||||
origin = request.headers.get("origin")
|
|
||||||
if not origin:
|
|
||||||
return
|
return
|
||||||
expected = str(request.base_url).rstrip("/")
|
authorization = request.headers.get("authorization", "").strip()
|
||||||
if origin.rstrip("/") != expected:
|
if authorization != f"Bearer {config.agent_access_token}":
|
||||||
raise HTTPException(status_code=403, detail="Origin not allowed")
|
raise_agent_error(
|
||||||
|
status_code=403,
|
||||||
|
code="invalid_agent_token",
|
||||||
|
message="Invalid agent token",
|
||||||
|
extra={
|
||||||
|
"config_path": str(config.config_path) if config.config_path else None,
|
||||||
|
"client_id": config.client_id or None,
|
||||||
|
"display_name": config.display_name or None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_agent_error(status_code: int, code: str, message: str, *, extra: dict | None = None) -> None:
|
||||||
def check_csrf(request: Request) -> None:
|
detail = {"code": code, "message": message}
|
||||||
token = request.headers.get("x-csrf-token")
|
if extra:
|
||||||
if token != CSRF_TOKEN:
|
detail.update(extra)
|
||||||
raise HTTPException(status_code=403, detail="Invalid CSRF token")
|
raise HTTPException(status_code=status_code, detail=detail)
|
||||||
|
|
||||||
|
|
||||||
|
def get_share_root(share: str) -> Path:
|
||||||
def perms_string(mode: int) -> str:
|
config = get_runtime_config()
|
||||||
return stat.filemode(mode)
|
normalized_share = (share or "").strip()
|
||||||
|
if normalized_share not in config.shares:
|
||||||
|
raise_agent_error(404, "path_not_found", "Share not found")
|
||||||
|
return Path(config.shares[normalized_share]).expanduser().resolve(strict=False)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_within_root(root: Path, candidate: Path) -> Path:
|
||||||
def can_preview_text(path: Path) -> bool:
|
try:
|
||||||
if path.is_dir():
|
candidate.relative_to(root)
|
||||||
return False
|
except ValueError as exc:
|
||||||
if path.stat().st_size > MAX_TEXT_PREVIEW_BYTES:
|
_ = exc
|
||||||
return False
|
raise_agent_error(403, "path_traversal_detected", "Path escapes share root")
|
||||||
mime, _ = mimetypes.guess_type(path.name)
|
return candidate
|
||||||
if mime and (
|
|
||||||
mime.startswith("text/")
|
|
||||||
or mime in {"application/json", "application/xml", "application/javascript"}
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return path.suffix.lower() in TEXT_SUFFIXES
|
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path:
|
||||||
|
root = get_share_root(share)
|
||||||
|
normalized = (raw_path or "").strip().replace("\\", "/")
|
||||||
|
if normalized.startswith("/") or any(part == ".." for part in normalized.split("/")):
|
||||||
|
raise_agent_error(400, "invalid_request", "Invalid share-relative path")
|
||||||
|
candidate = (root / normalized).resolve(strict=False)
|
||||||
|
candidate = ensure_within_root(root, candidate)
|
||||||
|
if must_exist and not candidate.exists():
|
||||||
|
raise_agent_error(404, "path_not_found", "Path not found")
|
||||||
|
return candidate
|
||||||
|
|
||||||
def entry_payload(path: Path) -> dict:
|
|
||||||
st = path.lstat()
|
def directory_entry_payload(path: Path) -> dict:
|
||||||
kind = "directory" if path.is_dir() else "file"
|
stat_result = path.lstat()
|
||||||
mime, _ = mimetypes.guess_type(path.name)
|
|
||||||
return {
|
return {
|
||||||
"name": path.name,
|
"name": path.name,
|
||||||
"rel_path": rel_from_home(path),
|
"kind": "directory" if path.is_dir() else "file",
|
||||||
"parent_rel_path": rel_from_home(path.parent),
|
"size": stat_result.st_size,
|
||||||
"kind": kind,
|
"modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
"is_symlink": path.is_symlink(),
|
|
||||||
"size": st.st_size,
|
|
||||||
"modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"),
|
|
||||||
"mime": mime or "application/octet-stream",
|
|
||||||
"perms": perms_string(st.st_mode),
|
|
||||||
"can_preview_text": can_preview_text(path) if path.is_file() else False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def info_payload(path: Path, *, share: str, raw_path: str) -> dict:
|
||||||
|
stat_result = path.lstat()
|
||||||
|
kind = "directory" if path.is_dir() else "file"
|
||||||
|
mime, _ = mimetypes.guess_type(path.name)
|
||||||
|
width, height = image_dimensions(path) if path.is_file() else (None, None)
|
||||||
|
return {
|
||||||
|
"share": share,
|
||||||
|
"path": raw_path.strip().replace("\\", "/").strip("/"),
|
||||||
|
"name": path.name,
|
||||||
|
"kind": kind,
|
||||||
|
"size": None if path.is_dir() else stat_result.st_size,
|
||||||
|
"modified": datetime.fromtimestamp(stat_result.st_mtime, tz=timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||||
|
"content_type": mime or "application/octet-stream",
|
||||||
|
"extension": path.suffix.lower() or None,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"owner": None,
|
||||||
|
"group": None,
|
||||||
|
"config_path": str(get_runtime_config().config_path) if get_runtime_config().config_path else None,
|
||||||
|
}
|
||||||
|
|
||||||
def sorted_entries(path: Path, show_hidden: bool = False) -> list[dict]:
|
|
||||||
|
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("."):
|
||||||
continue
|
continue
|
||||||
filtered.append(child)
|
filtered.append(child)
|
||||||
filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower()))
|
filtered.sort(key=lambda item: (not item.is_dir(), item.name.lower()))
|
||||||
return [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:
|
||||||
def move_to_trash(path: Path) -> Path:
|
lowered = (name or "").lower()
|
||||||
TRASH_DIR.mkdir(parents=True, exist_ok=True)
|
special = SPECIAL_TEXT_FILENAMES.get(lowered)
|
||||||
target = TRASH_DIR / path.name
|
if special:
|
||||||
if target.exists():
|
return special
|
||||||
target = TRASH_DIR / f"{target.stem}-{int(time.time())}{target.suffix}"
|
return TEXT_CONTENT_TYPES.get(Path(name).suffix.lower())
|
||||||
shutil.move(str(path), str(target))
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
|
def read_text_preview(path: Path, *, max_bytes: int) -> dict:
|
||||||
def copy_entry(source: Path, destination_dir: Path) -> Path:
|
size = int(path.stat().st_size)
|
||||||
destination = destination_dir / source.name
|
preview_limit = min(max(1, int(max_bytes)), TEXT_PREVIEW_MAX_BYTES)
|
||||||
if destination.exists():
|
with path.open("rb") as handle:
|
||||||
raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}")
|
raw = handle.read(preview_limit + 1)
|
||||||
if source.is_dir():
|
truncated = size > preview_limit or len(raw) > preview_limit
|
||||||
shutil.copytree(source, destination, symlinks=True)
|
if truncated:
|
||||||
else:
|
raw = raw[:preview_limit]
|
||||||
shutil.copy2(source, destination, follow_symlinks=False)
|
if b"\x00" in raw:
|
||||||
return destination
|
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]:
|
||||||
def move_entry(source: Path, destination_dir: Path) -> Path:
|
suffix = path.suffix.lower()
|
||||||
destination = destination_dir / source.name
|
try:
|
||||||
if destination.exists():
|
if suffix == ".png":
|
||||||
raise HTTPException(status_code=409, detail=f"Destination already exists: {destination.name}")
|
with path.open("rb") as handle:
|
||||||
shutil.move(str(source), str(destination))
|
header = handle.read(24)
|
||||||
return destination
|
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)
|
||||||
def select_paths_or_current(paths: list[str], cwd: str) -> list[Path]:
|
|
||||||
result = [resolve_user_path(p) for p in paths]
|
|
||||||
if not result:
|
|
||||||
raise HTTPException(status_code=400, detail="No paths selected")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
def resolve_from_cwd(cwd_path: Path, raw: str, *, must_exist: bool = True) -> Path:
|
def root() -> dict:
|
||||||
raw = (raw or "").strip()
|
config = get_runtime_config()
|
||||||
candidate = (cwd_path / raw).resolve(strict=False)
|
return {
|
||||||
candidate = ensure_within_home(candidate)
|
"ok": True,
|
||||||
if must_exist and not candidate.exists():
|
"app": APP_NAME,
|
||||||
raise HTTPException(status_code=404, detail="Path not found")
|
"time": _now_iso(),
|
||||||
return candidate
|
"client_id": config.client_id or None,
|
||||||
|
"display_name": config.display_name or None,
|
||||||
|
"config_path": str(config.config_path) if config.config_path else None,
|
||||||
def run_command(command: str, cwd: str) -> dict:
|
"shares": sorted(config.shares.keys()),
|
||||||
command = (command or "").strip()
|
"auth_enabled": bool(config.agent_access_token),
|
||||||
if not command:
|
}
|
||||||
raise HTTPException(status_code=400, detail="Empty command")
|
|
||||||
cwd_path = resolve_user_path(cwd)
|
|
||||||
if not cwd_path.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="CWD is not a directory")
|
|
||||||
|
|
||||||
parts = command.split()
|
|
||||||
verb = parts[0].lower()
|
|
||||||
args = parts[1:]
|
|
||||||
|
|
||||||
if verb == "cd":
|
|
||||||
raw_target = " ".join(args) if args else ""
|
|
||||||
target = resolve_user_path(raw_target) if raw_target.startswith("/") else resolve_from_cwd(cwd_path, raw_target or ".")
|
|
||||||
if not target.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="Target is not a directory")
|
|
||||||
return {"ok": True, "action": "cd", "cwd": rel_from_home(target), "message": str(target)}
|
|
||||||
|
|
||||||
if verb == "mkdir":
|
|
||||||
name = sanitize_name(" ".join(args))
|
|
||||||
target = resolve_from_cwd(cwd_path, name, must_exist=False)
|
|
||||||
target.mkdir(exist_ok=False)
|
|
||||||
return {"ok": True, "action": "mkdir", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"}
|
|
||||||
|
|
||||||
if verb == "touch":
|
|
||||||
name = sanitize_name(" ".join(args))
|
|
||||||
target = resolve_from_cwd(cwd_path, name, must_exist=False)
|
|
||||||
target.touch(exist_ok=False)
|
|
||||||
return {"ok": True, "action": "touch", "cwd": rel_from_home(cwd_path), "message": f"Created {name}"}
|
|
||||||
|
|
||||||
if verb == "select":
|
|
||||||
pattern = " ".join(args).strip() or "*"
|
|
||||||
entries = sorted_entries(cwd_path, show_hidden=True)
|
|
||||||
matches = [e["rel_path"] for e in entries if fnmatch.fnmatch(e["name"], pattern)]
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"action": "select",
|
|
||||||
"cwd": rel_from_home(cwd_path),
|
|
||||||
"message": f"Matched {len(matches)} item(s)",
|
|
||||||
"matches": matches,
|
|
||||||
}
|
|
||||||
|
|
||||||
if verb == "help":
|
|
||||||
return {
|
|
||||||
"ok": True,
|
|
||||||
"action": "help",
|
|
||||||
"cwd": rel_from_home(cwd_path),
|
|
||||||
"message": "Commands: cd <path>, mkdir <name>, touch <name>, select <glob>, help",
|
|
||||||
}
|
|
||||||
|
|
||||||
raise HTTPException(status_code=400, detail="Unsupported command")
|
|
||||||
|
|
||||||
|
|
||||||
@app.middleware("http")
|
|
||||||
async def harden_headers(request: Request, call_next):
|
|
||||||
response = await call_next(request)
|
|
||||||
response.headers["X-Frame-Options"] = "DENY"
|
|
||||||
response.headers["Content-Security-Policy"] = (
|
|
||||||
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
|
|
||||||
"connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
|
|
||||||
)
|
|
||||||
response.headers["Referrer-Policy"] = "no-referrer"
|
|
||||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
def health(request: Request) -> dict:
|
||||||
return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)}
|
require_agent_auth(request)
|
||||||
|
config = get_runtime_config()
|
||||||
|
return {
|
||||||
@app.get("/", response_class=HTMLResponse)
|
"ok": True,
|
||||||
def index(request: Request):
|
"app": APP_NAME,
|
||||||
return templates.TemplateResponse(
|
"time": _now_iso(),
|
||||||
request,
|
"client_id": config.client_id or None,
|
||||||
"index.html",
|
"display_name": config.display_name or None,
|
||||||
{
|
"platform": config.platform,
|
||||||
"app_name": APP_NAME,
|
"endpoint": config.endpoint or None,
|
||||||
"home_root": str(HOME_ROOT),
|
"shares": sorted(config.shares.keys()),
|
||||||
"csrf_token": CSRF_TOKEN,
|
"config_path": str(config.config_path) if config.config_path else None,
|
||||||
},
|
"port_hint": DEFAULT_PORT,
|
||||||
)
|
"auth_enabled": bool(config.agent_access_token),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/list")
|
@app.get("/api/list")
|
||||||
def api_list(path: str = "", show_hidden: bool = False) -> dict:
|
def api_list(request: Request, share: str, path: str = "", show_hidden: bool = False) -> dict:
|
||||||
target = resolve_user_path(path)
|
require_agent_auth(request)
|
||||||
|
target = resolve_share_path(share, path)
|
||||||
if not target.is_dir():
|
if not target.is_dir():
|
||||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||||
return {
|
return {
|
||||||
"cwd": rel_from_home(target),
|
"share": share.strip(),
|
||||||
"absolute": str(target),
|
"path": path.strip().replace("\\", "/").strip("/"),
|
||||||
"parent": "" if target == HOME_ROOT else rel_from_home(target.parent),
|
"entries": list_directory(target, show_hidden=show_hidden),
|
||||||
"entries": sorted_entries(target, show_hidden=show_hidden),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/info")
|
||||||
|
def api_info(request: Request, share: str, path: str = "") -> dict:
|
||||||
|
require_agent_auth(request)
|
||||||
|
target = resolve_share_path(share, path)
|
||||||
|
return info_payload(target, share=share.strip(), raw_path=path)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/read")
|
@app.get("/api/read")
|
||||||
def api_read(path: str) -> dict:
|
def api_read(request: Request, share: str, path: str = "", max_bytes: int = TEXT_PREVIEW_MAX_BYTES) -> dict:
|
||||||
target = resolve_user_path(path)
|
require_agent_auth(request)
|
||||||
|
target = resolve_share_path(share, path)
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
raise HTTPException(status_code=400, detail="Cannot read a directory as text")
|
raise_agent_error(409, "type_conflict", "Source must be a file")
|
||||||
if not can_preview_text(target):
|
if not target.is_file():
|
||||||
raise HTTPException(status_code=415, detail="File is not previewable as text")
|
raise_agent_error(409, "type_conflict", "Unsupported path type for read")
|
||||||
try:
|
content_type = text_content_type_for_name(target.name)
|
||||||
content = target.read_text(encoding="utf-8")
|
if content_type is None:
|
||||||
encoding = "utf-8"
|
raise_agent_error(409, "unsupported_type", "File type is not supported for text preview")
|
||||||
except UnicodeDecodeError:
|
|
||||||
content = target.read_text(encoding="utf-8", errors="replace")
|
|
||||||
encoding = "utf-8 (lossy)"
|
|
||||||
return {
|
return {
|
||||||
"path": rel_from_home(target),
|
|
||||||
"name": target.name,
|
"name": target.name,
|
||||||
"encoding": encoding,
|
"path": path.strip().replace("\\", "/").strip("/"),
|
||||||
"content": content,
|
"content_type": content_type,
|
||||||
"size": target.stat().st_size,
|
**read_text_preview(target, max_bytes=max_bytes),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/meta")
|
|
||||||
def api_meta(path: str) -> dict:
|
|
||||||
target = resolve_user_path(path)
|
|
||||||
payload = entry_payload(target)
|
|
||||||
payload["absolute"] = str(target)
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/download")
|
@app.get("/api/download")
|
||||||
def api_download(path: str):
|
def api_download(request: Request, share: str, path: str = "") -> FileResponse:
|
||||||
target = resolve_user_path(path)
|
require_agent_auth(request)
|
||||||
|
target = resolve_share_path(share, path)
|
||||||
if target.is_dir():
|
if target.is_dir():
|
||||||
raise HTTPException(status_code=400, detail="Cannot download a directory")
|
raise_agent_error(409, "type_conflict", "Source must be a file")
|
||||||
return FileResponse(path=target, filename=target.name)
|
if not target.is_file():
|
||||||
|
raise_agent_error(409, "type_conflict", "Unsupported path type for download")
|
||||||
|
return FileResponse(
|
||||||
@app.get("/api/preview")
|
path=target,
|
||||||
def api_preview(path: str):
|
media_type=mimetypes.guess_type(target.name)[0] or "application/octet-stream",
|
||||||
target = resolve_user_path(path)
|
filename=target.name,
|
||||||
if target.is_dir():
|
)
|
||||||
raise HTTPException(status_code=400, detail="Cannot preview a directory")
|
|
||||||
mime, _ = mimetypes.guess_type(target.name)
|
|
||||||
if not mime or not mime.startswith("image/"):
|
|
||||||
raise HTTPException(status_code=415, detail="Preview only supports images")
|
|
||||||
return FileResponse(path=target, media_type=mime)
|
|
||||||
|
|
||||||
|
|
||||||
@app.put("/api/write")
|
|
||||||
async def api_write(request: Request, path: str = Form(...), content: str = Form(...)) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
target = resolve_user_path(path, must_exist=False)
|
|
||||||
ensure_within_home(target.parent.resolve(strict=False))
|
|
||||||
if target.exists() and target.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="Cannot overwrite a directory")
|
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
tmp = target.with_name(target.name + ".tmp-write")
|
|
||||||
tmp.write_text(content, encoding="utf-8")
|
|
||||||
os.replace(tmp, target)
|
|
||||||
return {"ok": True, "path": rel_from_home(target)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/mkdir")
|
|
||||||
async def api_mkdir(request: Request, path: str = Form(...), name: str = Form(...)) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
base = resolve_user_path(path)
|
|
||||||
if not base.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="Base path is not a directory")
|
|
||||||
child = resolve_user_path(str(Path(rel_from_home(base)) / sanitize_name(name)), must_exist=False)
|
|
||||||
child.mkdir(parents=False, exist_ok=False)
|
|
||||||
return {"ok": True, "path": rel_from_home(child)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/upload")
|
|
||||||
async def api_upload(request: Request, path: str = Form(...), files: list[UploadFile] = File(...)) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
base = resolve_user_path(path)
|
|
||||||
if not base.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="Upload target is not a directory")
|
|
||||||
saved: list[str] = []
|
|
||||||
for upload in files:
|
|
||||||
filename = Path(upload.filename or "").name
|
|
||||||
if not filename:
|
|
||||||
continue
|
|
||||||
destination = resolve_user_path(str(Path(rel_from_home(base)) / filename), must_exist=False)
|
|
||||||
with destination.open("wb") as f:
|
|
||||||
while chunk := await upload.read(1024 * 1024):
|
|
||||||
f.write(chunk)
|
|
||||||
saved.append(rel_from_home(destination))
|
|
||||||
return {"ok": True, "saved": saved}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/rename")
|
|
||||||
async def api_rename(request: Request, payload: RenamePayload) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
source = resolve_user_path(payload.path)
|
|
||||||
destination = resolve_user_path(str(Path(rel_from_home(source.parent)) / sanitize_name(payload.new_name)), must_exist=False)
|
|
||||||
if destination.exists():
|
|
||||||
raise HTTPException(status_code=409, detail="Destination already exists")
|
|
||||||
os.replace(source, destination)
|
|
||||||
return {"ok": True, "old_path": rel_from_home(source), "new_path": rel_from_home(destination)}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/copy")
|
|
||||||
async def api_copy(request: Request, payload: PathsPayload) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
if payload.destination_dir is None:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing destination_dir")
|
|
||||||
destination_dir = resolve_user_path(payload.destination_dir)
|
|
||||||
if not destination_dir.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="Destination is not a directory")
|
|
||||||
results = []
|
|
||||||
for source in select_paths_or_current(payload.paths, payload.destination_dir):
|
|
||||||
copied = copy_entry(source, destination_dir)
|
|
||||||
results.append(rel_from_home(copied))
|
|
||||||
return {"ok": True, "copied": results}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/move")
|
|
||||||
async def api_move(request: Request, payload: PathsPayload) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
if payload.destination_dir is None:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing destination_dir")
|
|
||||||
destination_dir = resolve_user_path(payload.destination_dir)
|
|
||||||
if not destination_dir.is_dir():
|
|
||||||
raise HTTPException(status_code=400, detail="Destination is not a directory")
|
|
||||||
results = []
|
|
||||||
for source in select_paths_or_current(payload.paths, payload.destination_dir):
|
|
||||||
moved = move_entry(source, destination_dir)
|
|
||||||
results.append(rel_from_home(moved))
|
|
||||||
return {"ok": True, "moved": results}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/delete")
|
|
||||||
async def api_delete(request: Request, payload: DeletePayload) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
paths = select_paths_or_current(payload.paths, "")
|
|
||||||
deleted = []
|
|
||||||
for target in paths:
|
|
||||||
if target == HOME_ROOT:
|
|
||||||
raise HTTPException(status_code=400, detail="Refusing to delete home root")
|
|
||||||
if payload.mode == "trash":
|
|
||||||
moved = move_to_trash(target)
|
|
||||||
deleted.append(str(moved))
|
|
||||||
else:
|
|
||||||
if target.is_dir():
|
|
||||||
shutil.rmtree(target)
|
|
||||||
else:
|
|
||||||
target.unlink()
|
|
||||||
deleted.append(rel_from_home(target))
|
|
||||||
return {"ok": True, "mode": payload.mode, "deleted": deleted}
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/command")
|
|
||||||
async def api_command(request: Request, payload: CommandPayload) -> dict:
|
|
||||||
check_origin(request)
|
|
||||||
check_csrf(request)
|
|
||||||
return run_command(payload.command, payload.cwd)
|
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_exception_handler(_: Request, exc: HTTPException):
|
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})
|
||||||
|
|
||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def unhandled_exception_handler(_: Request, exc: Exception):
|
async def unhandled_exception_handler(_: Request, exc: Exception) -> JSONResponse:
|
||||||
return JSONResponse(status_code=500, content={"ok": False, "detail": html.escape(str(exc))})
|
return JSONResponse(status_code=500, content={"ok": False, "detail": str(exc)})
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.webmanager.remote-client-agent</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/bin/python3</string>
|
||||||
|
<string>-u</string>
|
||||||
|
<string>/workspace/webmanager-mvp/finder_commander/remote_client_agent.py</string>
|
||||||
|
<string>--config</string>
|
||||||
|
<string>/workspace/webmanager-mvp/finder_commander/remote_client_agent.launchd.json</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/tmp/com.webmanager.remote-client-agent.out.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/tmp/com.webmanager.remote-client-agent.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"agent_access_token": "change-me-agent-token",
|
||||||
|
"client_id": "",
|
||||||
|
"display_name": "MacBook Pro van Jan",
|
||||||
|
"endpoint": "http://192.168.1.25:8765",
|
||||||
|
"heartbeat_interval_seconds": 20,
|
||||||
|
"platform": "macos",
|
||||||
|
"registration_token": "change-me-registration-token",
|
||||||
|
"shares": {
|
||||||
|
"downloads": "/Users/jan/Downloads",
|
||||||
|
"movies": "/Users/jan/Movies",
|
||||||
|
"pictures": "/Users/jan/Pictures"
|
||||||
|
},
|
||||||
|
"webmanager_base_url": "http://127.0.0.1:8080"
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"agent_access_token": "change-me-agent-token",
|
||||||
|
"client_id": "",
|
||||||
|
"display_name": "MacBook Pro van Jan",
|
||||||
|
"endpoint": "http://192.168.1.25:8765",
|
||||||
|
"heartbeat_interval_seconds": 20,
|
||||||
|
"platform": "macos",
|
||||||
|
"registration_token": "change-me-registration-token",
|
||||||
|
"shares": {
|
||||||
|
"downloads": "/Users/jan/Downloads",
|
||||||
|
"movies": "/Users/jan/Movies",
|
||||||
|
"pictures": "/Users/jan/Pictures"
|
||||||
|
},
|
||||||
|
"webmanager_base_url": "http://127.0.0.1:8080"
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from urllib import error, request
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_VERSION = "1.1.0-phase1"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AgentConfig:
|
||||||
|
config_path: Path
|
||||||
|
webmanager_base_url: str
|
||||||
|
registration_token: str
|
||||||
|
agent_access_token: str
|
||||||
|
display_name: str
|
||||||
|
endpoint: str
|
||||||
|
shares: dict[str, str]
|
||||||
|
heartbeat_interval_seconds: int
|
||||||
|
client_id: str
|
||||||
|
platform: str = "macos"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def normalized_base_url(self) -> str:
|
||||||
|
return self.webmanager_base_url.rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(config_path: Path) -> AgentConfig:
|
||||||
|
raw = json.loads(config_path.read_text(encoding="utf-8"))
|
||||||
|
client_id = str(raw.get("client_id", "")).strip()
|
||||||
|
if not client_id:
|
||||||
|
client_id = str(uuid.uuid4())
|
||||||
|
raw["client_id"] = client_id
|
||||||
|
config_path.write_text(json.dumps(raw, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
shares_raw = raw.get("shares") or {}
|
||||||
|
shares: dict[str, str] = {}
|
||||||
|
if isinstance(shares_raw, dict):
|
||||||
|
for key, value in shares_raw.items():
|
||||||
|
normalized_key = str(key).strip()
|
||||||
|
normalized_value = str(value).strip()
|
||||||
|
if normalized_key and normalized_value:
|
||||||
|
shares[normalized_key] = normalized_value
|
||||||
|
|
||||||
|
if not shares:
|
||||||
|
raise ValueError("config requires at least one share")
|
||||||
|
|
||||||
|
return AgentConfig(
|
||||||
|
config_path=config_path,
|
||||||
|
webmanager_base_url=str(raw.get("webmanager_base_url", "")).strip(),
|
||||||
|
registration_token=str(raw.get("registration_token", "")).strip(),
|
||||||
|
agent_access_token=str(raw.get("agent_access_token", "")).strip(),
|
||||||
|
display_name=str(raw.get("display_name", "")).strip(),
|
||||||
|
endpoint=str(raw.get("public_endpoint", raw.get("endpoint", ""))).strip(),
|
||||||
|
shares=shares,
|
||||||
|
heartbeat_interval_seconds=max(5, int(raw.get("heartbeat_interval_seconds", 20))),
|
||||||
|
client_id=client_id,
|
||||||
|
platform=str(raw.get("platform", "macos")).strip() or "macos",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def require_non_empty(value: str, field: str) -> str:
|
||||||
|
normalized = value.strip()
|
||||||
|
if not normalized:
|
||||||
|
raise ValueError(f"config field '{field}' is required")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def build_register_payload(config: AgentConfig) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"display_name": config.display_name,
|
||||||
|
"platform": config.platform,
|
||||||
|
"agent_version": AGENT_VERSION,
|
||||||
|
"endpoint": config.endpoint,
|
||||||
|
"shares": [{"key": key, "label": key.capitalize()} for key in sorted(config.shares.keys())],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_heartbeat_payload(config: AgentConfig) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"client_id": config.client_id,
|
||||||
|
"agent_version": AGENT_VERSION,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def post_json(url: str, token: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = request.Request(
|
||||||
|
url,
|
||||||
|
method="POST",
|
||||||
|
data=data,
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with request.urlopen(req, timeout=10) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def run_heartbeat_loop(config: AgentConfig, stop_event: threading.Event) -> None:
|
||||||
|
require_non_empty(config.webmanager_base_url, "webmanager_base_url")
|
||||||
|
require_non_empty(config.registration_token, "registration_token")
|
||||||
|
require_non_empty(config.agent_access_token, "agent_access_token")
|
||||||
|
require_non_empty(config.display_name, "display_name")
|
||||||
|
require_non_empty(config.endpoint, "public_endpoint")
|
||||||
|
|
||||||
|
register_url = f"{config.normalized_base_url}/api/clients/register"
|
||||||
|
heartbeat_url = f"{config.normalized_base_url}/api/clients/heartbeat"
|
||||||
|
|
||||||
|
print(f"Starting remote client agent for {config.display_name} ({config.client_id})", flush=True)
|
||||||
|
print(f"Using config: {config.config_path}", flush=True)
|
||||||
|
print("agent_access_token is configured for authenticated agent endpoints", flush=True)
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
post_json(register_url, config.registration_token, build_register_payload(config))
|
||||||
|
print("register ok", flush=True)
|
||||||
|
break
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
print(f"register failed: HTTP {exc.code}", file=sys.stderr, flush=True)
|
||||||
|
except error.URLError as exc:
|
||||||
|
print(f"register failed: {exc.reason}", file=sys.stderr, flush=True)
|
||||||
|
if stop_event.wait(config.heartbeat_interval_seconds):
|
||||||
|
return
|
||||||
|
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
post_json(heartbeat_url, config.registration_token, build_heartbeat_payload(config))
|
||||||
|
print("heartbeat ok", flush=True)
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
print(f"heartbeat failed: HTTP {exc.code}", file=sys.stderr, flush=True)
|
||||||
|
except error.URLError as exc:
|
||||||
|
print(f"heartbeat failed: {exc.reason}", file=sys.stderr, flush=True)
|
||||||
|
if stop_event.wait(config.heartbeat_interval_seconds):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bind_host(config: AgentConfig, requested_host: str | None) -> str:
|
||||||
|
normalized = (requested_host or "").strip()
|
||||||
|
if normalized:
|
||||||
|
return normalized
|
||||||
|
return "0.0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bind_port(config: AgentConfig, requested_port: int | None) -> int:
|
||||||
|
if requested_port and requested_port > 0:
|
||||||
|
return requested_port
|
||||||
|
parsed = urlparse(config.endpoint)
|
||||||
|
if parsed.port:
|
||||||
|
return parsed.port
|
||||||
|
if parsed.scheme == "https":
|
||||||
|
return 443
|
||||||
|
if parsed.scheme == "http":
|
||||||
|
return 80
|
||||||
|
return 8765
|
||||||
|
|
||||||
|
|
||||||
|
def run(config: AgentConfig, requested_host: str | None, requested_port: int | None) -> None:
|
||||||
|
stop_event = threading.Event()
|
||||||
|
heartbeat_thread = threading.Thread(
|
||||||
|
target=run_heartbeat_loop,
|
||||||
|
args=(config, stop_event),
|
||||||
|
daemon=True,
|
||||||
|
name="remote-client-heartbeat",
|
||||||
|
)
|
||||||
|
heartbeat_thread.start()
|
||||||
|
|
||||||
|
bind_host = resolve_bind_host(config, requested_host)
|
||||||
|
bind_port = resolve_bind_port(config, requested_port)
|
||||||
|
print(f"Starting HTTP agent on {bind_host}:{bind_port}", flush=True)
|
||||||
|
print(f"Advertised endpoint: {config.endpoint}", flush=True)
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(config.config_path)
|
||||||
|
uvicorn.run("app.main:app", host=bind_host, port=bind_port)
|
||||||
|
finally:
|
||||||
|
stop_event.set()
|
||||||
|
heartbeat_thread.join(timeout=2)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Remote client agent Phase 1 for WebManager MVP")
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
default=str(Path(__file__).resolve().with_name("remote_client_agent.example.json")),
|
||||||
|
help="Path to remote client agent config JSON",
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="", help="Bind host for the HTTP agent, defaults to 0.0.0.0")
|
||||||
|
parser.add_argument("--port", type=int, default=0, help="Bind port for the HTTP agent, defaults to endpoint port")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
config = load_config(Path(args.config).resolve())
|
||||||
|
run(config, requested_host=args.host, requested_port=args.port)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
except Exception as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Run Finder Commander remote agent HTTP API")
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
required=True,
|
||||||
|
help="Path to remote agent config JSON",
|
||||||
|
)
|
||||||
|
parser.add_argument("--host", default="0.0.0.0", help="Listen host")
|
||||||
|
parser.add_argument("--port", type=int, default=8765, help="Listen port")
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
config_path = Path(args.config).expanduser().resolve(strict=False)
|
||||||
|
if not config_path.is_file():
|
||||||
|
raise SystemExit(f"Config file not found: {config_path}")
|
||||||
|
|
||||||
|
os.environ["FINDER_COMMANDER_REMOTE_AGENT_CONFIG"] = str(config_path)
|
||||||
|
print(f"Using config: {config_path}", flush=True)
|
||||||
|
uvicorn.run("app.main:app", host=args.host, port=args.port)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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()
|
||||||
@@ -1,5 +1,17 @@
|
|||||||
# Remote Client Shares Implementation Phases V1.1
|
# Remote Client Shares Implementation Phases V1.1
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Per huidige repositorystatus zijn de in dit document beschreven implementatiefases afgerond:
|
||||||
|
|
||||||
|
- Phase 1: afgerond
|
||||||
|
- Phase 2: afgerond
|
||||||
|
- Phase 3: afgerond
|
||||||
|
|
||||||
|
Dit document beschrijft geen Phase 4.
|
||||||
|
|
||||||
|
De sectie `Later` hieronder blijft expliciet buiten de beschreven fasering van V1.1 en is geen impliciete volgende fase.
|
||||||
|
|
||||||
## Doel
|
## Doel
|
||||||
|
|
||||||
Dit document splitst `REMOTE_CLIENT_SHARES_V1_DESIGN.md` op in pragmatische implementatiefases.
|
Dit document splitst `REMOTE_CLIENT_SHARES_V1_DESIGN.md` op in pragmatische implementatiefases.
|
||||||
@@ -32,6 +44,10 @@ Info, tekstpreview, eenvoudige image preview en download voor remote shares.
|
|||||||
|
|
||||||
Alle write-acties, bookmarks/startup paths en cross-source flows.
|
Alle write-acties, bookmarks/startup paths en cross-source flows.
|
||||||
|
|
||||||
|
Opmerking:
|
||||||
|
|
||||||
|
- `Later` betekent in dit document: bewust uitgestelde scope, niet een gedefinieerde volgende implementatiefase
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 1: Client Registry
|
## Phase 1: Client Registry
|
||||||
@@ -285,6 +301,11 @@ Nieuwe endpoints:
|
|||||||
|
|
||||||
Deze onderdelen horen niet in V1.1.
|
Deze onderdelen horen niet in V1.1.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- deze onderdelen blijven expliciet buiten de afgeronde Phase 1 t/m Phase 3 scope
|
||||||
|
- voor deze onderdelen bestaat in dit document geen aparte vervolgfase
|
||||||
|
|
||||||
### Write-acties
|
### Write-acties
|
||||||
|
|
||||||
- mkdir
|
- mkdir
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# Remote Client Shares V1.1 Design
|
# Remote Client Shares V1.1 Design
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Dit document beschrijft de V1.1-doelscope voor Remote Client Shares.
|
||||||
|
|
||||||
|
Per huidige repositorystatus valt de beschreven V1.1 read-mostly scope onder afgeronde implementatie van:
|
||||||
|
|
||||||
|
- client registry
|
||||||
|
- browse via `/Clients`
|
||||||
|
- file info
|
||||||
|
- tekstpreview
|
||||||
|
- eenvoudige image preview
|
||||||
|
- download
|
||||||
|
|
||||||
|
De expliciet niet in V1.1 opgenomen onderdelen hieronder blijven buiten scope en vormen in dit document geen aparte vervolgfase.
|
||||||
|
|
||||||
## Doel
|
## Doel
|
||||||
|
|
||||||
Een gebruiker van WebManager moet naast de bestaande server-side storage-roots ook een beperkte set lokale mappen van zijn eigen client-Mac kunnen benaderen, zonder de hele homefolder bloot te geven.
|
Een gebruiker van WebManager moet naast de bestaande server-side storage-roots ook een beperkte set lokale mappen van zijn eigen client-Mac kunnen benaderen, zonder de hele homefolder bloot te geven.
|
||||||
@@ -88,6 +103,11 @@ Daarom mogen remote client shares niet in hetzelfde model worden gestopt als `ro
|
|||||||
- automatische LAN discovery
|
- automatische LAN discovery
|
||||||
- multi-user auth met OS user mapping
|
- multi-user auth met OS user mapping
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- deze lijst blijft expliciet uitgesloten van V1.1
|
||||||
|
- dit document definieert hiervoor geen Phase 4 of andere vervolgfase
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Gewenste gebruikerservaring
|
## Gewenste gebruikerservaring
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Research: Remote Single-File Copy To Host
|
||||||
|
|
||||||
|
## Relevante file analysis
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [routes_files.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_files.py)
|
||||||
|
Bevat de bestaande lokale upload-route (`POST /api/files/upload`) en de remote read-only Phase 3 routes (`view`, `info`, `download`, `image`) via `RemoteFileService`.
|
||||||
|
- [routes_copy.py](/workspace/webmanager-mvp/webui/backend/app/api/routes_copy.py)
|
||||||
|
Bevat de bestaande copy-route (`POST /api/files/copy`) die volledig uitgaat van host-side source en host-side destination.
|
||||||
|
- [file_ops_service.py](/workspace/webmanager-mvp/webui/backend/app/services/file_ops_service.py)
|
||||||
|
Bevat lokale file-acties. Relevant is vooral `upload()`, omdat die host-write doet na `PathGuard`-validatie van een doeldirectory.
|
||||||
|
- [copy_task_service.py](/workspace/webmanager-mvp/webui/backend/app/services/copy_task_service.py)
|
||||||
|
Bevat task-opbouw, destination-validatie en taakcreatie voor copy, maar gaat uit van een lokale bron die via `PathGuard` naar een host-pad resolveert.
|
||||||
|
- [remote_file_service.py](/workspace/webmanager-mvp/webui/backend/app/services/remote_file_service.py)
|
||||||
|
Bevat al de benodigde remote read-path parsing, share-validatie via registry, agent-auth, error mapping en een gestreamde `prepare_download()` naar de agent.
|
||||||
|
- [filesystem_adapter.py](/workspace/webmanager-mvp/webui/backend/app/fs/filesystem_adapter.py)
|
||||||
|
Bevat de feitelijke host-write helpers:
|
||||||
|
- `write_uploaded_file(path, file_stream, overwrite=False)`
|
||||||
|
- `copy_file(source, destination, on_progress=None)`
|
||||||
|
`copy_file` vereist een lokale bron op de host en is dus niet bruikbaar voor remote input. `write_uploaded_file` schrijft een inkomende stream naar een hostpad en is conceptueel het dichtstbij.
|
||||||
|
- [path_guard.py](/workspace/webmanager-mvp/webui/backend/app/security/path_guard.py)
|
||||||
|
Houdt host-write validatie strikt lokaal. Dat moet zo blijven; remote paden mogen hier niet als bronsemantiek in terechtkomen.
|
||||||
|
- [tasks_runner.py](/workspace/webmanager-mvp/webui/backend/app/tasks_runner.py)
|
||||||
|
Bevat task-based copy/move uitvoering, maar alleen voor host-side bronpaden. Wel relevant als patroon voor een aparte remote-to-host worker.
|
||||||
|
- [schemas.py](/workspace/webmanager-mvp/webui/backend/app/api/schemas.py)
|
||||||
|
Bevat bestaande `CopyRequest` en upload/copy response-modellen. Voor een aparte feature is waarschijnlijk een nieuw requestmodel nodig.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [app.js](/workspace/webmanager-mvp/webui/html/app.js)
|
||||||
|
Relevante bestaande flows:
|
||||||
|
- `uploadFileRequest()` gebruikt uitsluitend `/api/files/upload`
|
||||||
|
- `startCopySelected()` gebruikt uitsluitend `/api/files/copy`
|
||||||
|
- remote browse/view/download is al source-aware
|
||||||
|
- remote copy is nu bewust geblokkeerd
|
||||||
|
Dit bevestigt dat upload-flow en copy-flow momenteel twee losse UI-contracten zijn.
|
||||||
|
|
||||||
|
### Agent
|
||||||
|
|
||||||
|
- [finder_commander/app/main.py](/workspace/webmanager-mvp/finder_commander/app/main.py)
|
||||||
|
Agent heeft al wat voor deze feature nodig is:
|
||||||
|
- strikte `share + relative path` validatie
|
||||||
|
- `GET /api/info`
|
||||||
|
- `GET /api/download`
|
||||||
|
Voor remote single-file copy naar host is geen nieuwe remote write-API nodig.
|
||||||
|
|
||||||
|
## Oordeel over hergebruik van upload-internals
|
||||||
|
|
||||||
|
### Bestaande upload-functionaliteit aanpassen?
|
||||||
|
|
||||||
|
Nee.
|
||||||
|
|
||||||
|
Reden:
|
||||||
|
|
||||||
|
- de bestaande upload-route, upload-requestvorm en upload-UI werken al goed
|
||||||
|
- upload is browser -> host via multipart/form-data
|
||||||
|
- de gewenste feature is agent/remote -> host via backend-proxy/stream
|
||||||
|
- dat is een ander contract, andere foutbron en andere bronsemantiek
|
||||||
|
|
||||||
|
### Interne host-write logica hergebruiken?
|
||||||
|
|
||||||
|
Ja, maar alleen op intern helper/service-niveau.
|
||||||
|
|
||||||
|
Concreet oordeel:
|
||||||
|
|
||||||
|
- `FilesystemAdapter.copy_file()` is niet geschikt voor hergebruik
|
||||||
|
Reden: vereist een lokale host-bronpad als source.
|
||||||
|
- `FilesystemAdapter.write_uploaded_file()` is deels relevant
|
||||||
|
Reden: dit doet precies de host-write van een inkomende stream naar een doelbestand.
|
||||||
|
- Direct hergebruik van `FileOpsService.upload()` is niet verstandig
|
||||||
|
Reden: die methode is semantisch en contractueel gekoppeld aan multipart upload en `UploadFile`.
|
||||||
|
|
||||||
|
Best passende richting:
|
||||||
|
|
||||||
|
- niet hergebruiken via bestaande upload-endpoints of upload-flow
|
||||||
|
- wel overwegen om de onderliggende stream-naar-bestand write logica te hergebruiken of te veralgemeniseren in `FilesystemAdapter`
|
||||||
|
- voorkeur: een nieuwe sibling-helper zoals `write_stream_file(...)` of een kleine interne extractie, zodat upload ongewijzigd blijft en remote copy dezelfde veilige host-write primitief kan gebruiken
|
||||||
|
|
||||||
|
## Ontwerpvoorstel
|
||||||
|
|
||||||
|
### Feature
|
||||||
|
|
||||||
|
`Copy remote file to host`
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
- alleen single file
|
||||||
|
- alleen source onder `/Clients/...`
|
||||||
|
- alleen destination op host-side lokale map
|
||||||
|
- geen mappen
|
||||||
|
- geen overwrite in eerste change request tenzij expliciet gewenst
|
||||||
|
- geen upload-route hergebruik
|
||||||
|
- geen brede refactor
|
||||||
|
|
||||||
|
### Backendontwerp
|
||||||
|
|
||||||
|
Voeg een aparte backend feature toe, niet via `POST /api/files/upload` en niet via bestaande `POST /api/files/copy`.
|
||||||
|
|
||||||
|
Voorkeursvorm:
|
||||||
|
|
||||||
|
- nieuwe route, bijvoorbeeld `POST /api/files/remote-copy`
|
||||||
|
- request bevat:
|
||||||
|
- `source`: remote bestandspad onder `/Clients/...`
|
||||||
|
- `destination_dir`: host-directory pad
|
||||||
|
|
||||||
|
Nieuwe service, bijvoorbeeld:
|
||||||
|
|
||||||
|
- `RemoteCopyToHostService`
|
||||||
|
|
||||||
|
Verantwoordelijkheden:
|
||||||
|
|
||||||
|
1. valideer dat `source` een remote `/Clients/...` file is
|
||||||
|
2. valideer dat `destination_dir` een host-directory is via bestaande lokale `PathGuard`
|
||||||
|
3. haal remote metadata op of resolve remote naam via bestaande `RemoteFileService`
|
||||||
|
4. bouw destination pad als `destination_dir/<remote-filename>`
|
||||||
|
5. faal op bestaand doelbestand in eerste versie
|
||||||
|
6. open remote download-stream via aparte interne helper op `RemoteFileService`
|
||||||
|
7. schrijf gestreamd naar host met een aparte interne host-write helper
|
||||||
|
8. map fouten strikt:
|
||||||
|
- remote unavailable blijft lokale actie-fout
|
||||||
|
- host permission/path-conflict blijft gewone host-fout
|
||||||
|
|
||||||
|
### Aanbevolen interne hergebruikslijn
|
||||||
|
|
||||||
|
- laat `RemoteFileService` een interne streaming primitive aanbieden, bijvoorbeeld een variant op de huidige remote download-open logica zonder HTTP-response voor browser-download
|
||||||
|
- laat `FilesystemAdapter` een aparte stream-write helper aanbieden voor generieke inkomende streams
|
||||||
|
- laat upload zijn bestaande publieke route en flow behouden
|
||||||
|
|
||||||
|
### Frontendontwerp
|
||||||
|
|
||||||
|
Geen wijziging aan upload-UI.
|
||||||
|
|
||||||
|
Kleine aparte UI-feature:
|
||||||
|
|
||||||
|
- toon een aparte actie alleen als:
|
||||||
|
- bronpane een remote file-selectie heeft van exact 1 bestand
|
||||||
|
- doelpane op een host/local directory staat
|
||||||
|
- de actie roept de nieuwe backend-route aan
|
||||||
|
- na succes:
|
||||||
|
- refresh beide panes
|
||||||
|
- toon lokale foutmelding bij falen
|
||||||
|
|
||||||
|
Voorkeur:
|
||||||
|
|
||||||
|
- aparte actie of expliciete source-aware branch voor "Copy remote file to host"
|
||||||
|
- niet de bestaande upload-flow hergebruiken
|
||||||
|
|
||||||
|
### Agentontwerp
|
||||||
|
|
||||||
|
Geen nieuwe agent-endpoints nodig in deze scope.
|
||||||
|
|
||||||
|
De bestaande `GET /api/download` is voldoende als read-only bron voor streaming.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- een enkel bestand onder `/Clients/...` kan naar een host-directory worden gekopieerd
|
||||||
|
- de destination moet een host/local directory zijn
|
||||||
|
- mappen als remote bron worden geweigerd
|
||||||
|
- remote -> remote wordt geweigerd
|
||||||
|
- host -> remote wordt geweigerd
|
||||||
|
- overwrite gebeurt niet impliciet; bestaand doelbestand geeft een nette fout
|
||||||
|
- bestaande upload-route, upload-contract en upload-UI blijven ongewijzigd
|
||||||
|
- bestaande lokale copy-flow blijft ongewijzigd
|
||||||
|
- remote fouten blijven lokaal tot deze actie
|
||||||
|
- host-write blijft onder bestaande lokale `PathGuard`-regels vallen
|
||||||
|
- data wordt gestreamd; geen volledige file-buffer in memory
|
||||||
|
|
||||||
|
## Klein plan
|
||||||
|
|
||||||
|
1. Voeg een research-backed change request toe voor een aparte route `POST /api/files/remote-copy`.
|
||||||
|
2. Voeg een kleine service toe die alleen remote single-file source + local destination_dir ondersteunt.
|
||||||
|
3. Voeg een interne streaming helper toe in `RemoteFileService` voor remote bestand-inname door backend.
|
||||||
|
4. Voeg een aparte interne host-write helper toe in `FilesystemAdapter` voor generieke stream-naar-bestand writes, zonder upload-API te wijzigen.
|
||||||
|
5. Voeg minimale frontend wiring toe voor een aparte "Copy remote file to host"-actie.
|
||||||
|
6. Test stapsgewijs:
|
||||||
|
- success path remote file -> local dir
|
||||||
|
- bestaand doelbestand
|
||||||
|
- remote directory rejected
|
||||||
|
- remote failure stays local
|
||||||
|
- upload-regressie: bestaande `/api/files/upload` blijft ongewijzigd
|
||||||
|
|
||||||
|
## Expliciete lijst van wat buiten scope blijft
|
||||||
|
|
||||||
|
- remote mappen kopiëren
|
||||||
|
- remote write-acties
|
||||||
|
- remote -> remote
|
||||||
|
- host -> remote
|
||||||
|
- aanpassing van bestaande upload-routes
|
||||||
|
- aanpassing van upload-requestcontract
|
||||||
|
- aanpassing van upload-UI
|
||||||
|
- brede refactor van copy/upload/task-infrastructuur
|
||||||
|
- bookmarks/startup paths
|
||||||
|
- remote task-runner verbreding buiten deze ene actie
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,39 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header
|
||||||
|
|
||||||
|
from backend.app.api.schemas import (
|
||||||
|
RemoteClientHeartbeatRequest,
|
||||||
|
RemoteClientItem,
|
||||||
|
RemoteClientListResponse,
|
||||||
|
RemoteClientRegisterRequest,
|
||||||
|
)
|
||||||
|
from backend.app.dependencies import get_remote_client_service
|
||||||
|
from backend.app.services.remote_client_service import RemoteClientService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/clients")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=RemoteClientListResponse)
|
||||||
|
async def list_clients(
|
||||||
|
service: RemoteClientService = Depends(get_remote_client_service),
|
||||||
|
) -> RemoteClientListResponse:
|
||||||
|
return service.list_clients()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=RemoteClientItem)
|
||||||
|
async def register_client(
|
||||||
|
request: RemoteClientRegisterRequest,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
service: RemoteClientService = Depends(get_remote_client_service),
|
||||||
|
) -> RemoteClientItem:
|
||||||
|
return service.register_client(authorization=authorization, request=request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/heartbeat", response_model=RemoteClientItem)
|
||||||
|
async def heartbeat(
|
||||||
|
request: RemoteClientHeartbeatRequest,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
service: RemoteClientService = Depends(get_remote_client_service),
|
||||||
|
) -> RemoteClientItem:
|
||||||
|
return service.record_heartbeat(authorization=authorization, request=request)
|
||||||
@@ -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"],
|
||||||
|
|||||||
@@ -238,3 +238,41 @@ class SearchResultItem(BaseModel):
|
|||||||
class SearchResponse(BaseModel):
|
class SearchResponse(BaseModel):
|
||||||
items: list[SearchResultItem]
|
items: list[SearchResultItem]
|
||||||
truncated: bool
|
truncated: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientShare(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientRegisterRequest(BaseModel):
|
||||||
|
client_id: str
|
||||||
|
display_name: str
|
||||||
|
platform: str
|
||||||
|
agent_version: str
|
||||||
|
endpoint: str
|
||||||
|
shares: list[RemoteClientShare]
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientHeartbeatRequest(BaseModel):
|
||||||
|
client_id: str
|
||||||
|
agent_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientItem(BaseModel):
|
||||||
|
client_id: str
|
||||||
|
display_name: str
|
||||||
|
platform: str
|
||||||
|
agent_version: str
|
||||||
|
endpoint: str
|
||||||
|
shares: list[RemoteClientShare]
|
||||||
|
last_seen: str | None = None
|
||||||
|
status: str
|
||||||
|
last_error: str | None = None
|
||||||
|
reachable_at: str | None = None
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientListResponse(BaseModel):
|
||||||
|
items: list[RemoteClientItem]
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from pathlib import Path
|
|||||||
class Settings:
|
class Settings:
|
||||||
root_aliases: dict[str, str]
|
root_aliases: dict[str, str]
|
||||||
task_db_path: str
|
task_db_path: str
|
||||||
|
remote_client_registration_token: str
|
||||||
|
remote_client_offline_timeout_seconds: int
|
||||||
|
remote_client_agent_auth_header: str
|
||||||
|
remote_client_agent_auth_scheme: str
|
||||||
|
remote_client_agent_auth_token: str
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROOT_ALIASES = {
|
DEFAULT_ROOT_ALIASES = {
|
||||||
@@ -40,4 +45,18 @@ def get_settings() -> Settings:
|
|||||||
task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", default_task_db_path).strip()
|
task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", default_task_db_path).strip()
|
||||||
if not task_db_path:
|
if not task_db_path:
|
||||||
task_db_path = default_task_db_path
|
task_db_path = default_task_db_path
|
||||||
return Settings(root_aliases=_load_root_aliases(), task_db_path=task_db_path)
|
raw_offline_timeout = os.getenv("WEBMANAGER_REMOTE_CLIENT_OFFLINE_TIMEOUT_SECONDS", "60").strip()
|
||||||
|
try:
|
||||||
|
remote_client_offline_timeout_seconds = max(1, int(raw_offline_timeout))
|
||||||
|
except ValueError:
|
||||||
|
remote_client_offline_timeout_seconds = 60
|
||||||
|
return Settings(
|
||||||
|
root_aliases=_load_root_aliases(),
|
||||||
|
task_db_path=task_db_path,
|
||||||
|
remote_client_registration_token=os.getenv("WEBMANAGER_REMOTE_CLIENT_REGISTRATION_TOKEN", "").strip(),
|
||||||
|
remote_client_offline_timeout_seconds=remote_client_offline_timeout_seconds,
|
||||||
|
remote_client_agent_auth_header=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_HEADER", "Authorization").strip()
|
||||||
|
or "Authorization",
|
||||||
|
remote_client_agent_auth_scheme=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_SCHEME", "Bearer").strip() or "Bearer",
|
||||||
|
remote_client_agent_auth_token=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_TOKEN", "").strip(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientRepository:
|
||||||
|
def __init__(self, db_path: str):
|
||||||
|
self._db_path = db_path
|
||||||
|
self._ensure_schema()
|
||||||
|
|
||||||
|
def upsert_client(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client_id: str,
|
||||||
|
display_name: str,
|
||||||
|
platform: str,
|
||||||
|
agent_version: str,
|
||||||
|
endpoint: str,
|
||||||
|
shares: list[dict[str, str]],
|
||||||
|
now_iso: str,
|
||||||
|
) -> dict:
|
||||||
|
shares_json = self._encode_shares(shares)
|
||||||
|
with self._connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO remote_clients (
|
||||||
|
client_id, display_name, platform, agent_version, endpoint, shares_json,
|
||||||
|
last_seen, status, last_error, reachable_at, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(client_id) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name,
|
||||||
|
platform = excluded.platform,
|
||||||
|
agent_version = excluded.agent_version,
|
||||||
|
endpoint = excluded.endpoint,
|
||||||
|
shares_json = excluded.shares_json,
|
||||||
|
last_seen = excluded.last_seen,
|
||||||
|
status = excluded.status,
|
||||||
|
last_error = NULL,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
client_id,
|
||||||
|
display_name,
|
||||||
|
platform,
|
||||||
|
agent_version,
|
||||||
|
endpoint,
|
||||||
|
shares_json,
|
||||||
|
now_iso,
|
||||||
|
"online",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
now_iso,
|
||||||
|
now_iso,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM remote_clients WHERE client_id = ?", (client_id,)).fetchone()
|
||||||
|
return self._to_dict(row)
|
||||||
|
|
||||||
|
def record_heartbeat(self, *, client_id: str, agent_version: str, now_iso: str) -> dict | None:
|
||||||
|
with self._connection() as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE remote_clients
|
||||||
|
SET agent_version = ?, last_seen = ?, status = ?, updated_at = ?
|
||||||
|
WHERE client_id = ?
|
||||||
|
""",
|
||||||
|
(agent_version, now_iso, "online", now_iso, client_id),
|
||||||
|
)
|
||||||
|
if cursor.rowcount <= 0:
|
||||||
|
return None
|
||||||
|
row = conn.execute("SELECT * FROM remote_clients WHERE client_id = ?", (client_id,)).fetchone()
|
||||||
|
return self._to_dict(row)
|
||||||
|
|
||||||
|
def mark_stale_clients_offline(self, *, cutoff_iso: str, now_iso: str) -> None:
|
||||||
|
with self._connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE remote_clients
|
||||||
|
SET status = ?, updated_at = ?
|
||||||
|
WHERE status != ? AND last_seen IS NOT NULL AND last_seen < ?
|
||||||
|
""",
|
||||||
|
("offline", now_iso, "offline", cutoff_iso),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_clients(self) -> list[dict]:
|
||||||
|
with self._connection() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM remote_clients
|
||||||
|
ORDER BY LOWER(display_name) ASC, client_id ASC
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [self._to_dict(row) for row in rows]
|
||||||
|
|
||||||
|
def get_client(self, client_id: str) -> dict | None:
|
||||||
|
with self._connection() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT *
|
||||||
|
FROM remote_clients
|
||||||
|
WHERE client_id = ?
|
||||||
|
""",
|
||||||
|
(client_id,),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return self._to_dict(row)
|
||||||
|
|
||||||
|
def _ensure_schema(self) -> None:
|
||||||
|
db_path = Path(self._db_path)
|
||||||
|
if db_path.parent and str(db_path.parent) not in {"", "."}:
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with self._connection() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS remote_clients (
|
||||||
|
client_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL,
|
||||||
|
agent_version TEXT NOT NULL,
|
||||||
|
endpoint TEXT NOT NULL,
|
||||||
|
shares_json TEXT NOT NULL,
|
||||||
|
last_seen TEXT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
last_error TEXT NULL,
|
||||||
|
reachable_at TEXT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_remote_clients_display_name
|
||||||
|
ON remote_clients(display_name)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_remote_clients_last_seen
|
||||||
|
ON remote_clients(last_seen)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _connection(self):
|
||||||
|
conn = sqlite3.connect(self._db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _to_dict(cls, row: sqlite3.Row) -> dict:
|
||||||
|
return {
|
||||||
|
"client_id": row["client_id"],
|
||||||
|
"display_name": row["display_name"],
|
||||||
|
"platform": row["platform"],
|
||||||
|
"agent_version": row["agent_version"],
|
||||||
|
"endpoint": row["endpoint"],
|
||||||
|
"shares": cls._decode_shares(row["shares_json"]),
|
||||||
|
"last_seen": row["last_seen"],
|
||||||
|
"status": row["status"],
|
||||||
|
"last_error": row["last_error"],
|
||||||
|
"reachable_at": row["reachable_at"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _encode_shares(shares: list[dict[str, str]]) -> str:
|
||||||
|
return json.dumps(shares, separators=(",", ":"), sort_keys=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decode_shares(raw: str) -> list[dict[str, str]]:
|
||||||
|
parsed = json.loads(raw or "[]")
|
||||||
|
if not isinstance(parsed, list):
|
||||||
|
return []
|
||||||
|
normalized: list[dict[str, str]] = []
|
||||||
|
for item in parsed:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
key = str(item.get("key", "")).strip()
|
||||||
|
label = str(item.get("label", "")).strip()
|
||||||
|
if key and label:
|
||||||
|
normalized.append({"key": key, "label": label})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from backend.app.config import Settings, get_settings
|
from backend.app.config import Settings, get_settings
|
||||||
from backend.app.db.bookmark_repository import BookmarkRepository
|
from backend.app.db.bookmark_repository import BookmarkRepository
|
||||||
from backend.app.db.history_repository import HistoryRepository
|
from backend.app.db.history_repository import HistoryRepository
|
||||||
|
from backend.app.db.remote_client_repository import RemoteClientRepository
|
||||||
from backend.app.db.settings_repository import SettingsRepository
|
from backend.app.db.settings_repository import SettingsRepository
|
||||||
from backend.app.db.task_repository import TaskRepository
|
from backend.app.db.task_repository import TaskRepository
|
||||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
@@ -19,6 +20,9 @@ from backend.app.services.duplicate_task_service import DuplicateTaskService
|
|||||||
from backend.app.services.file_ops_service import FileOpsService
|
from backend.app.services.file_ops_service import FileOpsService
|
||||||
from backend.app.services.history_service import HistoryService
|
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_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
|
||||||
@@ -59,6 +63,12 @@ def get_settings_repository() -> SettingsRepository:
|
|||||||
return SettingsRepository(db_path=settings.task_db_path)
|
return SettingsRepository(db_path=settings.task_db_path)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_remote_client_repository() -> RemoteClientRepository:
|
||||||
|
settings: Settings = get_settings()
|
||||||
|
return RemoteClientRepository(db_path=settings.task_db_path)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def get_task_runner() -> TaskRunner:
|
def get_task_runner() -> TaskRunner:
|
||||||
return TaskRunner(
|
return TaskRunner(
|
||||||
@@ -75,7 +85,11 @@ def get_archive_artifact_root() -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def get_browse_service() -> BrowseService:
|
async def get_browse_service() -> BrowseService:
|
||||||
return BrowseService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter())
|
return BrowseService(
|
||||||
|
path_guard=get_path_guard(),
|
||||||
|
filesystem=get_filesystem_adapter(),
|
||||||
|
remote_browse_service=await get_remote_browse_service(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_file_ops_service() -> FileOpsService:
|
async def get_file_ops_service() -> FileOpsService:
|
||||||
@@ -155,3 +169,32 @@ async def get_search_service() -> SearchService:
|
|||||||
|
|
||||||
async def get_settings_service() -> SettingsService:
|
async def get_settings_service() -> SettingsService:
|
||||||
return SettingsService(repository=get_settings_repository(), path_guard=get_path_guard())
|
return SettingsService(repository=get_settings_repository(), path_guard=get_path_guard())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_remote_client_service() -> RemoteClientService:
|
||||||
|
settings: Settings = get_settings()
|
||||||
|
return RemoteClientService(
|
||||||
|
repository=get_remote_client_repository(),
|
||||||
|
registration_token=settings.remote_client_registration_token,
|
||||||
|
offline_timeout_seconds=settings.remote_client_offline_timeout_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_remote_browse_service() -> RemoteBrowseService:
|
||||||
|
settings: Settings = get_settings()
|
||||||
|
return RemoteBrowseService(
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from backend.app.api.errors import AppError
|
|||||||
from backend.app.api.routes_bookmarks import router as bookmarks_router
|
from backend.app.api.routes_bookmarks import router as bookmarks_router
|
||||||
from backend.app.api.routes_browse import router as browse_router
|
from backend.app.api.routes_browse import router as browse_router
|
||||||
from backend.app.api.routes_copy import router as copy_router
|
from backend.app.api.routes_copy import router as copy_router
|
||||||
|
from backend.app.api.routes_clients import router as clients_router
|
||||||
from backend.app.api.routes_duplicate import router as duplicate_router
|
from backend.app.api.routes_duplicate import router as duplicate_router
|
||||||
from backend.app.api.routes_files import router as files_router
|
from backend.app.api.routes_files import router as files_router
|
||||||
from backend.app.api.routes_history import router as history_router
|
from backend.app.api.routes_history import router as history_router
|
||||||
@@ -33,6 +34,7 @@ app.mount("/ui", StaticFiles(directory=str(UI_DIR), html=True), name="ui")
|
|||||||
app.include_router(browse_router, prefix="/api")
|
app.include_router(browse_router, prefix="/api")
|
||||||
app.include_router(files_router, prefix="/api")
|
app.include_router(files_router, prefix="/api")
|
||||||
app.include_router(copy_router, prefix="/api")
|
app.include_router(copy_router, prefix="/api")
|
||||||
|
app.include_router(clients_router, prefix="/api")
|
||||||
app.include_router(duplicate_router, prefix="/api")
|
app.include_router(duplicate_router, prefix="/api")
|
||||||
app.include_router(move_router, prefix="/api")
|
app.include_router(move_router, prefix="/api")
|
||||||
app.include_router(search_router, prefix="/api")
|
app.include_router(search_router, prefix="/api")
|
||||||
|
|||||||
Binary file not shown.
@@ -3,14 +3,24 @@ from __future__ import annotations
|
|||||||
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry
|
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry
|
||||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
from backend.app.security.path_guard import PathGuard
|
from backend.app.security.path_guard import PathGuard
|
||||||
|
from backend.app.services.remote_browse_service import RemoteBrowseService
|
||||||
|
|
||||||
|
|
||||||
class BrowseService:
|
class BrowseService:
|
||||||
def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter):
|
def __init__(
|
||||||
|
self,
|
||||||
|
path_guard: PathGuard,
|
||||||
|
filesystem: FilesystemAdapter,
|
||||||
|
remote_browse_service: RemoteBrowseService | None = None,
|
||||||
|
):
|
||||||
self._path_guard = path_guard
|
self._path_guard = path_guard
|
||||||
self._filesystem = filesystem
|
self._filesystem = filesystem
|
||||||
|
self._remote_browse_service = remote_browse_service
|
||||||
|
|
||||||
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
||||||
|
if self._remote_browse_service and self._remote_browse_service.handles_path(path):
|
||||||
|
return self._remote_browse_service.browse(path=path, show_hidden=show_hidden)
|
||||||
|
|
||||||
if self._path_guard.is_virtual_volumes_path(path):
|
if self._path_guard.is_virtual_volumes_path(path):
|
||||||
directories = [
|
directories = [
|
||||||
DirectoryEntry(name=item["name"], path=item["path"], modified="")
|
DirectoryEntry(name=item["name"], path=item["path"], modified="")
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from backend.app.api.errors import AppError
|
||||||
|
from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry, RemoteClientItem
|
||||||
|
from backend.app.services.remote_client_service import RemoteClientService
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteBrowseService:
|
||||||
|
ROOT_PATH = "/Clients"
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
remote_client_service: RemoteClientService,
|
||||||
|
agent_auth_header: str,
|
||||||
|
agent_auth_scheme: str,
|
||||||
|
agent_auth_token: str,
|
||||||
|
agent_timeout_seconds: float = 2.0,
|
||||||
|
):
|
||||||
|
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))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def handles_path(cls, path: str) -> bool:
|
||||||
|
normalized = (path or "").strip()
|
||||||
|
return normalized == cls.ROOT_PATH or normalized.startswith(f"{cls.ROOT_PATH}/")
|
||||||
|
|
||||||
|
def browse(self, path: str, show_hidden: bool) -> BrowseResponse:
|
||||||
|
parts = self._path_parts(path)
|
||||||
|
if not parts:
|
||||||
|
return self._browse_clients_root()
|
||||||
|
if len(parts) == 1:
|
||||||
|
return self._browse_client(parts[0])
|
||||||
|
return self._browse_remote_share(parts[0], parts[1], parts[2:], show_hidden)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _path_parts(cls, path: str) -> list[str]:
|
||||||
|
normalized = (path or "").strip().rstrip("/")
|
||||||
|
if normalized == cls.ROOT_PATH:
|
||||||
|
return []
|
||||||
|
return normalized[len(cls.ROOT_PATH) + 1 :].split("/")
|
||||||
|
|
||||||
|
def _browse_clients_root(self) -> BrowseResponse:
|
||||||
|
clients = self._remote_client_service.list_clients().items
|
||||||
|
directories = [
|
||||||
|
DirectoryEntry(
|
||||||
|
name=client.display_name,
|
||||||
|
path=f"{self.ROOT_PATH}/{client.client_id}",
|
||||||
|
modified=client.last_seen or client.updated_at,
|
||||||
|
)
|
||||||
|
for client in clients
|
||||||
|
]
|
||||||
|
return BrowseResponse(path=self.ROOT_PATH, directories=directories, files=[])
|
||||||
|
|
||||||
|
def _browse_client(self, client_id: str) -> BrowseResponse:
|
||||||
|
client = self._remote_client_service.get_client(client_id)
|
||||||
|
directories = [
|
||||||
|
DirectoryEntry(
|
||||||
|
name=share.label,
|
||||||
|
path=f"{self.ROOT_PATH}/{client.client_id}/{share.key}",
|
||||||
|
modified=client.last_seen or client.updated_at,
|
||||||
|
)
|
||||||
|
for share in client.shares
|
||||||
|
]
|
||||||
|
return BrowseResponse(path=f"{self.ROOT_PATH}/{client.client_id}", directories=directories, files=[])
|
||||||
|
|
||||||
|
def _browse_remote_share(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
share_key: str,
|
||||||
|
relative_parts: list[str],
|
||||||
|
show_hidden: bool,
|
||||||
|
) -> BrowseResponse:
|
||||||
|
client = self._remote_client_service.get_client(client_id)
|
||||||
|
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 = next((item for item in client.shares if item.key == share_key), None)
|
||||||
|
if share is None:
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
details={"client_id": client.client_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
base_path = f"{self.ROOT_PATH}/{client.client_id}/{share.key}"
|
||||||
|
relative_path = "/".join(relative_parts)
|
||||||
|
agent_payload = self._fetch_remote_listing(client=client, share_key=share.key, relative_path=relative_path, show_hidden=show_hidden)
|
||||||
|
|
||||||
|
directories: list[DirectoryEntry] = []
|
||||||
|
files: list[FileEntry] = []
|
||||||
|
for entry in agent_payload.get("entries", []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
name = str(entry.get("name", "")).strip()
|
||||||
|
kind = str(entry.get("kind", "")).strip()
|
||||||
|
if not name or kind not in {"directory", "file"}:
|
||||||
|
continue
|
||||||
|
child_path = f"{base_path}/{name}"
|
||||||
|
modified = str(entry.get("modified", "") or "")
|
||||||
|
if kind == "directory":
|
||||||
|
directories.append(DirectoryEntry(name=name, path=child_path, modified=modified))
|
||||||
|
continue
|
||||||
|
size = entry.get("size", 0)
|
||||||
|
try:
|
||||||
|
normalized_size = max(0, int(size))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
normalized_size = 0
|
||||||
|
files.append(FileEntry(name=name, path=child_path, size=normalized_size, modified=modified))
|
||||||
|
|
||||||
|
response_path = base_path if not relative_path else f"{base_path}/{relative_path}"
|
||||||
|
return BrowseResponse(path=response_path, directories=directories, files=files)
|
||||||
|
|
||||||
|
def _fetch_remote_listing(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client: RemoteClientItem,
|
||||||
|
share_key: str,
|
||||||
|
relative_path: str,
|
||||||
|
show_hidden: bool,
|
||||||
|
) -> dict:
|
||||||
|
normalized_endpoint = client.endpoint.rstrip("/")
|
||||||
|
query = urlencode({"share": share_key, "path": relative_path, "show_hidden": str(show_hidden).lower()})
|
||||||
|
url = f"{normalized_endpoint}/api/list?{query}"
|
||||||
|
headers = {self._agent_auth_header: f"{self._agent_auth_scheme} {self._agent_auth_token}"}
|
||||||
|
timeout = httpx.Timeout(self._agent_timeout_seconds, connect=self._agent_timeout_seconds)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with httpx.Client(timeout=timeout, headers=headers) as client_http:
|
||||||
|
response = client_http.get(url)
|
||||||
|
except httpx.TimeoutException as exc:
|
||||||
|
raise 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},
|
||||||
|
) from exc
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
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},
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
raise AppError(
|
||||||
|
code="path_not_found",
|
||||||
|
message="Remote path was not found",
|
||||||
|
status_code=404,
|
||||||
|
details={"client_id": client.client_id, "share_key": share_key},
|
||||||
|
)
|
||||||
|
if response.status_code in {401, 403}:
|
||||||
|
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},
|
||||||
|
)
|
||||||
|
if response.status_code >= 400:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_error",
|
||||||
|
message=f"Remote client '{client.display_name}' browse failed",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint, "status_code": str(response.status_code)},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_error",
|
||||||
|
message=f"Remote client '{client.display_name}' returned invalid JSON",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
) from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_error",
|
||||||
|
message=f"Remote client '{client.display_name}' returned an invalid response",
|
||||||
|
status_code=502,
|
||||||
|
details={"client_id": client.client_id, "endpoint": client.endpoint},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from backend.app.api.errors import AppError
|
||||||
|
from backend.app.api.schemas import (
|
||||||
|
RemoteClientHeartbeatRequest,
|
||||||
|
RemoteClientItem,
|
||||||
|
RemoteClientListResponse,
|
||||||
|
RemoteClientRegisterRequest,
|
||||||
|
)
|
||||||
|
from backend.app.db.remote_client_repository import RemoteClientRepository
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
repository: RemoteClientRepository,
|
||||||
|
registration_token: str,
|
||||||
|
offline_timeout_seconds: int,
|
||||||
|
now: Callable[[], datetime] | None = None,
|
||||||
|
):
|
||||||
|
self._repository = repository
|
||||||
|
self._registration_token = registration_token.strip()
|
||||||
|
self._offline_timeout_seconds = max(1, int(offline_timeout_seconds))
|
||||||
|
self._now = now or (lambda: datetime.now(tz=timezone.utc))
|
||||||
|
|
||||||
|
def list_clients(self) -> RemoteClientListResponse:
|
||||||
|
self._refresh_stale_statuses()
|
||||||
|
items = [RemoteClientItem(**row) for row in self._repository.list_clients()]
|
||||||
|
return RemoteClientListResponse(items=items)
|
||||||
|
|
||||||
|
def get_client(self, client_id: str) -> RemoteClientItem:
|
||||||
|
normalized_client_id = (client_id or "").strip()
|
||||||
|
if not normalized_client_id:
|
||||||
|
raise AppError(
|
||||||
|
code="invalid_request",
|
||||||
|
message="client_id is required",
|
||||||
|
status_code=400,
|
||||||
|
details={"client_id": client_id},
|
||||||
|
)
|
||||||
|
self._refresh_stale_statuses()
|
||||||
|
item = self._repository.get_client(normalized_client_id)
|
||||||
|
if item is None:
|
||||||
|
raise AppError(
|
||||||
|
code="path_not_found",
|
||||||
|
message="Remote client was not found",
|
||||||
|
status_code=404,
|
||||||
|
details={"client_id": normalized_client_id},
|
||||||
|
)
|
||||||
|
return RemoteClientItem(**item)
|
||||||
|
|
||||||
|
def register_client(self, authorization: str | None, request: RemoteClientRegisterRequest) -> RemoteClientItem:
|
||||||
|
self._require_registration_auth(authorization)
|
||||||
|
payload = self._normalize_register_request(request)
|
||||||
|
now_iso = self._to_iso(self._now())
|
||||||
|
item = self._repository.upsert_client(now_iso=now_iso, **payload)
|
||||||
|
return RemoteClientItem(**item)
|
||||||
|
|
||||||
|
def record_heartbeat(self, authorization: str | None, request: RemoteClientHeartbeatRequest) -> RemoteClientItem:
|
||||||
|
self._require_registration_auth(authorization)
|
||||||
|
client_id = (request.client_id or "").strip()
|
||||||
|
agent_version = (request.agent_version or "").strip()
|
||||||
|
if not client_id:
|
||||||
|
raise AppError(
|
||||||
|
code="invalid_request",
|
||||||
|
message="client_id is required",
|
||||||
|
status_code=400,
|
||||||
|
details={"client_id": request.client_id},
|
||||||
|
)
|
||||||
|
if not agent_version:
|
||||||
|
raise AppError(
|
||||||
|
code="invalid_request",
|
||||||
|
message="agent_version is required",
|
||||||
|
status_code=400,
|
||||||
|
details={"agent_version": request.agent_version},
|
||||||
|
)
|
||||||
|
item = self._repository.record_heartbeat(
|
||||||
|
client_id=client_id,
|
||||||
|
agent_version=agent_version,
|
||||||
|
now_iso=self._to_iso(self._now()),
|
||||||
|
)
|
||||||
|
if item is None:
|
||||||
|
raise AppError(
|
||||||
|
code="path_not_found",
|
||||||
|
message="Remote client was not found",
|
||||||
|
status_code=404,
|
||||||
|
details={"client_id": client_id},
|
||||||
|
)
|
||||||
|
return RemoteClientItem(**item)
|
||||||
|
|
||||||
|
def _require_registration_auth(self, authorization: str | None) -> None:
|
||||||
|
if not self._registration_token:
|
||||||
|
raise AppError(
|
||||||
|
code="remote_client_registration_disabled",
|
||||||
|
message="Remote client registration is not configured",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
expected = f"Bearer {self._registration_token}"
|
||||||
|
if (authorization or "").strip() != expected:
|
||||||
|
raise AppError(
|
||||||
|
code="forbidden",
|
||||||
|
message="Invalid remote client registration token",
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _normalize_register_request(self, request: RemoteClientRegisterRequest) -> dict:
|
||||||
|
client_id = (request.client_id or "").strip()
|
||||||
|
display_name = (request.display_name or "").strip()
|
||||||
|
platform = (request.platform or "").strip()
|
||||||
|
agent_version = (request.agent_version or "").strip()
|
||||||
|
endpoint = (request.endpoint or "").strip()
|
||||||
|
shares = [
|
||||||
|
{"key": (item.key or "").strip(), "label": (item.label or "").strip()}
|
||||||
|
for item in request.shares
|
||||||
|
]
|
||||||
|
shares = [item for item in shares if item["key"] and item["label"]]
|
||||||
|
|
||||||
|
if not client_id:
|
||||||
|
raise AppError("invalid_request", "client_id is required", 400, {"client_id": request.client_id})
|
||||||
|
if not display_name:
|
||||||
|
raise AppError("invalid_request", "display_name is required", 400, {"display_name": request.display_name})
|
||||||
|
if not platform:
|
||||||
|
raise AppError("invalid_request", "platform is required", 400, {"platform": request.platform})
|
||||||
|
if not agent_version:
|
||||||
|
raise AppError("invalid_request", "agent_version is required", 400, {"agent_version": request.agent_version})
|
||||||
|
if not endpoint:
|
||||||
|
raise AppError("invalid_request", "endpoint is required", 400, {"endpoint": request.endpoint})
|
||||||
|
if not shares:
|
||||||
|
raise AppError("invalid_request", "at least one share is required", 400, {"shares": "[]"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"client_id": client_id,
|
||||||
|
"display_name": display_name,
|
||||||
|
"platform": platform,
|
||||||
|
"agent_version": agent_version,
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"shares": shares,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _refresh_stale_statuses(self) -> None:
|
||||||
|
now = self._now()
|
||||||
|
self._repository.mark_stale_clients_offline(
|
||||||
|
cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)),
|
||||||
|
now_iso=self._to_iso(now),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_iso(value: datetime) -> str:
|
||||||
|
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
@@ -11,11 +12,43 @@ import httpx
|
|||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
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
|
from backend.app.dependencies import get_browse_service
|
||||||
|
from backend.app.db.remote_client_repository import RemoteClientRepository
|
||||||
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
from backend.app.fs.filesystem_adapter import FilesystemAdapter
|
||||||
from backend.app.main import app
|
from backend.app.main import app
|
||||||
from backend.app.security.path_guard import PathGuard
|
from backend.app.security.path_guard import PathGuard
|
||||||
from backend.app.services.browse_service import BrowseService
|
from backend.app.services.browse_service import BrowseService
|
||||||
|
from backend.app.services.remote_browse_service import RemoteBrowseService
|
||||||
|
from backend.app.services.remote_client_service import RemoteClientService
|
||||||
|
|
||||||
|
|
||||||
|
class _StubRemoteBrowseService(RemoteBrowseService):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
remote_client_service: RemoteClientService,
|
||||||
|
listings: 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",
|
||||||
|
agent_timeout_seconds=0.25,
|
||||||
|
)
|
||||||
|
self._listings = listings
|
||||||
|
self._failing_client_ids = failing_client_ids
|
||||||
|
|
||||||
|
def _fetch_remote_listing(self, *, client, share_key: str, relative_path: str, show_hidden: bool) -> 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._listings[(client.client_id, share_key, relative_path)]
|
||||||
|
|
||||||
|
|
||||||
class BrowseApiGoldenTest(unittest.TestCase):
|
class BrowseApiGoldenTest(unittest.TestCase):
|
||||||
@@ -36,6 +69,12 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
file_path.write_bytes(b"abc")
|
file_path.write_bytes(b"abc")
|
||||||
second_file = self.second_root / "archive.txt"
|
second_file = self.second_root / "archive.txt"
|
||||||
second_file.write_text("z", encoding="utf-8")
|
second_file.write_text("z", encoding="utf-8")
|
||||||
|
remote_root = Path(self.temp_dir.name) / "remote-downloads"
|
||||||
|
remote_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
remote_dir = remote_root / "Series"
|
||||||
|
remote_dir.mkdir()
|
||||||
|
remote_file = remote_root / "episode.mkv"
|
||||||
|
remote_file.write_bytes(b"remote")
|
||||||
|
|
||||||
hidden_dir = self.root / ".hidden_dir"
|
hidden_dir = self.root / ".hidden_dir"
|
||||||
hidden_dir.mkdir()
|
hidden_dir.mkdir()
|
||||||
@@ -43,15 +82,70 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
hidden_file.write_bytes(b"x")
|
hidden_file.write_bytes(b"x")
|
||||||
|
|
||||||
mtime = 1710000000
|
mtime = 1710000000
|
||||||
for path in [folder, file_path, hidden_dir, hidden_file, second_file]:
|
for path in [folder, file_path, hidden_dir, hidden_file, second_file, remote_dir, remote_file]:
|
||||||
Path(path).touch()
|
Path(path).touch()
|
||||||
Path(path).chmod(0o755)
|
Path(path).chmod(0o755)
|
||||||
import os
|
|
||||||
os.utime(path, (mtime, mtime))
|
os.utime(path, (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://127.0.0.1:1",
|
||||||
|
shares=[{"key": "downloads", "label": "Downloads"}],
|
||||||
|
now_iso=now_iso,
|
||||||
|
)
|
||||||
|
|
||||||
service = BrowseService(
|
service = BrowseService(
|
||||||
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
|
path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}),
|
||||||
filesystem=FilesystemAdapter(),
|
filesystem=FilesystemAdapter(),
|
||||||
|
remote_browse_service=_StubRemoteBrowseService(
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
listings={
|
||||||
|
(
|
||||||
|
"client-123",
|
||||||
|
"downloads",
|
||||||
|
"",
|
||||||
|
): {
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"name": "Series",
|
||||||
|
"kind": "directory",
|
||||||
|
"size": remote_dir.stat().st_size,
|
||||||
|
"modified": datetime.fromtimestamp(remote_dir.stat().st_mtime, tz=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "episode.mkv",
|
||||||
|
"kind": "file",
|
||||||
|
"size": remote_file.stat().st_size,
|
||||||
|
"modified": datetime.fromtimestamp(remote_file.stat().st_mtime, tz=timezone.utc)
|
||||||
|
.isoformat()
|
||||||
|
.replace("+00:00", "Z"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
failing_client_ids={"broken-client"},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
async def _override_browse_service() -> BrowseService:
|
async def _override_browse_service() -> BrowseService:
|
||||||
return service
|
return service
|
||||||
@@ -151,6 +245,80 @@ class BrowseApiGoldenTest(unittest.TestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_browse_virtual_clients_and_remote_share(self) -> None:
|
||||||
|
clients_response = self._get("/Clients")
|
||||||
|
self.assertEqual(clients_response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
clients_response.json(),
|
||||||
|
{
|
||||||
|
"path": "/Clients",
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "Jan MacBook",
|
||||||
|
"path": "/Clients/client-123",
|
||||||
|
"modified": "2026-03-26T12:00:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Offline iMac",
|
||||||
|
"path": "/Clients/broken-client",
|
||||||
|
"modified": "2026-03-26T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"files": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
shares_response = self._get("/Clients/client-123")
|
||||||
|
self.assertEqual(shares_response.status_code, 200)
|
||||||
|
self.assertEqual(
|
||||||
|
shares_response.json(),
|
||||||
|
{
|
||||||
|
"path": "/Clients/client-123",
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "Downloads",
|
||||||
|
"path": "/Clients/client-123/downloads",
|
||||||
|
"modified": "2026-03-26T12:00:00Z",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
browse_response = self._get("/Clients/client-123/downloads")
|
||||||
|
self.assertEqual(browse_response.status_code, 200)
|
||||||
|
modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||||
|
self.assertEqual(
|
||||||
|
browse_response.json(),
|
||||||
|
{
|
||||||
|
"path": "/Clients/client-123/downloads",
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "Series",
|
||||||
|
"path": "/Clients/client-123/downloads/Series",
|
||||||
|
"modified": modified,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"name": "episode.mkv",
|
||||||
|
"path": "/Clients/client-123/downloads/episode.mkv",
|
||||||
|
"size": 6,
|
||||||
|
"modified": modified,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_remote_client_failure_stays_local_to_remote_subtree(self) -> None:
|
||||||
|
broken_response = self._get("/Clients/broken-client/downloads")
|
||||||
|
self.assertEqual(broken_response.status_code, 502)
|
||||||
|
self.assertEqual(broken_response.json()["error"]["code"], "remote_client_unreachable")
|
||||||
|
|
||||||
|
volumes_response = self._get("/Volumes")
|
||||||
|
self.assertEqual(volumes_response.status_code, 200)
|
||||||
|
self.assertEqual(volumes_response.json()["path"], "/Volumes")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
from backend.app.dependencies import get_remote_client_service
|
||||||
|
from backend.app.db.remote_client_repository import RemoteClientRepository
|
||||||
|
from backend.app.main import app
|
||||||
|
from backend.app.services.remote_client_service import RemoteClientService
|
||||||
|
|
||||||
|
|
||||||
|
class _Clock:
|
||||||
|
def __init__(self, current: datetime):
|
||||||
|
self.current = current
|
||||||
|
|
||||||
|
def now(self) -> datetime:
|
||||||
|
return self.current
|
||||||
|
|
||||||
|
def advance(self, *, seconds: int) -> None:
|
||||||
|
self.current += timedelta(seconds=seconds)
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteClientsApiGoldenTest(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.temp_dir = tempfile.TemporaryDirectory()
|
||||||
|
self.clock = _Clock(datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc))
|
||||||
|
repository = RemoteClientRepository(str(Path(self.temp_dir.name) / "remote-clients.db"))
|
||||||
|
service = RemoteClientService(
|
||||||
|
repository=repository,
|
||||||
|
registration_token="secret-token",
|
||||||
|
offline_timeout_seconds=60,
|
||||||
|
now=self.clock.now,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _override_remote_client_service() -> RemoteClientService:
|
||||||
|
return service
|
||||||
|
|
||||||
|
app.dependency_overrides[get_remote_client_service] = _override_remote_client_service
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
self.temp_dir.cleanup()
|
||||||
|
|
||||||
|
def _request(self, method: str, url: str, payload: dict | None = None, token: str | None = None) -> httpx.Response:
|
||||||
|
async def _run() -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
headers = {}
|
||||||
|
if token is not None:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||||
|
if method == "GET":
|
||||||
|
return await client.get(url, headers=headers)
|
||||||
|
return await client.post(url, json=payload, headers=headers)
|
||||||
|
|
||||||
|
return asyncio.run(_run())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _register_payload() -> dict:
|
||||||
|
return {
|
||||||
|
"client_id": "client-123",
|
||||||
|
"display_name": "Jan MacBook",
|
||||||
|
"platform": "macos",
|
||||||
|
"agent_version": "1.1.0",
|
||||||
|
"endpoint": "http://192.168.1.25:8765",
|
||||||
|
"shares": [{"key": "downloads", "label": "Downloads"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_list_is_empty_by_default(self) -> None:
|
||||||
|
response = self._request("GET", "/api/clients")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"items": []})
|
||||||
|
|
||||||
|
def test_register_then_list_then_heartbeat_and_status_timeout(self) -> None:
|
||||||
|
register_response = self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/clients/register",
|
||||||
|
self._register_payload(),
|
||||||
|
token="secret-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(register_response.status_code, 200)
|
||||||
|
register_body = register_response.json()
|
||||||
|
self.assertEqual(register_body["client_id"], "client-123")
|
||||||
|
self.assertEqual(register_body["display_name"], "Jan MacBook")
|
||||||
|
self.assertEqual(register_body["status"], "online")
|
||||||
|
self.assertEqual(register_body["last_seen"], "2026-03-26T12:00:00Z")
|
||||||
|
self.assertIsNone(register_body["last_error"])
|
||||||
|
self.assertIsNone(register_body["reachable_at"])
|
||||||
|
|
||||||
|
list_response = self._request("GET", "/api/clients")
|
||||||
|
self.assertEqual(list_response.status_code, 200)
|
||||||
|
self.assertEqual(len(list_response.json()["items"]), 1)
|
||||||
|
self.assertEqual(list_response.json()["items"][0]["status"], "online")
|
||||||
|
|
||||||
|
self.clock.advance(seconds=30)
|
||||||
|
heartbeat_response = self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/clients/heartbeat",
|
||||||
|
{"client_id": "client-123", "agent_version": "1.1.1"},
|
||||||
|
token="secret-token",
|
||||||
|
)
|
||||||
|
self.assertEqual(heartbeat_response.status_code, 200)
|
||||||
|
heartbeat_body = heartbeat_response.json()
|
||||||
|
self.assertEqual(heartbeat_body["agent_version"], "1.1.1")
|
||||||
|
self.assertEqual(heartbeat_body["last_seen"], "2026-03-26T12:00:30Z")
|
||||||
|
self.assertEqual(heartbeat_body["status"], "online")
|
||||||
|
|
||||||
|
self.clock.advance(seconds=61)
|
||||||
|
timed_out_list = self._request("GET", "/api/clients")
|
||||||
|
self.assertEqual(timed_out_list.status_code, 200)
|
||||||
|
timed_out_item = timed_out_list.json()["items"][0]
|
||||||
|
self.assertEqual(timed_out_item["status"], "offline")
|
||||||
|
self.assertEqual(timed_out_item["last_seen"], "2026-03-26T12:00:30Z")
|
||||||
|
self.assertIsNone(timed_out_item["last_error"])
|
||||||
|
self.assertIsNone(timed_out_item["reachable_at"])
|
||||||
|
|
||||||
|
def test_register_rejects_invalid_token(self) -> None:
|
||||||
|
response = self._request(
|
||||||
|
"POST",
|
||||||
|
"/api/clients/register",
|
||||||
|
self._register_payload(),
|
||||||
|
token="wrong-token",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
self.assertEqual(response.json()["error"]["code"], "forbidden")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -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()
|
||||||
@@ -850,7 +850,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('id="download-modal-close-btn"', body)
|
self.assertIn('id="download-modal-close-btn"', body)
|
||||||
self.assertIn('id="context-menu"', body)
|
self.assertIn('id="context-menu"', body)
|
||||||
self.assertIn('id="context-menu-scope"', body)
|
self.assertIn('id="context-menu-scope"', body)
|
||||||
self.assertIn('id="context-menu-target"', body)
|
self.assertNotIn('id="context-menu-target"', body)
|
||||||
self.assertIn('id="context-menu-open-btn"', body)
|
self.assertIn('id="context-menu-open-btn"', body)
|
||||||
self.assertIn('id="context-menu-edit-btn"', body)
|
self.assertIn('id="context-menu-edit-btn"', body)
|
||||||
self.assertIn('id="context-menu-download-btn"', body)
|
self.assertIn('id="context-menu-download-btn"', body)
|
||||||
@@ -1133,7 +1133,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('setStatus("Preparing download...");', app_js)
|
self.assertIn('setStatus("Preparing download...");', app_js)
|
||||||
self.assertIn('setStatus("Requesting download...");', app_js)
|
self.assertIn('setStatus("Requesting download...");', app_js)
|
||||||
self.assertIn('setStatus(zipDownload ? "Preparing download..." : "Requesting download...");', app_js)
|
self.assertIn('setStatus(zipDownload ? "Preparing download..." : "Requesting download...");', app_js)
|
||||||
self.assertIn('setStatus(`Download requested: ${anchor.download}`);', app_js)
|
self.assertIn('setStatus(`Download requested: ${fileName}`);', app_js)
|
||||||
self.assertIn('"/api/files/download/archive-prepare"', app_js)
|
self.assertIn('"/api/files/download/archive-prepare"', app_js)
|
||||||
self.assertIn('"/api/files/duplicate"', app_js)
|
self.assertIn('"/api/files/duplicate"', app_js)
|
||||||
self.assertIn('"/api/files/delete"', app_js)
|
self.assertIn('"/api/files/delete"', app_js)
|
||||||
@@ -1181,24 +1181,26 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('if (!row) {', app_js)
|
self.assertIn('if (!row) {', app_js)
|
||||||
self.assertIn('closeContextMenu();', app_js)
|
self.assertIn('closeContextMenu();', app_js)
|
||||||
self.assertIn('elements.openButton.classList.toggle("hidden", isMulti);', app_js)
|
self.assertIn('elements.openButton.classList.toggle("hidden", isMulti);', app_js)
|
||||||
self.assertIn('const openableSingle = items.length === 1 && isOpenableSelection(items[0]);', app_js)
|
self.assertIn('const openableSingle =', app_js)
|
||||||
|
self.assertIn('items[0].kind === "directory" || isRemoteViewableSelection(items[0])', app_js)
|
||||||
self.assertIn('elements.openButton.disabled = !openableSingle;', app_js)
|
self.assertIn('elements.openButton.disabled = !openableSingle;', app_js)
|
||||||
self.assertIn('if (item.kind === "directory") {', app_js)
|
self.assertIn('if (item.kind === "directory") {', app_js)
|
||||||
self.assertIn('return isImageSelection(item) || isVideoSelection(item);', app_js)
|
self.assertIn('return isImageSelection(item) || isVideoSelection(item);', app_js)
|
||||||
self.assertIn('const editableSingle = items.length === 1 && isEditableSelection(items[0]);', app_js)
|
self.assertIn('const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]);', app_js)
|
||||||
self.assertIn('return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html", ".conf"].some((suffix) => lower.endsWith(suffix));', app_js)
|
self.assertIn('return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html", ".conf"].some((suffix) => lower.endsWith(suffix));', app_js)
|
||||||
self.assertIn('if (!item || item.kind !== "file") {', app_js)
|
self.assertIn('if (!item || item.kind !== "file") {', app_js)
|
||||||
self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js)
|
self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection);', app_js)
|
||||||
self.assertIn('elements.editButton.disabled = !editableSingle;', app_js)
|
self.assertIn('elements.editButton.disabled = !editableSingle;', app_js)
|
||||||
self.assertIn('const downloadableSelection = items.length > 0;', app_js)
|
self.assertIn('const downloadableSelection = items.length === 1 && items[0].kind === "file";', app_js)
|
||||||
self.assertIn('elements.downloadButton.classList.remove("hidden");', app_js)
|
self.assertIn('elements.downloadButton.classList.remove("hidden");', app_js)
|
||||||
self.assertIn('elements.downloadButton.disabled = !downloadableSelection;', app_js)
|
self.assertIn('elements.downloadButton.disabled = !downloadableSelection;', app_js)
|
||||||
self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js)
|
self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection);', app_js)
|
||||||
self.assertIn('elements.duplicateButton.classList.remove("hidden");', app_js)
|
self.assertIn('elements.duplicateButton.classList.remove("hidden");', app_js)
|
||||||
self.assertIn('elements.duplicateButton.disabled = items.length === 0;', app_js)
|
self.assertIn('elements.duplicateButton.disabled = remoteSelection || items.length === 0;', app_js)
|
||||||
self.assertIn('elements.copyButton.classList.remove("hidden");', app_js)
|
self.assertIn('elements.copyButton.classList.remove("hidden");', app_js)
|
||||||
self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js)
|
self.assertIn('elements.copyButton.disabled = remoteSelection || items.length === 0;', app_js)
|
||||||
self.assertIn('elements.moveButton.classList.remove("hidden");', app_js)
|
self.assertIn('elements.moveButton.classList.remove("hidden");', app_js)
|
||||||
|
self.assertIn('elements.moveButton.disabled = remoteSelection || items.length === 0;', app_js)
|
||||||
self.assertIn('elements.propertiesButton.classList.remove("hidden");', app_js)
|
self.assertIn('elements.propertiesButton.classList.remove("hidden");', app_js)
|
||||||
self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js)
|
self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js)
|
||||||
self.assertIn('openCurrentDirectory();', app_js)
|
self.assertIn('openCurrentDirectory();', app_js)
|
||||||
@@ -1207,8 +1209,8 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('const created = await createArchiveDownloadTask(selectedPaths);', app_js)
|
self.assertIn('const created = await createArchiveDownloadTask(selectedPaths);', app_js)
|
||||||
self.assertIn('const task = await waitForArchiveDownloadReady(created.task_id);', app_js)
|
self.assertIn('const task = await waitForArchiveDownloadReady(created.task_id);', app_js)
|
||||||
self.assertIn('startArchiveDownload(task.id, task.destination);', app_js)
|
self.assertIn('startArchiveDownload(task.id, task.destination);', app_js)
|
||||||
self.assertIn('const { blob, fileName } = await downloadFileRequest(selectedPaths);', app_js)
|
self.assertIn('const response = await downloadFileRequest(selectedPaths);', app_js)
|
||||||
self.assertIn('anchor.download = fileName || selected.name;', app_js)
|
self.assertIn('anchor.download = response.fileName || selected.name;', app_js)
|
||||||
self.assertIn('openRenamePopup();', app_js)
|
self.assertIn('openRenamePopup();', app_js)
|
||||||
self.assertIn('const result = await createDuplicateTask(selectedItems.map((item) => item.path));', app_js)
|
self.assertIn('const result = await createDuplicateTask(selectedItems.map((item) => item.path));', app_js)
|
||||||
self.assertIn('showActionSummary("Duplicate", 1, 0, null);', app_js)
|
self.assertIn('showActionSummary("Duplicate", 1, 0, null);', app_js)
|
||||||
@@ -1233,7 +1235,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
|
|||||||
self.assertIn('renderInfoField("Selected items", selectedItems.length);', app_js)
|
self.assertIn('renderInfoField("Selected items", selectedItems.length);', app_js)
|
||||||
self.assertIn('renderInfoField("Files", fileCount);', app_js)
|
self.assertIn('renderInfoField("Files", fileCount);', app_js)
|
||||||
self.assertIn('renderInfoField("Directories", directoryCount);', app_js)
|
self.assertIn('renderInfoField("Directories", directoryCount);', app_js)
|
||||||
self.assertIn('document.getElementById("copy-btn").disabled = !hasSelection;', app_js)
|
self.assertIn('document.getElementById("copy-btn").disabled = remoteBrowse || !hasSelection;', app_js)
|
||||||
self.assertNotIn('Only files are supported for copy', app_js)
|
self.assertNotIn('Only files are supported for copy', app_js)
|
||||||
self.assertIn('document.getElementById("upload-menu-toggle").onclick = (event) => {', app_js)
|
self.assertIn('document.getElementById("upload-menu-toggle").onclick = (event) => {', app_js)
|
||||||
self.assertIn('document.getElementById("upload-folder-btn").onclick = openFolderPicker;', app_js)
|
self.assertIn('document.getElementById("upload-folder-btn").onclick = openFolderPicker;', app_js)
|
||||||
|
|||||||
+182
-37
@@ -141,6 +141,10 @@ const VALID_THEME_FAMILIES = [
|
|||||||
"fluent-neon",
|
"fluent-neon",
|
||||||
];
|
];
|
||||||
const VALID_COLOR_MODES = ["dark", "light"];
|
const VALID_COLOR_MODES = ["dark", "light"];
|
||||||
|
const VIRTUAL_SOURCES = [
|
||||||
|
{ path: "/Volumes", label: "Volumes" },
|
||||||
|
{ path: "/Clients", label: "Clients" },
|
||||||
|
];
|
||||||
let searchState = {
|
let searchState = {
|
||||||
pane: "left",
|
pane: "left",
|
||||||
path: "/Volumes",
|
path: "/Volumes",
|
||||||
@@ -200,6 +204,56 @@ function activePaneState() {
|
|||||||
return paneState(state.activePane);
|
return paneState(state.activePane);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sourceRootForPath(path) {
|
||||||
|
const normalized = (path || "").trim();
|
||||||
|
if (normalized === "/Clients" || normalized.startsWith("/Clients/")) {
|
||||||
|
return "/Clients";
|
||||||
|
}
|
||||||
|
return "/Volumes";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRemoteBrowsePath(path) {
|
||||||
|
return sourceRootForPath(path) === "/Clients";
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSourceSwitchers() {
|
||||||
|
["left", "right"].forEach((pane) => {
|
||||||
|
const container = document.getElementById(`${pane}-source-switcher`);
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const activeSource = sourceRootForPath(paneState(pane).currentPath);
|
||||||
|
[...container.querySelectorAll("button[data-source-path]")].forEach((button) => {
|
||||||
|
const isActive = button.dataset.sourcePath === activeSource;
|
||||||
|
button.disabled = isActive;
|
||||||
|
button.setAttribute("aria-pressed", isActive ? "true" : "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSourceSwitchers() {
|
||||||
|
["left", "right"].forEach((pane) => {
|
||||||
|
const toolbar = document.querySelector(`#${pane}-pane .pane-topbar`);
|
||||||
|
if (!toolbar || document.getElementById(`${pane}-source-switcher`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.id = `${pane}-source-switcher`;
|
||||||
|
container.className = "pane-source-switcher";
|
||||||
|
VIRTUAL_SOURCES.forEach((source) => {
|
||||||
|
const button = createButton(source.label, () => {
|
||||||
|
setActivePane(pane);
|
||||||
|
navigateTo(pane, source.path);
|
||||||
|
});
|
||||||
|
button.type = "button";
|
||||||
|
button.dataset.sourcePath = source.path;
|
||||||
|
container.append(button);
|
||||||
|
});
|
||||||
|
toolbar.prepend(container);
|
||||||
|
});
|
||||||
|
syncSourceSwitchers();
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(msg) {
|
function setStatus(msg) {
|
||||||
document.getElementById("status").textContent = msg;
|
document.getElementById("status").textContent = msg;
|
||||||
}
|
}
|
||||||
@@ -382,7 +436,6 @@ function contextMenuElements() {
|
|||||||
return {
|
return {
|
||||||
menu: document.getElementById("context-menu"),
|
menu: document.getElementById("context-menu"),
|
||||||
scope: document.getElementById("context-menu-scope"),
|
scope: document.getElementById("context-menu-scope"),
|
||||||
target: document.getElementById("context-menu-target"),
|
|
||||||
openButton: document.getElementById("context-menu-open-btn"),
|
openButton: document.getElementById("context-menu-open-btn"),
|
||||||
editButton: document.getElementById("context-menu-edit-btn"),
|
editButton: document.getElementById("context-menu-edit-btn"),
|
||||||
downloadButton: document.getElementById("context-menu-download-btn"),
|
downloadButton: document.getElementById("context-menu-download-btn"),
|
||||||
@@ -405,6 +458,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");
|
||||||
}
|
}
|
||||||
@@ -703,7 +773,8 @@ function closeContextMenu() {
|
|||||||
}
|
}
|
||||||
elements.menu.classList.add("hidden");
|
elements.menu.classList.add("hidden");
|
||||||
elements.scope.textContent = "";
|
elements.scope.textContent = "";
|
||||||
elements.target.textContent = "";
|
elements.menu.style.left = "";
|
||||||
|
elements.menu.style.top = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function openContextMenu(pane, entry, event) {
|
function openContextMenu(pane, entry, event) {
|
||||||
@@ -716,6 +787,7 @@ function openContextMenu(pane, entry, event) {
|
|||||||
const items = selectedPathsSet.has(entry.path)
|
const items = selectedPathsSet.has(entry.path)
|
||||||
? selectedItems.map((item) => ({ ...item }))
|
? selectedItems.map((item) => ({ ...item }))
|
||||||
: [selectedEntryFromItem(entry)];
|
: [selectedEntryFromItem(entry)];
|
||||||
|
const remoteSelection = items.some((item) => isRemoteBrowsePath(item.path));
|
||||||
|
|
||||||
contextMenuState.open = true;
|
contextMenuState.open = true;
|
||||||
contextMenuState.pane = pane;
|
contextMenuState.pane = pane;
|
||||||
@@ -723,34 +795,70 @@ 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 && isOpenableSelection(items[0]);
|
const openableSingle =
|
||||||
const editableSingle = items.length === 1 && isEditableSelection(items[0]);
|
items.length === 1 && (remoteSelection ? items[0].kind === "directory" || isRemoteViewableSelection(items[0]) : isOpenableSelection(items[0]));
|
||||||
const downloadableSelection = items.length > 0;
|
const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]);
|
||||||
|
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.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");
|
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.remove("hidden");
|
elements.downloadButton.classList.remove("hidden");
|
||||||
elements.downloadButton.disabled = !downloadableSelection;
|
elements.downloadButton.disabled = !downloadableSelection;
|
||||||
elements.renameButton.classList.toggle("hidden", isMulti);
|
elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection);
|
||||||
elements.duplicateButton.classList.remove("hidden");
|
elements.duplicateButton.classList.remove("hidden");
|
||||||
elements.duplicateButton.disabled = items.length === 0;
|
elements.duplicateButton.disabled = remoteSelection || items.length === 0;
|
||||||
elements.copyButton.classList.remove("hidden");
|
elements.copyButton.classList.remove("hidden");
|
||||||
elements.copyButton.disabled = items.length === 0;
|
elements.copyButton.disabled = remoteSelection || items.length === 0;
|
||||||
elements.moveButton.classList.remove("hidden");
|
elements.moveButton.classList.remove("hidden");
|
||||||
|
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.propertiesButton.classList.remove("hidden");
|
elements.propertiesButton.classList.remove("hidden");
|
||||||
elements.propertiesButton.disabled = items.length === 0;
|
elements.propertiesButton.disabled = items.length === 0;
|
||||||
|
|
||||||
const menuWidth = 220;
|
|
||||||
const menuHeight = 120;
|
|
||||||
const x = Math.min(event.clientX, window.innerWidth - menuWidth - 12);
|
|
||||||
const y = Math.min(event.clientY, window.innerHeight - menuHeight - 12);
|
|
||||||
elements.menu.style.left = `${Math.max(8, x)}px`;
|
|
||||||
elements.menu.style.top = `${Math.max(8, y)}px`;
|
|
||||||
elements.menu.classList.remove("hidden");
|
elements.menu.classList.remove("hidden");
|
||||||
|
positionContextMenu(elements.menu, event.currentTarget, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionContextMenu(menu, rowElement, event) {
|
||||||
|
if (!menu) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const paneElement = rowElement instanceof Element ? rowElement.closest(".pane") : null;
|
||||||
|
const paneRect = paneElement ? paneElement.getBoundingClientRect() : null;
|
||||||
|
const rowRect = rowElement instanceof Element ? rowElement.getBoundingClientRect() : null;
|
||||||
|
const menuRect = menu.getBoundingClientRect();
|
||||||
|
const viewportPadding = 8;
|
||||||
|
const panePadding = 8;
|
||||||
|
|
||||||
|
const minLeft = paneRect ? Math.max(viewportPadding, paneRect.left + panePadding) : viewportPadding;
|
||||||
|
const maxLeft = paneRect
|
||||||
|
? Math.max(minLeft, Math.min(window.innerWidth - viewportPadding - menuRect.width, paneRect.right - panePadding - menuRect.width))
|
||||||
|
: Math.max(minLeft, window.innerWidth - viewportPadding - menuRect.width);
|
||||||
|
const preferredLeft = rowRect ? rowRect.left + 12 : event.clientX;
|
||||||
|
const left = Math.max(minLeft, Math.min(maxLeft, preferredLeft));
|
||||||
|
|
||||||
|
const paneTop = paneRect ? paneRect.top + panePadding : viewportPadding;
|
||||||
|
const paneBottom = paneRect ? paneRect.bottom - panePadding : window.innerHeight - viewportPadding;
|
||||||
|
const rowTop = rowRect ? rowRect.top : event.clientY;
|
||||||
|
const rowBottom = rowRect ? rowRect.bottom : event.clientY;
|
||||||
|
const spaceBelow = paneBottom - rowBottom;
|
||||||
|
const spaceAbove = rowTop - paneTop;
|
||||||
|
|
||||||
|
let top;
|
||||||
|
if (spaceBelow >= menuRect.height || spaceBelow >= spaceAbove) {
|
||||||
|
top = rowBottom;
|
||||||
|
} else if (spaceAbove >= menuRect.height) {
|
||||||
|
top = rowTop - menuRect.height;
|
||||||
|
} else {
|
||||||
|
top = Math.max(paneTop, Math.min(paneBottom - menuRect.height, rowBottom));
|
||||||
|
}
|
||||||
|
top = Math.max(paneTop, Math.min(top, paneBottom - menuRect.height));
|
||||||
|
|
||||||
|
menu.style.left = `${left}px`;
|
||||||
|
menu.style.top = `${top}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyContextMenuSelection() {
|
function applyContextMenuSelection() {
|
||||||
@@ -903,17 +1011,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)) {
|
||||||
const anchor = document.createElement("a");
|
fileName = startDirectSingleFileDownload(selected.path, selected.name).fileName || selected.name;
|
||||||
anchor.href = url;
|
} else {
|
||||||
anchor.download = fileName || selected.name;
|
const response = await downloadFileRequest(selectedPaths);
|
||||||
document.body.append(anchor);
|
const url = URL.createObjectURL(response.blob);
|
||||||
anchor.click();
|
const anchor = document.createElement("a");
|
||||||
anchor.remove();
|
anchor.href = url;
|
||||||
URL.revokeObjectURL(url);
|
anchor.download = response.fileName || selected.name;
|
||||||
markSingleFileDownloadRequested(anchor.download, selected.path);
|
document.body.append(anchor);
|
||||||
setStatus(`Download requested: ${anchor.download}`);
|
anchor.click();
|
||||||
|
anchor.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
fileName = anchor.download || selected.name;
|
||||||
|
}
|
||||||
|
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") {
|
||||||
@@ -1222,6 +1336,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 });
|
||||||
}
|
}
|
||||||
@@ -2050,12 +2176,18 @@ function updateActionButtons() {
|
|||||||
const hasSelection = count > 0;
|
const hasSelection = count > 0;
|
||||||
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");
|
||||||
document.getElementById("view-btn").disabled = !exactlyOne || !allFiles;
|
const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath);
|
||||||
document.getElementById("edit-btn").disabled = !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
|
const remoteViewable = exactlyOne && isRemoteViewableSelection(selectedItems[0] || null);
|
||||||
document.getElementById("rename-btn").disabled = !exactlyOne;
|
document.getElementById("view-btn").disabled = remoteBrowse ? !remoteViewable : !exactlyOne || !allFiles;
|
||||||
document.getElementById("delete-btn").disabled = !hasSelection;
|
document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null);
|
||||||
document.getElementById("copy-btn").disabled = !hasSelection;
|
document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne;
|
||||||
document.getElementById("move-btn").disabled = !hasSelection;
|
document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection;
|
||||||
|
document.getElementById("copy-btn").disabled = remoteBrowse || !hasSelection;
|
||||||
|
document.getElementById("move-btn").disabled = remoteBrowse || !hasSelection;
|
||||||
|
document.getElementById("mkdir-btn").disabled = remoteBrowse;
|
||||||
|
document.getElementById("upload-btn").disabled = remoteBrowse;
|
||||||
|
document.getElementById("upload-menu-toggle").disabled = remoteBrowse;
|
||||||
|
document.getElementById("upload-folder-btn").disabled = remoteBrowse;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEditableSelection(item) {
|
function isEditableSelection(item) {
|
||||||
@@ -2208,7 +2340,7 @@ function currentParentPath(path) {
|
|||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (normalized === "/Volumes") {
|
if (normalized === "/Volumes" || normalized === "/Clients") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (normalized.startsWith("/")) {
|
if (normalized.startsWith("/")) {
|
||||||
@@ -2287,16 +2419,17 @@ function renderBreadcrumbs(pane, path) {
|
|||||||
const isHostPath = normalized.startsWith("/");
|
const isHostPath = normalized.startsWith("/");
|
||||||
const parts = normalized.split("/").filter(Boolean);
|
const parts = normalized.split("/").filter(Boolean);
|
||||||
if (isHostPath) {
|
if (isHostPath) {
|
||||||
|
const rootTarget = parts.length > 0 ? `/${parts[0]}` : "/Volumes";
|
||||||
const rootCrumb = createButton("/", () => {
|
const rootCrumb = createButton("/", () => {
|
||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
navigateTo(pane, "/Volumes");
|
navigateTo(pane, rootTarget);
|
||||||
});
|
});
|
||||||
rootCrumb.type = "button";
|
rootCrumb.type = "button";
|
||||||
rootCrumb.onclick = (ev) => {
|
rootCrumb.onclick = (ev) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setActivePane(pane);
|
setActivePane(pane);
|
||||||
navigateTo(pane, "/Volumes");
|
navigateTo(pane, rootTarget);
|
||||||
};
|
};
|
||||||
nav.append(rootCrumb);
|
nav.append(rootCrumb);
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
@@ -2619,6 +2752,7 @@ async function loadBrowsePane(pane) {
|
|||||||
});
|
});
|
||||||
const data = await apiRequest("GET", `/api/browse?${query.toString()}`);
|
const data = await apiRequest("GET", `/api/browse?${query.toString()}`);
|
||||||
model.currentPath = data.path;
|
model.currentPath = data.path;
|
||||||
|
syncSourceSwitchers();
|
||||||
renderBreadcrumbs(pane, data.path);
|
renderBreadcrumbs(pane, data.path);
|
||||||
|
|
||||||
const visibleItems = [];
|
const visibleItems = [];
|
||||||
@@ -2682,6 +2816,8 @@ function navigateTo(pane, path) {
|
|||||||
model.currentRowIndex = 0;
|
model.currentRowIndex = 0;
|
||||||
clearSelectionAnchor(pane);
|
clearSelectionAnchor(pane);
|
||||||
setSelectedItem(pane, null);
|
setSelectedItem(pane, null);
|
||||||
|
syncSourceSwitchers();
|
||||||
|
updateActionButtons();
|
||||||
loadBrowsePane(pane);
|
loadBrowsePane(pane);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4625,6 +4761,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;
|
||||||
@@ -4726,7 +4870,7 @@ function openCurrentDirectory() {
|
|||||||
openImageViewer();
|
openImageViewer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isVideoSelection(item)) {
|
if (!isRemoteBrowsePath(item.path) && isVideoSelection(item)) {
|
||||||
openVideoViewer();
|
openVideoViewer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5305,6 +5449,7 @@ async function init() {
|
|||||||
setError("actions-error", "");
|
setError("actions-error", "");
|
||||||
applyTheme("default", "dark");
|
applyTheme("default", "dark");
|
||||||
setActivePane("left");
|
setActivePane("left");
|
||||||
|
ensureSourceSwitchers();
|
||||||
setupEvents();
|
setupEvents();
|
||||||
await loadSettings();
|
await loadSettings();
|
||||||
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);
|
applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);
|
||||||
|
|||||||
+8
-12
@@ -907,7 +907,11 @@ button:disabled {
|
|||||||
|
|
||||||
.context-menu {
|
.context-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
min-width: 220px;
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(196px, calc(100vw - 24px));
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -924,24 +928,16 @@ button:disabled {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu-target {
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-primary);
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-separator {
|
.context-menu-separator {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 8px 0;
|
margin: 6px 0 8px;
|
||||||
background: var(--color-border);
|
background: var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-menu button {
|
.context-menu button {
|
||||||
width: 100%;
|
width: auto;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
#upload-modal .popup-card {
|
#upload-modal .popup-card {
|
||||||
|
|||||||
@@ -161,7 +161,6 @@
|
|||||||
|
|
||||||
<div id="context-menu" class="context-menu hidden" role="menu" aria-label="Item context menu">
|
<div id="context-menu" class="context-menu hidden" role="menu" aria-label="Item context menu">
|
||||||
<div id="context-menu-scope" class="context-menu-scope"></div>
|
<div id="context-menu-scope" class="context-menu-scope"></div>
|
||||||
<div id="context-menu-target" class="context-menu-target"></div>
|
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<button id="context-menu-open-btn" type="button" role="menuitem">Open</button>
|
<button id="context-menu-open-btn" type="button" role="menuitem">Open</button>
|
||||||
<button id="context-menu-edit-btn" type="button" role="menuitem">Edit</button>
|
<button id="context-menu-edit-btn" type="button" role="menuitem">Edit</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user