diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/admin.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/admin.py | 66 |
1 files changed, 60 insertions, 6 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py index 785731d..7ee05ca 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/admin.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py @@ -15,7 +15,7 @@ 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 +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 @@ -42,8 +42,18 @@ async def admin_stats( current_user: User = Depends(require_moderator), db: AsyncSession = Depends(get_db), ): - user_count = (await db.execute(select(func.count()).select_from(User))).scalar_one() - group_count = (await db.execute(select(func.count()).select_from(Group))).scalar_one() + # 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, @@ -63,14 +73,16 @@ async def admin_list_users( offset: int = 0, limit: int = Query(default=50, le=200), ): - query = select(User).order_by(User.created_at.desc()) + 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) + 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() @@ -222,9 +234,13 @@ async def admin_list_groups( query = ( select( Group, - func.count(GroupMember.user_id).label("member_count"), + 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) @@ -288,6 +304,44 @@ async def admin_patch_group( # ── 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), |