285 lines
12 KiB
Python
285 lines
12 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
|
|
|
|
# -- 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())
|