From 684f52be4d1a2fd526c6f37affbf4600f42d5a47 Mon Sep 17 00:00:00 2001 From: kodi Date: Thu, 26 Mar 2026 19:41:58 +0100 Subject: [PATCH] feat: remote client deel 1 --- .../remote_client_agent.example.json | 15 ++ finder_commander/remote_client_agent.py | 168 ++++++++++++++++ .../app/__pycache__/config.cpython-313.pyc | Bin 2120 -> 3217 bytes .../__pycache__/dependencies.cpython-313.pyc | Bin 7341 -> 8211 bytes .../app/__pycache__/main.cpython-313.pyc | Bin 3615 -> 3728 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 10472 -> 12031 bytes webui/backend/app/api/routes_clients.py | 39 ++++ webui/backend/app/api/schemas.py | 38 ++++ webui/backend/app/config.py | 19 +- .../app/db/remote_client_repository.py | 187 ++++++++++++++++++ webui/backend/app/dependencies.py | 17 ++ webui/backend/app/main.py | 2 + .../app/services/remote_client_service.py | 128 ++++++++++++ webui/backend/data/tasks.db | Bin 405504 -> 405504 bytes .../tests/golden/test_api_clients_golden.py | 139 +++++++++++++ 15 files changed, 751 insertions(+), 1 deletion(-) create mode 100644 finder_commander/remote_client_agent.example.json create mode 100644 finder_commander/remote_client_agent.py create mode 100644 webui/backend/app/api/routes_clients.py create mode 100644 webui/backend/app/db/remote_client_repository.py create mode 100644 webui/backend/app/services/remote_client_service.py create mode 100644 webui/backend/tests/golden/test_api_clients_golden.py diff --git a/finder_commander/remote_client_agent.example.json b/finder_commander/remote_client_agent.example.json new file mode 100644 index 0000000..e1d5879 --- /dev/null +++ b/finder_commander/remote_client_agent.example.json @@ -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" +} diff --git a/finder_commander/remote_client_agent.py b/finder_commander/remote_client_agent.py new file mode 100644 index 0000000..a21a621 --- /dev/null +++ b/finder_commander/remote_client_agent.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import argparse +import json +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from urllib import error, request + + +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(config: AgentConfig) -> 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("agent_access_token is configured for future authenticated agent endpoints", flush=True) + + while True: + 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) + time.sleep(config.heartbeat_interval_seconds) + + while True: + 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) + time.sleep(config.heartbeat_interval_seconds) + + +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", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + config = load_config(Path(args.config).resolve()) + run(config) + except KeyboardInterrupt: + return 130 + except Exception as exc: + print(str(exc), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/webui/backend/app/__pycache__/config.cpython-313.pyc b/webui/backend/app/__pycache__/config.cpython-313.pyc index 643991120584ca18c8abfef51b5262ce18089b79..61a6cae479766af0c3b778528157edac5c21e485 100644 GIT binary patch delta 1323 zcmZ`(O>7%Q6rR~XJMmxQ#5A!Z;s~eNP1@AN4G7U9oMfArHa7Cw1<8f2u{X{twztf# zLxog)n0%z)DP zU>hD#X5Ap3COr+ZcelW0{M>>d@+3$i4x=CW&&L%ovx?Gn!5Ec~xDFRo{#LOxx{ zR0)6+-VoDkV%`a)aoK1{{n)L==e7SOl~y;zP0`_2lrmC~z#($qC_qe* zm=R*g&7v?ixCPqWTrxr4XNe&N=g^JvZ4@GC_VlV=%II5j?!; z7iszt1+f(eQv!un%}+d2Fkyb>5tc}SyGQAs1|rRcNTVK|m-kXbtG4RO z8)zM`k#5s7=AWMU%N!K?0Gk6EP!oNMypDs>>F!A42}@5<78pPDhhXxLL>O{zyng|; C_gcUJ delta 419 zcmbOzc|w5iGcPX}0}y<)+L(EOWg_2QMy-i&G}zgSSku`xIVL+ZI!qR3N@nDnT+U=H z!Ut5?&hS7;;WCTD4I$BMETToCo3AltGBJuxc3_oY)dI?kOpaw;C?g7FB?B=WumV}k z3=E8)zc5UeV!Ny%2$P8xhOtbcj8Mj4R!b%o22D1#JPJm@VEVuSBtA1U nFiCu10&#g51m$lCsC;B)mSDQTAo7KWpV8o}3M*p}BiKLyj`UOl diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index 590bbb65a49b3632afcdf3a65b86a7f6b1741f21..e016732242a18e9af920d16dce542f4b785d6775 100644 GIT binary patch literal 8211 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 D53IoA delta 2294 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 Cj2#F7 diff --git a/webui/backend/app/api/routes_clients.py b/webui/backend/app/api/routes_clients.py new file mode 100644 index 0000000..539aa60 --- /dev/null +++ b/webui/backend/app/api/routes_clients.py @@ -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) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index e350fc8..24582ef 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -238,3 +238,41 @@ class SearchResultItem(BaseModel): class SearchResponse(BaseModel): items: list[SearchResultItem] 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] diff --git a/webui/backend/app/config.py b/webui/backend/app/config.py index 5d50fc0..a3c8f8e 100644 --- a/webui/backend/app/config.py +++ b/webui/backend/app/config.py @@ -9,6 +9,10 @@ from pathlib import Path class Settings: root_aliases: dict[str, 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 DEFAULT_ROOT_ALIASES = { @@ -40,4 +44,17 @@ def get_settings() -> Settings: task_db_path = os.getenv("WEBMANAGER_TASK_DB_PATH", default_task_db_path).strip() if not 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", + ) diff --git a/webui/backend/app/db/remote_client_repository.py b/webui/backend/app/db/remote_client_repository.py new file mode 100644 index 0000000..b2bad1d --- /dev/null +++ b/webui/backend/app/db/remote_client_repository.py @@ -0,0 +1,187 @@ +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 _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") diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 3db48b9..41aafd7 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -6,6 +6,7 @@ from pathlib import Path from backend.app.config import Settings, get_settings from backend.app.db.bookmark_repository import BookmarkRepository 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.task_repository import TaskRepository from backend.app.fs.filesystem_adapter import FilesystemAdapter @@ -19,6 +20,7 @@ from backend.app.services.duplicate_task_service import DuplicateTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService from backend.app.services.move_task_service import MoveTaskService +from backend.app.services.remote_client_service import RemoteClientService from backend.app.services.search_service import SearchService from backend.app.services.settings_service import SettingsService from backend.app.services.task_service import TaskService @@ -59,6 +61,12 @@ def get_settings_repository() -> SettingsRepository: 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) def get_task_runner() -> TaskRunner: return TaskRunner( @@ -155,3 +163,12 @@ async def get_search_service() -> SearchService: async def get_settings_service() -> SettingsService: 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, + ) diff --git a/webui/backend/app/main.py b/webui/backend/app/main.py index b28e714..ffff7b2 100644 --- a/webui/backend/app/main.py +++ b/webui/backend/app/main.py @@ -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_browse import router as browse_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_files import router as files_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(files_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(move_router, prefix="/api") app.include_router(search_router, prefix="/api") diff --git a/webui/backend/app/services/remote_client_service.py b/webui/backend/app/services/remote_client_service.py new file mode 100644 index 0000000..02237fc --- /dev/null +++ b/webui/backend/app/services/remote_client_service.py @@ -0,0 +1,128 @@ +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: + 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), + ) + items = [RemoteClientItem(**row) for row in self._repository.list_clients()] + return RemoteClientListResponse(items=items) + + 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, + } + + @staticmethod + def _to_iso(value: datetime) -> str: + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 3869ff4a8d03b9f7f153964ecd1bb6af62fd00ce..515638c38c544e2a0c64aa267c5ea6218d233de7 100644 GIT binary patch delta 2549 zcma)7T}&KR7@d3X%(DLrDOI{{L;c}r+s-=o&fNK-(SRF_ZD>sc7&1o7?98l90HN5X zjWNQKRuW>>wlVYIgP||P7}HW>MttzWCrON<22Io#^g-Jf!;3F%qTZ##EZwE-%OrEp zmwV3l%{kvpt~O1sHhq|ecfPu^?|Jr|ADsPnSMo0|lw1m@?xb#|&ZlrHkz86hS^q`S zTS|f!p5wUYD_wi{r3EDKe$yRx?X#|P${rma9X~ZP>bQSEr~ZBfZ9q3@90tg>oxFa81!B{S^rXB zNo6e;LZjAxpynUF2fplXVAH$`4s)@!cr^ZW{JHpmAMS){fc7Bj6@A#~=epqM(Fyhn zi9}RUl0~BSVcLQ94q}+lsES~Q=v5Jknoz5PXdP5Bp}Ga3eT+mgIwZNygtP>C^J1vc zpXq^b*~wuL=fZR0Z^L&Z(a0e$d=q}}uik{;gg}^mTON2li!d<}Xw6cCs*+-Y%mI|E zYz7-y-5^9GCTOo(AknN&u&U`MXsc3CY9Omi2A0?jts4dV*9R6flEsEb2&-r*Rg~J< zHEl0K-(Ezs@1mU0;MH~VZDFAy@q3*t9Pvme-vb5Lf2Whb%zGbn^TGsmT*Fdyk=WD` z71eS?UDXWHwF?C;@7lUXG^m$+V5yFlH!LhtM^!|HfnuJJf@sOQreQ~MTwRB1sWMNg zs;XEL70RwtC?PBXc_KQLsMywA4QmwA^*bH^qi%k52KAshuWp%tDvaiYkA*YnwwGPz z4?=X`8(8LZ{_HXzLN)6J?X~I!t+na}Eu~6Z_1<_U^yKvM72ZF-BKSv|lW>SjzMgR6 zmt%{u$D`*Wvvq%jE(iC`ZE)4%Xo=)Fi|I5NYfUQPTQzaCG3VxzVqKt{y$c2Yc1j}7<&|b_eWx)H=78M(O2lgu1-E=daj7PKaSU8$WrZVB7 zR4ksnmbjWFgzU=?cn``h2R=pMqEOQC*v8NJ499U0kD)>*#c~>EAnR79L42)&Uv6fg z-P3r1z96i?M%a1g!)^SCbC|JS>ae9l$(492gMVPPSF`G58ajw<2+$Vr@Iii>ALgc2--9Q_n54U8&lx&z ziiHrJ5#>&m%c(wkE6NhUiD+1)Yo^g|nEFrsonDf)MfzHWdDndo1Ye^3KVeNo%Tz|o zSh$M;Wmaebc7%DpY2D&vfc2TiQG@7T^ym5=7t<6u+slThFvxsDtFl8(d8FJB$E&PO zgsQAZ*~Sl8l258kL-*}%@pc*g6aDK<_OC1Q)NVr^C3eCS 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()