220 lines
7.4 KiB
Python
220 lines
7.4 KiB
Python
import json
|
|
import secrets
|
|
import string
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import quote, urlencode
|
|
from urllib.request import Request as UrlRequest
|
|
from urllib.request import urlopen
|
|
|
|
from app.config import get_gitea_admin_token, get_gitea_server_url
|
|
from app.db.users import update_gitea_sync_state
|
|
|
|
GITEA_ACCOUNT_ROLES = {"admin", "developer"}
|
|
|
|
|
|
def sync_gitea_user(user: dict) -> dict:
|
|
user_id = int(user["id"])
|
|
desired_active = _requires_gitea_account(user)
|
|
username = _gitea_username(user_id)
|
|
|
|
update_gitea_sync_state(user_id, "syncing")
|
|
try:
|
|
if desired_active:
|
|
gitea_user = _ensure_gitea_user(user, username)
|
|
gitea_username = _gitea_login(gitea_user) or username
|
|
_update_gitea_user(gitea_username, user, active=True, gitea_user=gitea_user)
|
|
update_gitea_sync_state(
|
|
user_id,
|
|
"synced",
|
|
gitea_user_id=int(gitea_user["id"]),
|
|
synced=True,
|
|
)
|
|
return {"status": "synced", "action": "activated", "gitea_user_id": gitea_user["id"]}
|
|
|
|
if not user.get("gitea_user_id"):
|
|
update_gitea_sync_state(user_id, "not_required", synced=True)
|
|
return {"status": "not_required", "action": "none"}
|
|
|
|
gitea_user = _find_linked_gitea_user(user, username)
|
|
if gitea_user:
|
|
gitea_username = _gitea_login(gitea_user) or username
|
|
_update_gitea_user(gitea_username, user, active=False, gitea_user=gitea_user)
|
|
update_gitea_sync_state(
|
|
user_id,
|
|
"not_required",
|
|
gitea_user_id=int(gitea_user["id"]),
|
|
synced=True,
|
|
)
|
|
return {"status": "not_required", "action": "deactivated", "gitea_user_id": gitea_user["id"]}
|
|
|
|
update_gitea_sync_state(user_id, "not_required", synced=True)
|
|
return {"status": "not_required", "action": "none"}
|
|
except Exception as exc:
|
|
error = str(exc)[:500]
|
|
update_gitea_sync_state(user_id, "error", error=error)
|
|
return {"status": "error", "error": error}
|
|
|
|
|
|
def _requires_gitea_account(user: dict) -> bool:
|
|
role = (user.get("role") or "").lower()
|
|
return role in GITEA_ACCOUNT_ROLES and bool(user.get("is_enabled", 1)) and bool(user.get("is_active", 1))
|
|
|
|
|
|
def _gitea_username(user_id: int) -> str:
|
|
return f"portal-{user_id}"
|
|
|
|
|
|
def _ensure_gitea_user(user: dict, username: str) -> dict:
|
|
existing = _get_gitea_user(username, missing_ok=True)
|
|
if existing:
|
|
return existing
|
|
|
|
existing = _find_gitea_user_by_email(_email(user))
|
|
if existing:
|
|
return existing
|
|
|
|
return _create_gitea_user(user, username)
|
|
|
|
|
|
def _create_gitea_user(user: dict, username: str) -> dict:
|
|
payload = {
|
|
"username": username,
|
|
"login_name": username,
|
|
"source_id": 0,
|
|
"email": _email(user),
|
|
"full_name": _display_name(user),
|
|
"password": _random_password(),
|
|
"must_change_password": False,
|
|
"send_notify": False,
|
|
"visibility": "private",
|
|
}
|
|
return _request_json("POST", "/api/v1/admin/users", payload, expected=(201,))
|
|
|
|
|
|
def _update_gitea_user(username: str, user: dict, active: bool, gitea_user: dict | None = None) -> dict:
|
|
payload = {
|
|
"email": _email(user),
|
|
"full_name": _display_name(user),
|
|
"login_name": _gitea_login_name(gitea_user) or username,
|
|
"source_id": _gitea_source_id(gitea_user),
|
|
"active": active,
|
|
"admin": bool((gitea_user or {}).get("is_admin", False)),
|
|
"visibility": "private",
|
|
}
|
|
return _request_json("PATCH", f"/api/v1/admin/users/{quote(username)}", payload, expected=(200,))
|
|
|
|
|
|
def _get_gitea_user(username: str, missing_ok: bool = False) -> dict | None:
|
|
try:
|
|
return _request_json("GET", f"/api/v1/users/{quote(username)}", expected=(200,))
|
|
except RuntimeError as exc:
|
|
if missing_ok and "HTTP 404" in str(exc):
|
|
return None
|
|
raise
|
|
|
|
|
|
def _find_linked_gitea_user(user: dict, username: str) -> dict | None:
|
|
existing = _get_gitea_user(username, missing_ok=True)
|
|
if existing:
|
|
return existing
|
|
|
|
gitea_user_id = user.get("gitea_user_id")
|
|
if not gitea_user_id:
|
|
return None
|
|
|
|
existing = _find_gitea_user_by_email(_email(user))
|
|
if existing and int(existing.get("id") or 0) == int(gitea_user_id):
|
|
return existing
|
|
|
|
return None
|
|
|
|
|
|
def _find_gitea_user_by_email(email: str) -> dict | None:
|
|
email = (email or "").strip().lower()
|
|
if not email:
|
|
return None
|
|
|
|
response = _request_json("GET", f"/api/v1/admin/users?{urlencode({'q': email})}", expected=(200,))
|
|
users = response if isinstance(response, list) else response.get("data", [])
|
|
for gitea_user in users:
|
|
if (gitea_user.get("email") or "").strip().lower() == email:
|
|
return gitea_user
|
|
|
|
return None
|
|
|
|
|
|
def _gitea_login(gitea_user: dict | None) -> str:
|
|
if not gitea_user:
|
|
return ""
|
|
return (gitea_user.get("login") or gitea_user.get("username") or "").strip()
|
|
|
|
|
|
def _gitea_login_name(gitea_user: dict | None) -> str:
|
|
if not gitea_user:
|
|
return ""
|
|
return (gitea_user.get("login_name") or _gitea_login(gitea_user)).strip()
|
|
|
|
|
|
def _gitea_source_id(gitea_user: dict | None) -> int:
|
|
if not gitea_user:
|
|
return 0
|
|
try:
|
|
return int(gitea_user.get("source_id") or 0)
|
|
except (TypeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def _request_json(method: str, path: str, payload: dict | None = None, expected: tuple[int, ...] = (200,)) -> dict:
|
|
base_url = get_gitea_server_url().rstrip("/")
|
|
token = get_gitea_admin_token()
|
|
if not base_url:
|
|
raise RuntimeError("Gitea server URL is not configured")
|
|
if not token:
|
|
raise RuntimeError("Gitea admin token is not configured")
|
|
|
|
data = json.dumps(payload).encode("utf-8") if payload is not None else None
|
|
request = UrlRequest(
|
|
f"{base_url}{path}",
|
|
data=data,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"Authorization": f"token {token}",
|
|
"Content-Type": "application/json",
|
|
},
|
|
method=method,
|
|
)
|
|
try:
|
|
with urlopen(request, timeout=10) as response:
|
|
body = response.read().decode("utf-8")
|
|
if response.status not in expected:
|
|
raise RuntimeError(f"Gitea API returned HTTP {response.status}")
|
|
return json.loads(body) if body else {}
|
|
except HTTPError as exc:
|
|
detail = exc.read().decode("utf-8", errors="replace")
|
|
raise RuntimeError(f"Gitea API returned HTTP {exc.code}: {detail}") from exc
|
|
except (URLError, TimeoutError, json.JSONDecodeError) as exc:
|
|
raise RuntimeError(f"Gitea API request failed for {base_url}{path}: {exc}") from exc
|
|
|
|
|
|
def _email(user: dict) -> str:
|
|
email = (user.get("email") or "").strip().lower()
|
|
if email:
|
|
return email
|
|
return f"portal-{int(user['id'])}@localhost"
|
|
|
|
|
|
def _display_name(user: dict) -> str:
|
|
return (user.get("display_name") or user.get("username") or _email(user)).strip()
|
|
|
|
|
|
def _random_password() -> str:
|
|
alphabet = string.ascii_letters + string.digits
|
|
required = [
|
|
secrets.choice(string.ascii_lowercase),
|
|
secrets.choice(string.ascii_uppercase),
|
|
secrets.choice(string.digits),
|
|
]
|
|
required.extend(secrets.choice(alphabet) for _ in range(29))
|
|
secrets.SystemRandom().shuffle(required)
|
|
return "".join(required)
|