""" MeshBay Hub — admin/moderation panel endpoints. All endpoints require moderator or admin role. Separate from moderation.py (which handles public reporting and content blocklist). """ import logging from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.auth import decrypt_email from meshbay_hub.api.deps import require_admin, require_moderator from meshbay_hub.api.revocation import get_connected_node_count, is_node_connected from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User from meshbay_hub import hub_settings log = logging.getLogger(__name__) router = APIRouter(prefix="/v1/admin", tags=["admin"]) # ── Request models ─────────────────────────────────────────────────────────── class UserPatchRequest(BaseModel): role: str | None = None status: str | None = None class GroupPatchRequest(BaseModel): status: str | None = None class SettingsPatchRequest(BaseModel): allow_public_groups: bool | None = None # ── Instance settings ──────────────────────────────────────────────────────── def _settings_payload(allow_public_groups: bool) -> dict: return {"allow_public_groups": allow_public_groups} @router.get("/settings") async def admin_get_settings( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): """Instance-wide policy an admin controls from the panel. Moderators may read.""" return _settings_payload(await hub_settings.public_groups_allowed(db)) @router.patch("/settings") async def admin_patch_settings( body: SettingsPatchRequest, current_user: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): """ Change instance policy. Admin only — moderators get the read above. The enforcement lives where the thing being restricted happens (public-group creation is refused in `groups.create_group`), so flipping this here is the whole change: a client that keeps drawing the option still cannot use it. """ if body.allow_public_groups is not None: await hub_settings.set_raw( db, hub_settings.ALLOW_PUBLIC_GROUPS, "true" if body.allow_public_groups else "false") log.info("Instance setting allow_public_groups=%s by %s", body.allow_public_groups, current_user.username) db.add(IPLog( user_id=current_user.id, event="admin_settings_update", ip_address="admin", detail=f"allow_public_groups={body.allow_public_groups}", )) await db.commit() return _settings_payload(await hub_settings.public_groups_allowed(db)) # ── Stats ──────────────────────────────────────────────────────────────────── @router.get("/stats") async def admin_stats( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): # Deleted accounts are tombstoned rather than dropped, so that the # connection log stays readable. They are not users any more and must not be # counted as any: a hub whose user count only ever rises is measuring its # own history, not its population. user_count = (await db.execute( select(func.count()).select_from(User) .where(User.status != "deleted"))).scalar_one() # Groups are not tombstoned — deleting one removes the row — so every group # here is a group. A revoked one is suspended by moderation and still shown # in the list, so counting it keeps the two consistent. group_count = (await db.execute( select(func.count()).select_from(Group))).scalar_one() node_count = (await db.execute(select(func.count()).select_from(Node))).scalar_one() return { "users": user_count, "groups": group_count, "nodes": node_count, "online_nodes": get_connected_node_count(), } # ── Users ──────────────────────────────────────────────────────────────────── @router.get("/users") async def admin_list_users( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), q: str = "", offset: int = 0, limit: int = Query(default=50, le=200), ): query = (select(User).where(User.status != "deleted") .order_by(User.created_at.desc())) if q: query = query.where(User.username.ilike(f"%{q}%")) query = query.offset(offset).limit(limit) result = await db.execute(query) users = result.scalars().all() total_query = (select(func.count()).select_from(User) .where(User.status != "deleted")) if q: total_query = total_query.where(User.username.ilike(f"%{q}%")) total = (await db.execute(total_query)).scalar_one() return { "users": [ { "id": u.id, "username": u.username, "role": u.role, "status": u.status, "created_at": u.created_at.isoformat(), } for u in users ], "total": total, } @router.get("/users/{user_id}") async def admin_get_user( user_id: str, current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): user = await db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") group_count = (await db.execute( select(func.count()).where(GroupMember.user_id == user_id) )).scalar_one() try: email = decrypt_email(user.email) except Exception: email = "(encrypted)" return { "id": user.id, "username": user.username, "email": email, "role": user.role, "status": user.status, "created_at": user.created_at.isoformat(), "group_count": group_count, } @router.patch("/users/{user_id}") async def admin_patch_user( user_id: str, body: UserPatchRequest, current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): user = await db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") if user.id == current_user.id: raise HTTPException(status_code=400, detail="Cannot modify your own account") from meshbay_hub.api.notifications import create_notification if body.role is not None: if body.role not in ("user", "moderator", "admin"): raise HTTPException(status_code=422, detail="role must be user, moderator, or admin") user.role = body.role log.info("User %s role changed to %s by %s", user.username, body.role, current_user.username) await create_notification( db, user.id, "role_change", f"Your role has been changed to {body.role}", ) if body.status is not None: if body.status not in ("active", "suspended", "revoked"): raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked") user.status = body.status log.info("User %s status changed to %s by %s", user.username, body.status, current_user.username) if body.status == "suspended": await create_notification( db, user.id, "account_suspended", "Your account has been suspended", ) elif body.status == "active": await create_notification( db, user.id, "account_restored", "Your account has been restored", ) db.add(IPLog( user_id=current_user.id, event="admin_user_update", ip_address="admin", detail=f"{user.username}: role={user.role} status={user.status}", )) await db.commit() return { "id": user.id, "username": user.username, "role": user.role, "status": user.status, } # ── Groups ─────────────────────────────────────────────────────────────────── @router.delete("/users/{user_id}") async def admin_delete_user( user_id: str, current_user: User = Depends(require_admin), db: AsyncSession = Depends(get_db), ): """ Erase an account. Same erasure a user performs on themselves. Admin rather than moderator: suspension is reversible and is the moderation tool; this is not. Refused for one's own account — an administrator locking themselves out is a support incident, and there is `DELETE /v1/users/me` for someone who means it. """ from meshbay_hub.api.users import erase_account user = await db.get(User, user_id) if not user: raise HTTPException(status_code=404, detail="User not found") if user.id == current_user.id: raise HTTPException( status_code=400, detail="Use your own account settings to delete your account") if user.status == "deleted": raise HTTPException(status_code=410, detail="Account already deleted") result = await erase_account(db, user) log.info("Account %s erased by admin %s", result["username"], current_user.username) return result @router.get("/groups") async def admin_list_groups( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), offset: int = 0, limit: int = Query(default=50, le=200), ): query = ( select( Group, func.count(User.id).label("member_count"), ) # Members, not rows: a deleted account's membership is removed with it, # but joining through User keeps the count honest if one ever survives. .outerjoin(GroupMember, Group.id == GroupMember.group_id) .outerjoin(User, (User.id == GroupMember.user_id) & (User.status != "deleted")) .group_by(Group.id) .order_by(Group.created_at.desc()) .offset(offset) .limit(limit) ) result = await db.execute(query) rows = result.all() total = (await db.execute(select(func.count()).select_from(Group))).scalar_one() owner_ids = {g.admin_id for g, _ in rows} owner_by_id = dict((await db.execute( select(User.id, User.username).where(User.id.in_(owner_ids)))).all()) \ if owner_ids else {} return { "groups": [ { "id": g.id, "name": g.name, "admin_id": g.admin_id, "owner_username": owner_by_id.get(g.admin_id), "visibility": g.visibility, "description": g.description or "", "status": g.status, "created_at": g.created_at.isoformat(), "member_count": mc, } for g, mc in rows ], "total": total, } @router.patch("/groups/{group_id}") async def admin_patch_group( group_id: str, body: GroupPatchRequest, current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): group = await db.get(Group, group_id) if not group: raise HTTPException(status_code=404, detail="Group not found") if body.status is not None: if body.status not in ("active", "suspended", "revoked"): raise HTTPException(status_code=422, detail="status must be active, suspended, or revoked") group.status = body.status log.info("Group %s status changed to %s by %s", group.name, body.status, current_user.username) db.add(IPLog( user_id=current_user.id, event="admin_group_update", ip_address="admin", detail=f"{group.name}: status={group.status}", )) await db.commit() return { "id": group.id, "name": group.name, "status": group.status, } # ── IP Audit Logs ──────────────────────────────────────────────────────────── @router.get("/nodes") async def admin_list_nodes( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), limit: int = Query(default=100, le=200), ): """ Registered nodes, with the address the hub saw them announce from. `observed_ip` is the one to answer a question with: it comes from the connection that carried a valid Ed25519 signature over a fresh timestamp, so it is the address of whoever holds the node key. `endpoint_hint` is what the node believes its own address to be, discovered through a STUN server and sent to us — useful for reaching it, and not evidence of anything. """ rows = (await db.execute( select(Node, User.username) .outerjoin(User, User.id == Node.user_id) .order_by(Node.announced_at.desc()) .limit(limit))).all() return { "nodes": [ { "id": n.id, "user_id": n.user_id, "username": uname or "", "pk_node": n.pk_node, "observed_ip": n.observed_ip or "", "endpoint_hint": n.endpoint_hint or "", "last_seen": n.last_seen.isoformat() if n.last_seen else "", "announced_at": n.announced_at.isoformat(), "online": is_node_connected(n.id), } for n, uname in rows ], } @router.get("/logs") async def admin_list_logs( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), user_id: str | None = None, event: str | None = None, offset: int = 0, limit: int = Query(default=50, le=200), ): query = ( select(IPLog, User.username) .outerjoin(User, IPLog.user_id == User.id) .order_by(IPLog.timestamp.desc()) ) if user_id: query = query.where(IPLog.user_id == user_id) if event: query = query.where(IPLog.event == event) query = query.offset(offset).limit(limit) result = await db.execute(query) rows = result.all() return { "logs": [ { "id": lg.id, "user_id": lg.user_id, # The kept name wins: it is only ever written when an account is # deleted, and the join still answers then — with the tombstone, # `deleted-3f9a1c`, which is the one answer that helps nobody. "username": lg.username or uname or "", "event": lg.event, "ip_address": lg.ip_address, "detail": lg.detail, "timestamp": lg.timestamp.isoformat(), } for lg, uname in rows ], }