Files
claudetools/.claude/skills/seafile/scripts/seafile_client.py
Howard Enos fa4867580f sync: auto-sync from HOWARD-HOME at 2026-07-05 15:29:59
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 15:29:59
2026-07-05 15:30:28 -07:00

192 lines
7.6 KiB
Python

#!/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 <tok>` 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
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())