diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/groups.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/groups.py | 130 |
1 files changed, 126 insertions, 4 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 8e3197c..8283276 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub.api.deps import get_current_user, require_user_scope @@ -26,10 +26,16 @@ async def my_groups( db: AsyncSession = Depends(get_db), ): """List groups the current user belongs to.""" + from meshbay_hub.api.revocation import get_online_nodes_for_group + result = await db.execute( select(Group) .join(GroupMember, Group.id == GroupMember.group_id) - .where(GroupMember.user_id == current_user.id, Group.status == "active") + .where(GroupMember.user_id == current_user.id, Group.status == "active", + # A group nobody hosts yet is the owner's business alone. Someone + # added to it before a node exists would see a name they cannot + # open and cannot be told why. + or_(Group.hosted_at.is_not(None), Group.admin_id == current_user.id)) .order_by(Group.name) ) groups = result.scalars().all() @@ -48,6 +54,15 @@ async def my_groups( "created_at": g.created_at.isoformat(), "is_admin": g.admin_id == current_user.id, "description": g.description or "", + # Presence, from the socket registry the hub already keeps for + # signaling — so the sidebar gets it on the request it already + # makes, with no poll and no timer. It says a node serving this + # group is connected *to the hub*; it does not promise this + # browser can reach it, and a hub is free to lie about it. The + # client downgrades to offline on its own failed connection, + # which is the evidence that actually concerns the user. + "node_online": bool(get_online_nodes_for_group(g.id)), + "hosted": g.hosted_at is not None, } for g in groups ] @@ -88,7 +103,12 @@ async def list_public_groups( include_federated: bool = True, ): """List/search public groups — local and optionally federated. No auth required.""" - query = select(Group).where(Group.visibility == "public", Group.status == "active") + # Unhosted groups are absent from the directory: until a node announces it, + # a group has no files, no key and nothing to connect to, so listing it only + # produces a dead end. Its owner still sees it in /mine while they set it up. + query = select(Group).where(Group.visibility == "public", + Group.status == "active", + Group.hosted_at.is_not(None)) if q: query = query.where(Group.name.ilike(f"%{q}%")) result = await db.execute( @@ -246,10 +266,50 @@ async def join_group( class GroupCreateRequest(BaseModel): name: str visibility: str = "private" # public|private - join_policy: str = "invite" # open|request|invite + join_policy: str = "invite" # open (public groups) | invite (private) description: str | None = None +MAX_PUBLIC_GROUPS = 10 + + +async def _check_public_group_quota(db: AsyncSession, user: User) -> None: + """Refuse an eleventh live public group from the same owner. + + Public groups are the ones that cost other people something: they appear in + Discover and anyone may join them, so a script that opens hundreds fills the + directory for everybody. Private groups are invisible to anyone not invited + and are not capped. + + Counted: public, still active, owned by this user. A group suspended by + moderation does not hold a slot — the owner is already being dealt with, and + keeping the slot occupied would punish them twice. Deleting one frees a slot, + since the row is gone. + + Creation is the only place this can be checked, and deliberately so: PATCH + refuses to change visibility at all, so a private group cannot be flipped + public behind the cap. **If visibility ever becomes editable, this check has + to move with it.** + """ + if user.role in ("admin", "moderator"): + return # the cap is an anti-spam measure, not a rule about operating an instance + + count = (await db.execute( + select(func.count()) + .select_from(Group) + .where(Group.admin_id == user.id, + Group.visibility == "public", + Group.status == "active") + )).scalar_one() + + if count >= MAX_PUBLIC_GROUPS: + raise HTTPException( + status_code=409, + detail=f"You already run {count} public groups, which is the limit of " + f"{MAX_PUBLIC_GROUPS}. Delete one you no longer use, or create " + f"this one as private — private groups are not limited.") + + @router.post("", status_code=201) async def create_group( body: GroupCreateRequest, @@ -257,6 +317,19 @@ async def create_group( current_user: User = Depends(require_user_scope), db: AsyncSession = Depends(get_db), ): + if body.visibility == "public": + # A public group that admits nobody is a contradiction: it is listed in + # the directory, so people find it and then discover they cannot get in. + # Admission by request was considered and dropped — between strangers the + # only channel is the hub, so the one-time code would travel through the + # very party it exists to keep out, and would protect nothing. + if body.join_policy != "open": + raise HTTPException( + status_code=422, + detail="A public group is open to join. Make it private if you " + "want to choose who comes in.") + await _check_public_group_quota(db, current_user) + desc = (body.description or "")[:512] if body.description else None group = Group( name=body.name, @@ -324,6 +397,55 @@ async def remove_group_member( return {"status": "removed", "group_id": group_id, "username": username} +@router.post("/{group_id}/leave") +async def leave_group( + group_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """ + Leave a group you are a member of. + + Deliberately separate from `DELETE /{group_id}/members/{username}`, which is + the owner removing somebody else and is refused to everyone else. Reusing it + would have meant relaxing that check for the self case, and an authorization + rule with an exception in it is the kind that gets read wrong later. + + The owner cannot leave: the group would be left with no one able to admit a + member, edit it or delete it. That is the same answer the removal endpoint + already gives, and the same one account deletion gives while you still own + groups — hand the group over (not yet possible) or delete it. + + This is only the hub's half, exactly as for removal: membership is gone, so + signaling will not reach a node and the next token will not name this group. + The node keeps what it holds — the identity it pinned, the keypair bundle, + and the files uploaded — until its operator unpins them, and whoever left + still holds the group key they were served, so the operator should rotate it + (`meshbay-node gek-init`) if that matters. + """ + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + + if group.admin_id == current_user.id: + raise HTTPException( + status_code=409, + detail="You own this group, so you cannot leave it — it would be left " + "with nobody able to manage it. Delete the group instead.") + + membership = await db.get(GroupMember, (group_id, current_user.id)) + if not membership: + raise HTTPException(status_code=404, detail="You are not a member of this group") + + await db.delete(membership) + db.add(IPLog(user_id=current_user.id, event="group_leave", + ip_address=client_ip(request), + detail=f"left {group.name}")) + await db.commit() + return {"status": "left", "group_id": group_id} + + class GroupUpdateRequest(BaseModel): description: str | None = None |