"""Persistent Web UI users and stable account ownership helpers.""" from __future__ import annotations import json import os import re import tempfile import uuid from copy import deepcopy from pathlib import Path from utils.config import get_app_settings, get_userData, normalize_unique_id, save_userData from webui.auth import hash_password, verify_password USERS_FILE = Path(__file__).resolve().parents[1] / "webui_users.json" USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") class UserStoreError(ValueError): """Raised when a Web UI user operation is invalid.""" def normalize_username(username: str) -> str: return str(username or "").strip().casefold() def _atomic_write_json(path: Path, payload: object) -> None: path.parent.mkdir(parents=True, exist_ok=True) fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)) try: with os.fdopen(fd, "w", encoding="utf-8") as handle: json.dump(payload, handle, ensure_ascii=False, indent=2) handle.write("\n") handle.flush() os.fsync(handle.fileno()) os.replace(temp_name, path) finally: try: os.unlink(temp_name) except FileNotFoundError: pass def _load_raw() -> dict: if not USERS_FILE.exists(): return {"users": []} text = USERS_FILE.read_text(encoding="utf-8") if not text.strip(): return {"users": []} data = json.loads(text) if not isinstance(data, dict) or not isinstance(data.get("users", []), list): raise UserStoreError("webui_users.json must contain a users list") return data def get_web_users(force_reload: bool = False) -> list[dict]: del force_reload users = [] for raw in _load_raw().get("users", []): if not isinstance(raw, dict): continue username = str(raw.get("username", "")).strip() if not username: continue users.append( { "username": username, "role": "user", "password_hash": str(raw.get("password_hash", "")), "enabled": bool(raw.get("enabled", True)), "account_refs": list(dict.fromkeys( str(ref).strip() for ref in (raw.get("account_refs") or []) if str(ref).strip() )), } ) return users def save_web_users(users: list[dict]) -> list[dict]: normalized = [] seen = set() assigned = set() for raw in users: username = str(raw.get("username", "")).strip() key = normalize_username(username) if not username or key == normalize_username("admin") or key in seen: raise UserStoreError("invalid or duplicate Web user") if not USERNAME_RE.fullmatch(username): raise UserStoreError("Web username may contain only letters, digits, _, ., and -") password_hash = str(raw.get("password_hash", "")) if not password_hash: raise UserStoreError(f"password hash missing for {username}") refs = list(dict.fromkeys( str(ref).strip() for ref in (raw.get("account_refs") or []) if str(ref).strip() )) if assigned.intersection(refs): raise UserStoreError("one or more accounts are already assigned") assigned.update(refs) seen.add(key) normalized.append( { "username": username, "role": "user", "password_hash": password_hash, "enabled": bool(raw.get("enabled", True)), "account_refs": refs, } ) _atomic_write_json(USERS_FILE, {"users": normalized}) try: os.chmod(USERS_FILE, 0o600) except OSError: pass return deepcopy(normalized) def find_web_user(username: str) -> dict | None: key = normalize_username(username) return next((user for user in get_web_users() if normalize_username(user["username"]) == key), None) def authenticate(username: str, password: str) -> dict | None: settings = get_app_settings(force_reload=True) admin_username = str(settings.get("admin_username", "admin")).strip() or "admin" if normalize_username(username) == normalize_username(admin_username): if verify_password(password, settings.get("admin_password_hash", "")): return {"username": admin_username, "role": "admin", "account_refs": [], "enabled": True} return None user = find_web_user(username) if not user or not user.get("enabled") or not verify_password(password, user.get("password_hash", "")): return None return { "username": user["username"], "role": "user", "account_refs": list(user.get("account_refs", [])), "enabled": True, } def ensure_account_refs(accounts: list[dict] | None = None) -> tuple[list[dict], bool]: accounts = deepcopy(accounts if accounts is not None else get_userData(force_reload=True)) changed = False for account in accounts: if not str(account.get("account_ref", "")).strip(): account["account_ref"] = f"acc-{uuid.uuid4().hex}" changed = True if changed: save_userData(accounts) return accounts, changed def account_by_ref(accounts: list[dict], account_ref: str) -> dict | None: target = str(account_ref or "").strip() return next((account for account in accounts if str(account.get("account_ref", "")).strip() == target), None) def account_by_unique_id(accounts: list[dict], unique_id: str) -> dict | None: target = normalize_unique_id(unique_id) return next((account for account in accounts if normalize_unique_id(account.get("unique_id")) == target), None) def get_visible_accounts(principal: dict | None, accounts: list[dict] | None = None) -> list[dict]: accounts, _ = ensure_account_refs(accounts) if principal and principal.get("role") == "admin": return accounts allowed = set(principal.get("account_refs", [])) if principal else set() return [account for account in accounts if account.get("account_ref") in allowed] def can_access_account(principal: dict | None, account: dict | None) -> bool: if not principal or not account: return False return principal.get("role") == "admin" or account.get("account_ref") in set(principal.get("account_refs", [])) def all_assigned_refs(exclude_username: str | None = None) -> set[str]: excluded = normalize_username(exclude_username) if exclude_username else None refs = set() for user in get_web_users(): if excluded and normalize_username(user["username"]) == excluded: continue refs.update(user.get("account_refs", [])) return refs def _validate_refs(refs: list[str] | None, accounts: list[dict] | None = None) -> list[str]: accounts, _ = ensure_account_refs(accounts) valid = {str(account.get("account_ref")) for account in accounts} result = list(dict.fromkeys(str(ref).strip() for ref in (refs or []) if str(ref).strip())) unknown = [ref for ref in result if ref not in valid] if unknown: raise UserStoreError("one or more account assignments are invalid") return result def create_web_user(username: str, password: str, *, enabled: bool = True, account_refs: list[str] | None = None) -> dict: username = str(username or "").strip() if not USERNAME_RE.fullmatch(username) or normalize_username(username) == normalize_username("admin"): raise UserStoreError("invalid Web username") if not password: raise UserStoreError("password is required") if find_web_user(username): raise UserStoreError("Web username already exists") refs = _validate_refs(account_refs) if all_assigned_refs().intersection(refs): raise UserStoreError("one or more accounts are already assigned") item = { "username": username, "role": "user", "password_hash": hash_password(password), "enabled": bool(enabled), "account_refs": refs, } save_web_users(get_web_users() + [item]) return deepcopy(item) def update_web_user( username: str, *, new_username: str | None = None, password: str | None = None, enabled: bool | None = None, account_refs: list[str] | None = None, ) -> dict: users = get_web_users() target = next((user for user in users if normalize_username(user["username"]) == normalize_username(username)), None) if target is None: raise UserStoreError("Web user not found") original_username = target["username"] if new_username is not None: new_username = str(new_username).strip() if not USERNAME_RE.fullmatch(new_username) or normalize_username(new_username) == normalize_username("admin"): raise UserStoreError("invalid Web username") if normalize_username(new_username) != normalize_username(target["username"]) and find_web_user(new_username): raise UserStoreError("Web username already exists") target["username"] = new_username if password: target["password_hash"] = hash_password(password) if enabled is not None: target["enabled"] = bool(enabled) if account_refs is not None: refs = _validate_refs(account_refs) if all_assigned_refs(original_username).intersection(refs): raise UserStoreError("one or more accounts are already assigned") target["account_refs"] = refs save_web_users(users) return deepcopy(target) def remove_account_refs_from_users(account_refs: list[str] | set[str]) -> int: refs = {str(ref).strip() for ref in account_refs if str(ref).strip()} if not refs: return 0 users = get_web_users() changed = 0 for user in users: original = list(user.get("account_refs", [])) filtered = [ref for ref in original if ref not in refs] if filtered != original: user["account_refs"] = filtered changed += 1 if changed: save_web_users(users) return changed def delete_web_user(username: str) -> bool: key = normalize_username(username) users = get_web_users() remaining = [user for user in users if normalize_username(user["username"]) != key] if len(remaining) == len(users): return False save_web_users(remaining) return True