#!/usr/bin/env python3 """Seafile Pro Web API v2.1 client for the `seafile` skill. Talks to ACG's live Seafile Pro server (the "SeaCloud" / "SeaDrive" backend) that runs in Docker on Jupiter. Read-only: enumerates users, libraries (repos) and per-user storage so the GPS backup audit can see who is actually backing up here. Auth: POST /api2/auth-token/ (form username+password) -> a long-lived API token, then Bearer-style `Authorization: Token ` on every call. The token is a secret; it is cached under the skill's .cache/ (gitignored) like the b2 skill. Base URL defaults to the internal Docker endpoint (http://172.16.3.20:8082) which is directly reachable on the LAN/Tailscale; override with SEAFILE_URL (e.g. the public https://sync.azcomputerguru.com). """ from __future__ import annotations import json import os import sys import time import urllib.parse from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional # Import the shared backup helpers from .claude/scripts. _SKILL_DIR = Path(__file__).resolve().parent.parent # .../skills/seafile _SCRIPTS_DIR = _SKILL_DIR.parent.parent / "scripts" # .../.claude/scripts sys.path.insert(0, str(_SCRIPTS_DIR)) import backup_common as bc # noqa: E402 VAULT_ENTRY = "services/seafile-pro.sops.yaml" VAULT_FIELD_USER = "credentials.username" VAULT_FIELD_PASS = "credentials.password" DEFAULT_URL = os.environ.get("SEAFILE_URL", "http://172.16.3.20:8082") CACHE_DIR = _SKILL_DIR / ".cache" TOKEN_CACHE = CACHE_DIR / "token.json" # Seafile API tokens don't rotate on their own; re-auth weekly to stay safe. TOKEN_TTL_SECONDS = 7 * 24 * 3600 class SeafileClient: def __init__(self, base: Optional[str] = None): self.base = (base or DEFAULT_URL).rstrip("/") self._token: Optional[str] = None # -- auth ------------------------------------------------------------------ def _read_cache(self) -> Optional[str]: if not TOKEN_CACHE.exists(): return None try: c = json.loads(TOKEN_CACHE.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return None if c.get("base") != self.base: return None ts = c.get("authorized_at") try: when = datetime.fromisoformat(ts) except (TypeError, ValueError): return None if when.tzinfo is None: when = when.replace(tzinfo=timezone.utc) if (datetime.now(timezone.utc) - when).total_seconds() > TOKEN_TTL_SECONDS: return None return c.get("token") def _write_cache(self, token: str) -> None: CACHE_DIR.mkdir(parents=True, exist_ok=True) TOKEN_CACHE.write_text(json.dumps({ "base": self.base, "token": token, "authorized_at": datetime.now(timezone.utc).isoformat(), }, indent=2), encoding="utf-8") try: os.chmod(TOKEN_CACHE, 0o600) except OSError: pass def _authorize(self) -> str: user = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_USER) pw = bc.vault_get_field(VAULT_ENTRY, VAULT_FIELD_PASS) form = urllib.parse.urlencode({"username": user, "password": pw}).encode() req = bc.urllib.request.Request( f"{self.base}/api2/auth-token/", data=form, method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}) try: with bc.urllib.request.urlopen(req, timeout=bc.HTTP_TIMEOUT) as resp: body = json.loads(resp.read().decode("utf-8", errors="replace")) except bc.urllib.error.HTTPError as exc: raw = exc.read().decode("utf-8", errors="replace") raise bc.BackupError(f"Seafile auth-token failed (HTTP {exc.code}): {raw[:300]}") except bc.urllib.error.URLError as exc: raise bc.BackupError(f"Seafile auth request failed: {exc}") token = body.get("token") if not token: raise bc.BackupError(f"Seafile auth-token response missing token: {body}") self._write_cache(token) return token def _tok(self) -> str: if not self._token: self._token = self._read_cache() or self._authorize() return self._token def _get(self, path: str, params: Optional[dict] = None, retry_auth: bool = True) -> Any: url = f"{self.base}{path}" if params: url += "?" + urllib.parse.urlencode(params) status, body = bc.http_json("GET", url, headers={"Authorization": f"Token {self._tok()}"}) if status in (401, 403) and retry_auth: self._token = self._authorize() return self._get(path, params, retry_auth=False) if status != 200: raise bc.BackupError(f"Seafile GET {path} failed (HTTP {status}): {body}", status=status) return body # -- read methods ---------------------------------------------------------- def server_info(self) -> dict: return self._get("/api2/server-info/") def list_users(self) -> list[dict]: """All users via the admin API, paginated. Includes quota_usage (bytes).""" users: list[dict] = [] page = 1 while True: body = self._get("/api/v2.1/admin/users/", {"page": page, "per_page": 100}) batch = body.get("data", []) if isinstance(body, dict) else [] users.extend(batch) info = body.get("page_info") if isinstance(body, dict) else None # Admin users endpoint returns has_next_page in page_info on some # versions; else stop when a short page comes back. if info and not info.get("has_next_page"): break if not info and len(batch) < 100: break if not batch: break page += 1 return users def list_libraries(self) -> list[dict]: """All libraries (repos) via the admin API, paginated. Includes size + owner.""" repos: list[dict] = [] page = 1 while True: body = self._get("/api/v2.1/admin/libraries/", {"page": page, "per_page": 100}) batch = body.get("repos", []) if isinstance(body, dict) else [] repos.extend(batch) info = body.get("page_info") if isinstance(body, dict) else None if info and not info.get("has_next_page"): break if not info and len(batch) < 100: break if not batch: break page += 1 return repos def list_devices(self, user_email: Optional[str] = None) -> list[dict]: """Sync/desktop devices seen by the server (admin). Optional per-user filter. Proves a machine actually connected a Seafile/SeaDrive client. Endpoint availability varies by Seafile version; returns [] if unsupported. """ try: body = self._get("/api/v2.1/admin/devices/", {"page": 1, "per_page": 500}) except bc.BackupError: return [] devices = body.get("devices", []) if isinstance(body, dict) else [] if user_email: devices = [d for d in devices if d.get("user") == user_email] return devices # -- write plumbing -------------------------------------------------------- def _write(self, method: str, path: str, form: Optional[dict] = None, retry_auth: bool = True) -> Any: """POST/PUT/DELETE against the admin API using form-encoding. The Seafile admin endpoints read from request.data; the share endpoints use QueryDict.getlist('share_to'), which only works for form-encoded bodies (not JSON) -- so all writes go out as form-urlencoded via the shared transport (bc.http_json form=). Any 2xx is success; on error we surface the server's message. """ status, parsed = bc.http_json( method, f"{self.base}{path}", form=form or {}, headers={"Authorization": f"Token {self._tok()}"}) if status in (401, 403) and retry_auth: self._token = self._authorize() return self._write(method, path, form, retry_auth=False) if not (200 <= status < 300): msg = "" if isinstance(parsed, dict): msg = parsed.get("error_msg") or parsed.get("detail") or "" if not msg: msg = json.dumps(parsed)[:400] if parsed else "" raise bc.BackupError(f"Seafile {method} {path} failed (HTTP {status}): {msg}", status=status) return parsed @staticmethod def _uenc(email: str) -> str: return urllib.parse.quote(email, safe="") # -- user writes ----------------------------------------------------------- def create_user(self, email: str, password: str, name: Optional[str] = None, quota_mb: Optional[int] = None, role: Optional[str] = None, is_active: bool = True) -> dict: form: dict = {"email": email, "password": password, "is_active": "true" if is_active else "false"} if name: form["name"] = name if quota_mb is not None: form["quota_total"] = str(int(quota_mb)) if role: form["role"] = role return self._write("POST", "/api/v2.1/admin/users/", form) def update_user(self, email: str, is_active: Optional[bool] = None, password: Optional[str] = None, name: Optional[str] = None, quota_mb: Optional[int] = None, role: Optional[str] = None) -> dict: form: dict = {} if is_active is not None: form["is_active"] = "true" if is_active else "false" if password is not None: form["password"] = password if name is not None: form["name"] = name if quota_mb is not None: form["quota_total"] = str(int(quota_mb)) if role is not None: form["role"] = role if not form: raise bc.BackupError("update_user: nothing to change") return self._write("PUT", f"/api/v2.1/admin/users/{self._uenc(email)}/", form) def delete_user(self, email: str) -> dict: return self._write("DELETE", f"/api/v2.1/admin/users/{self._uenc(email)}/") # -- library writes -------------------------------------------------------- def create_library(self, name: str, owner: Optional[str] = None) -> dict: form: dict = {"name": name} if owner: form["owner"] = owner return self._write("POST", "/api/v2.1/admin/libraries/", form) def transfer_library(self, repo_id: str, new_owner: str) -> dict: return self._write("PUT", f"/api/v2.1/admin/libraries/{repo_id}/", {"owner": new_owner}) def delete_library(self, repo_id: str) -> dict: return self._write("DELETE", f"/api/v2.1/admin/libraries/{repo_id}/") # -- sharing writes -------------------------------------------------------- def share_library(self, repo_id: str, share_to, permission: str = "rw", share_type: str = "user") -> dict: recipients = share_to if isinstance(share_to, list) else [share_to] form = {"share_type": share_type, "permission": permission, "share_to": recipients} return self._write("POST", f"/api/v2.1/admin/libraries/{repo_id}/shares/", form) def unshare_library(self, repo_id: str, share_to: str, share_type: str = "user") -> dict: return self._write("DELETE", f"/api/v2.1/admin/libraries/{repo_id}/shares/", {"share_type": share_type, "share_to": share_to}) def main() -> int: try: c = SeafileClient() info = c.server_info() print(f"[OK] Seafile reachable at {c.base}; version={info.get('version')} " f"edition={'pro' if 'seafile-pro' in (info.get('features') or []) else 'ce'}") return 0 except bc.BackupError as exc: print(f"[ERROR] {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())