summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/admin.py273
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/deps.py13
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py12
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py10
4 files changed, 307 insertions, 1 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/admin.py b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
new file mode 100644
index 0000000..c3fa8c2
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/admin.py
@@ -0,0 +1,273 @@
+"""
+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_moderator
+from meshbay_hub.api.revocation import get_connected_node_count
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import Group, GroupMember, IPLog, Node, User
+
+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
+
+
+# ── Stats ────────────────────────────────────────────────────────────────────
+
+@router.get("/stats")
+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()
+ 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).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)
+ 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")
+
+ 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)
+
+ 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)
+
+ 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.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(GroupMember.user_id).label("member_count"),
+ )
+ .outerjoin(GroupMember, Group.id == GroupMember.group_id)
+ .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()
+
+ return {
+ "groups": [
+ {
+ "id": g.id,
+ "name": g.name,
+ "admin_id": g.admin_id,
+ "visibility": g.visibility,
+ "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("/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).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)
+ logs = result.scalars().all()
+
+ return {
+ "logs": [
+ {
+ "id": lg.id,
+ "user_id": lg.user_id,
+ "event": lg.event,
+ "ip_address": lg.ip_address,
+ "detail": lg.detail,
+ "timestamp": lg.timestamp.isoformat(),
+ }
+ for lg in logs
+ ],
+ }
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/deps.py b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
index 7a580ad..addba30 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/deps.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/deps.py
@@ -52,10 +52,21 @@ async def get_current_user(
return user
+async def require_moderator(
+ current_user: User = Depends(get_current_user),
+) -> User:
+ if current_user.role not in ("moderator", "admin") \
+ and current_user.username not in _admin_usernames:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
+ detail="Moderator access required")
+ return current_user
+
+
async def require_admin(
current_user: User = Depends(get_current_user),
) -> User:
- if current_user.username not in _admin_usernames:
+ if current_user.role != "admin" \
+ and current_user.username not in _admin_usernames:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Admin access required")
return current_user
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index f91b381..2c2eede 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -236,6 +236,18 @@ async def token_refresh(
}
+@router.get("/me")
+async def get_current_user_info(
+ current_user: User = Depends(get_current_user),
+):
+ return {
+ "user_id": current_user.id,
+ "username": current_user.username,
+ "role": current_user.role,
+ "status": current_user.status,
+ }
+
+
@router.get("/{username}/pubkeys")
async def get_user_pubkeys(
username: str,
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index 62917f4..5ad7329 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -21,6 +21,16 @@ STATIC_DIR = Path(__file__).parent.parent / "static"
router = APIRouter(tags=["webapp"])
+@router.get("/app", response_class=HTMLResponse)
+async def app_root():
+ return HTMLResponse(_HTML)
+
+
+@router.get("/app/{path:path}", response_class=HTMLResponse)
+async def app_catchall(path: str):
+ return HTMLResponse(_HTML)
+
+
@router.get("/", response_class=HTMLResponse)
async def index():
return HTMLResponse(_HTML)