sync: auto-sync from HOWARD-HOME at 2026-07-05 19:59:52
Author: Howard Enos Machine: HOWARD-HOME Timestamp: 2026-07-05 19:59:52
This commit is contained in:
@@ -12,6 +12,17 @@ Commands:
|
||||
the GPS view: users/usage, optionally cross-checked
|
||||
against GuruRMM endpoints (SeaDrive/Seafile installed?)
|
||||
|
||||
Write ops (admin) -- ALL preview by default; add --confirm to apply:
|
||||
user-create EMAIL --password P [--name N] [--quota-gb G] [--role R] [--inactive]
|
||||
user-set EMAIL [--active|--inactive] [--quota-gb G] [--name N] [--role R] [--password P]
|
||||
quota EMAIL --gb G set storage quota (GiB)
|
||||
user-delete EMAIL remove account (irreversible)
|
||||
lib-create NAME [--owner EMAIL] create a library
|
||||
lib-transfer REPO_ID --to EMAIL transfer ownership
|
||||
lib-delete REPO_ID delete a library (irreversible)
|
||||
share REPO_ID --to EMAIL... [--perm r|rw] [--group]
|
||||
unshare REPO_ID --to EMAIL [--group]
|
||||
|
||||
Server-side is the source of "who has an account + how much data"; --rmm adds the
|
||||
"which enrolled machine actually runs the client" half (the 'both' cross-check).
|
||||
"""
|
||||
@@ -185,8 +196,129 @@ def cmd_audit(c: SeafileClient, args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
# --- write ops (gated) --------------------------------------------------------
|
||||
# Seafile stores user quota in MB (binary unit); its own admin UI treats the quota
|
||||
# field as GiB, i.e. 1 GiB = 1024 MB. We expose --gb and convert the same way.
|
||||
GIB_MB = 1024
|
||||
|
||||
|
||||
def _gb_to_mb(gb) -> int:
|
||||
return int(round(float(gb) * GIB_MB))
|
||||
|
||||
|
||||
def _do_write(args, label: str, detail: dict, fn, reminder: str | None = None) -> int:
|
||||
"""Print the plan; execute only with --confirm. Preview is fully offline."""
|
||||
print(f"[PLAN] {label}")
|
||||
for k, v in detail.items():
|
||||
print(f" {k}: {v}")
|
||||
if not getattr(args, "confirm", False):
|
||||
print("[PREVIEW] not executed. Re-run with --confirm to apply.")
|
||||
return 0
|
||||
result = fn()
|
||||
print(f"[OK] {label}")
|
||||
if reminder:
|
||||
print(f"[REMINDER] {reminder}")
|
||||
if result:
|
||||
print(json.dumps(result, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_user_create(c: SeafileClient, args) -> int:
|
||||
quota_mb = _gb_to_mb(args.quota_gb) if args.quota_gb is not None else None
|
||||
detail = {"email": args.email, "active": not args.inactive, "password": "<set>"}
|
||||
if args.name:
|
||||
detail["name"] = args.name
|
||||
if quota_mb is not None:
|
||||
detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)"
|
||||
if args.role:
|
||||
detail["role"] = args.role
|
||||
return _do_write(
|
||||
args, f"create user {args.email}", detail,
|
||||
lambda: c.create_user(args.email, args.password, name=args.name,
|
||||
quota_mb=quota_mb, role=args.role,
|
||||
is_active=not args.inactive),
|
||||
reminder="password is a credential -- store it in the SOPS vault (vault skill).")
|
||||
|
||||
|
||||
def cmd_user_set(c: SeafileClient, args) -> int:
|
||||
if args.active and args.inactive:
|
||||
print("[ERROR] --active and --inactive are mutually exclusive", file=sys.stderr)
|
||||
return 2
|
||||
is_active = True if args.active else (False if args.inactive else None)
|
||||
quota_mb = _gb_to_mb(args.quota_gb) if args.quota_gb is not None else None
|
||||
detail: dict = {"email": args.email}
|
||||
if is_active is not None:
|
||||
detail["active"] = is_active
|
||||
if quota_mb is not None:
|
||||
detail["quota"] = f"{quota_mb} MB (= {args.quota_gb} GiB)"
|
||||
if args.name is not None:
|
||||
detail["name"] = args.name
|
||||
if args.role is not None:
|
||||
detail["role"] = args.role
|
||||
if args.password is not None:
|
||||
detail["password"] = "<set>"
|
||||
if len(detail) == 1:
|
||||
print("[ERROR] nothing to change (give --active/--inactive/--quota-gb/--name/--role/--password)",
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
reminder = ("password is a credential -- store it in the SOPS vault (vault skill)."
|
||||
if args.password is not None else None)
|
||||
return _do_write(
|
||||
args, f"update user {args.email}", detail,
|
||||
lambda: c.update_user(args.email, is_active=is_active, password=args.password,
|
||||
name=args.name, quota_mb=quota_mb, role=args.role),
|
||||
reminder=reminder)
|
||||
|
||||
|
||||
def cmd_quota(c: SeafileClient, args) -> int:
|
||||
quota_mb = _gb_to_mb(args.gb)
|
||||
detail = {"email": args.email, "quota": f"{quota_mb} MB (= {args.gb} GiB)"}
|
||||
return _do_write(args, f"set quota for {args.email}", detail,
|
||||
lambda: c.update_user(args.email, quota_mb=quota_mb))
|
||||
|
||||
|
||||
def cmd_user_delete(c: SeafileClient, args) -> int:
|
||||
detail = {"email": args.email, "note": "IRREVERSIBLE - removes the account"}
|
||||
return _do_write(args, f"DELETE user {args.email}", detail,
|
||||
lambda: c.delete_user(args.email))
|
||||
|
||||
|
||||
def cmd_lib_create(c: SeafileClient, args) -> int:
|
||||
detail = {"name": args.name, "owner": args.owner or "<admin account>"}
|
||||
return _do_write(args, f"create library '{args.name}'", detail,
|
||||
lambda: c.create_library(args.name, owner=args.owner))
|
||||
|
||||
|
||||
def cmd_lib_transfer(c: SeafileClient, args) -> int:
|
||||
detail = {"repo_id": args.repo_id, "new_owner": args.to}
|
||||
return _do_write(args, f"transfer library {args.repo_id}", detail,
|
||||
lambda: c.transfer_library(args.repo_id, args.to))
|
||||
|
||||
|
||||
def cmd_lib_delete(c: SeafileClient, args) -> int:
|
||||
detail = {"repo_id": args.repo_id, "note": "IRREVERSIBLE - deletes the library + data"}
|
||||
return _do_write(args, f"DELETE library {args.repo_id}", detail,
|
||||
lambda: c.delete_library(args.repo_id))
|
||||
|
||||
|
||||
def cmd_share(c: SeafileClient, args) -> int:
|
||||
stype = "group" if args.group else "user"
|
||||
detail = {"repo_id": args.repo_id, "share_to": args.to,
|
||||
"permission": args.perm, "type": stype}
|
||||
return _do_write(args, f"share library {args.repo_id}", detail,
|
||||
lambda: c.share_library(args.repo_id, args.to,
|
||||
permission=args.perm, share_type=stype))
|
||||
|
||||
|
||||
def cmd_unshare(c: SeafileClient, args) -> int:
|
||||
stype = "group" if args.group else "user"
|
||||
detail = {"repo_id": args.repo_id, "share_to": args.to, "type": stype}
|
||||
return _do_write(args, f"revoke share on {args.repo_id}", detail,
|
||||
lambda: c.unshare_library(args.repo_id, args.to, share_type=stype))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(prog="seafile", description="Seafile Pro backup inventory")
|
||||
p = argparse.ArgumentParser(prog="seafile", description="Seafile Pro backup inventory + admin")
|
||||
p.add_argument("--url", help="override server base URL (default internal Docker endpoint)")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
@@ -196,6 +328,50 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
us = sub.add_parser("usage"); us.add_argument("--json", action="store_true")
|
||||
d = sub.add_parser("devices"); d.add_argument("--user"); d.add_argument("--json", action="store_true")
|
||||
a = sub.add_parser("audit"); a.add_argument("--client"); a.add_argument("--rmm", action="store_true"); a.add_argument("--json", action="store_true")
|
||||
|
||||
# --- write ops (all preview-by-default; require --confirm to apply) ---
|
||||
uc = sub.add_parser("user-create", help="create an account")
|
||||
uc.add_argument("email"); uc.add_argument("--password", required=True)
|
||||
uc.add_argument("--name"); uc.add_argument("--quota-gb", type=float, dest="quota_gb")
|
||||
uc.add_argument("--role"); uc.add_argument("--inactive", action="store_true")
|
||||
uc.add_argument("--confirm", action="store_true")
|
||||
|
||||
ust = sub.add_parser("user-set", help="update an account (activate/quota/name/role/password)")
|
||||
ust.add_argument("email")
|
||||
ust.add_argument("--active", action="store_true"); ust.add_argument("--inactive", action="store_true")
|
||||
ust.add_argument("--quota-gb", type=float, dest="quota_gb"); ust.add_argument("--name")
|
||||
ust.add_argument("--role"); ust.add_argument("--password")
|
||||
ust.add_argument("--confirm", action="store_true")
|
||||
|
||||
q = sub.add_parser("quota", help="set a user's storage quota (GiB)")
|
||||
q.add_argument("email"); q.add_argument("--gb", type=float, required=True)
|
||||
q.add_argument("--confirm", action="store_true")
|
||||
|
||||
ud = sub.add_parser("user-delete", help="delete an account (irreversible)")
|
||||
ud.add_argument("email"); ud.add_argument("--confirm", action="store_true")
|
||||
|
||||
lc = sub.add_parser("lib-create", help="create a library")
|
||||
lc.add_argument("name"); lc.add_argument("--owner", help="owner email (default: admin)")
|
||||
lc.add_argument("--confirm", action="store_true")
|
||||
|
||||
lt = sub.add_parser("lib-transfer", help="transfer library ownership")
|
||||
lt.add_argument("repo_id"); lt.add_argument("--to", required=True, help="new owner email")
|
||||
lt.add_argument("--confirm", action="store_true")
|
||||
|
||||
ld = sub.add_parser("lib-delete", help="delete a library (irreversible)")
|
||||
ld.add_argument("repo_id"); ld.add_argument("--confirm", action="store_true")
|
||||
|
||||
sh = sub.add_parser("share", help="share a library to user(s)/group(s)")
|
||||
sh.add_argument("repo_id"); sh.add_argument("--to", nargs="+", required=True,
|
||||
help="recipient email(s), or group id(s) with --group")
|
||||
sh.add_argument("--perm", choices=["r", "rw"], default="rw")
|
||||
sh.add_argument("--group", action="store_true", help="share_to are group ids")
|
||||
sh.add_argument("--confirm", action="store_true")
|
||||
|
||||
un = sub.add_parser("unshare", help="revoke a library share")
|
||||
un.add_argument("repo_id"); un.add_argument("--to", required=True,
|
||||
help="recipient email, or group id with --group")
|
||||
un.add_argument("--group", action="store_true"); un.add_argument("--confirm", action="store_true")
|
||||
return p
|
||||
|
||||
|
||||
@@ -203,7 +379,11 @@ def main(argv=None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
c = SeafileClient(args.url)
|
||||
handlers = {"status": cmd_status, "users": cmd_users, "libraries": cmd_libraries,
|
||||
"usage": cmd_usage, "devices": cmd_devices, "audit": cmd_audit}
|
||||
"usage": cmd_usage, "devices": cmd_devices, "audit": cmd_audit,
|
||||
"user-create": cmd_user_create, "user-set": cmd_user_set,
|
||||
"quota": cmd_quota, "user-delete": cmd_user_delete,
|
||||
"lib-create": cmd_lib_create, "lib-transfer": cmd_lib_transfer,
|
||||
"lib-delete": cmd_lib_delete, "share": cmd_share, "unshare": cmd_unshare}
|
||||
try:
|
||||
return handlers[args.cmd](c, args)
|
||||
except bc.BackupError as exc:
|
||||
|
||||
@@ -174,6 +174,111 @@ class SeafileClient:
|
||||
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
|
||||
application/x-www-form-urlencoded. `form` values that are lists are
|
||||
expanded (doseq) into repeated fields.
|
||||
"""
|
||||
url = f"{self.base}{path}"
|
||||
data = urllib.parse.urlencode(form or {}, doseq=True).encode()
|
||||
req = bc.urllib.request.Request(
|
||||
url, data=data, method=method,
|
||||
headers={"Authorization": f"Token {self._tok()}",
|
||||
"Content-Type": "application/x-www-form-urlencoded"})
|
||||
try:
|
||||
with bc.urllib.request.urlopen(req, timeout=bc.HTTP_TIMEOUT) as resp:
|
||||
status = resp.getcode()
|
||||
raw = resp.read().decode("utf-8", errors="replace")
|
||||
except bc.urllib.error.HTTPError as exc:
|
||||
status = exc.code
|
||||
raw = exc.read().decode("utf-8", errors="replace")
|
||||
except bc.urllib.error.URLError as exc:
|
||||
raise bc.BackupError(f"Seafile {method} {path} failed: {exc}")
|
||||
if status in (401, 403) and retry_auth:
|
||||
self._token = self._authorize()
|
||||
return self._write(method, path, form, retry_auth=False)
|
||||
try:
|
||||
parsed = json.loads(raw) if raw.strip() else {}
|
||||
except json.JSONDecodeError:
|
||||
parsed = {"_raw": raw[:600]}
|
||||
if status not in (200, 201):
|
||||
msg = parsed.get("error_msg") or parsed.get("detail") or raw[:400]
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user