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 | 206 |
1 files changed, 191 insertions, 15 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index df2f336..10049b2 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -18,8 +18,11 @@ from meshbay_hub.db.engine import get_db from meshbay_hub.db.models import ( FederatedGroup, Group, + GroupHost, + GroupInvitation, GroupMember, IPLog, + Node, User, ) @@ -80,6 +83,71 @@ async def my_groups( } +@router.get("/invitations") +async def my_invitations( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Groups somebody added this account to, waiting for it to say yes. + + Nothing here is dialled or searched: until the invitation is accepted the + group is not in `/mine`, is not named in any MNP token, and signaling to + its nodes is refused like any non-member's. + """ + rows = (await db.execute( + select(GroupInvitation, Group, User.username) + .join(Group, Group.id == GroupInvitation.group_id) + .outerjoin(User, User.id == GroupInvitation.invited_by) + .where(GroupInvitation.user_id == current_user.id, Group.status == "active") + .order_by(GroupInvitation.created_at.desc()))).all() + owners = dict((await db.execute( + select(User.id, User.username).where( + User.id.in_({g.admin_id for _, g, _ in rows})))).all()) if rows else {} + return {"invitations": [ + {"group_id": g.id, "name": g.name, + "owner_username": owners.get(g.admin_id, ""), + "invited_by": inviter or "", + "created_at": inv.created_at.isoformat() if inv.created_at else None} + for inv, g, inviter in rows + ]} + + +@router.post("/{group_id}/invitation/accept") +async def accept_invitation( + group_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + group = await db.get(Group, group_id) + if inv is None or group is None or group.status != "active": + raise HTTPException(status_code=404, detail="No such invitation") + await db.delete(inv) + if not await db.get(GroupMember, (group_id, current_user.id)): + db.add(GroupMember(group_id=group_id, user_id=current_user.id)) + db.add(IPLog(user_id=current_user.id, event="group_join", + ip_address=client_ip(request), detail=group.name)) + await db.commit() + owner = await db.scalar(select(User.username).where(User.id == group.admin_id)) + return {"status": "joined", "group_id": group_id, "name": group.name, + "owner_username": owner} + + +@router.post("/{group_id}/invitation/decline") +async def decline_invitation( + group_id: str, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + if inv is None: + raise HTTPException(status_code=404, detail="No such invitation") + await db.delete(inv) + await db.commit() + return {"status": "declined", "group_id": group_id} + + @router.post("/{group_id}/activity") async def touch_group_activity( group_id: str, @@ -226,11 +294,21 @@ async def group_members( .where(GroupMember.group_id == group_id, User.status != "deleted") ) members = [{"user_id": uid, "username": uname} for uid, uname in result.all()] - return { + out = { "group_id": group_id, "admin_id": group.admin_id, "members": members, } + if group.admin_id == current_user.id: + # Who has been asked and not answered, for the owner only: another + # member learns nothing about people who have not joined. + invited = await db.execute( + select(User.id, User.username) + .join(GroupInvitation, User.id == GroupInvitation.user_id) + .where(GroupInvitation.group_id == group_id, User.status != "deleted")) + out["invited"] = [{"user_id": uid, "username": uname} + for uid, uname in invited.all()] + return out @router.post("/{group_id}/join") @@ -258,6 +336,9 @@ async def join_group( raise HTTPException(status_code=409, detail="Already a member") db.add(GroupMember(group_id=group_id, user_id=current_user.id)) + inv = await db.get(GroupInvitation, (group_id, current_user.id)) + if inv is not None: + await db.delete(inv) db.add(IPLog(user_id=current_user.id, event="group_join", ip_address=client_ip(request), detail=group.name)) await db.commit() @@ -437,10 +518,15 @@ async def remove_group_member( "group over or delete it.") membership = await db.get(GroupMember, (group_id, target.id)) - if not membership: + invitation = await db.get(GroupInvitation, (group_id, target.id)) + if not membership and not invitation: raise HTTPException(status_code=404, detail="Not a member of this group") - await db.delete(membership) + # An unanswered invitation is taken back the same way, by the same button. + if invitation is not None: + await db.delete(invitation) + if membership is not None: + await db.delete(membership) db.add(IPLog(user_id=current_user.id, event="group_leave", ip_address=client_ip(request), detail=f"{username} removed from {group.name}")) @@ -554,23 +640,23 @@ async def add_group_member( if not target: raise HTTPException(status_code=404, detail="User not found") - new_member = False - mem = await db.get(GroupMember, (group_id, target.id)) - if not mem: - db.add(GroupMember(group_id=group_id, user_id=target.id)) - new_member = True - - if new_member: + # An invitation, not a membership: the invitee has not agreed to anything, + # and a membership is what makes their client dial this group's nodes and + # name it in the tokens it hands them. They accept it themselves + # (`POST /{id}/invitation/accept`); until then the group is not theirs. + if await db.get(GroupMember, (group_id, target.id)): + return {"status": "member", "group_id": group_id, "username": username} + if await db.get(GroupInvitation, (group_id, target.id)) is None: + db.add(GroupInvitation(group_id=group_id, user_id=target.id, + invited_by=current_user.id)) from meshbay_hub.api.notifications import create_notification await create_notification( db, target.id, "group_invite", - f"You were added to {group.name}", - link=f"#/group/{group_id}", - group_id=group_id, + f"{current_user.username} invited you to a group", + link="#/", ) - await db.commit() - return {"status": "stored", "group_id": group_id, "username": username} + return {"status": "invited", "group_id": group_id, "username": username} class MuteRequest(BaseModel): @@ -695,3 +781,93 @@ async def invite_notify( return {"status": "sent"} + + +# ── Hosts: which nodes may serve this group ───────────────────────────────── +# +# A node owned by the group's owner hosts it without asking. Any other node — +# a member's, or the owner's own on another account — is registered for the +# group only once the owner approves it here. A node that claims a group it may +# not host appears in this list as `pending`, and the owner was notified. + +async def _owned(db: AsyncSession, group_id: str, user: User) -> Group: + group = await db.get(Group, group_id) + if not group: + raise HTTPException(status_code=404, detail="Group not found") + if group.admin_id != user.id: + raise HTTPException(status_code=403, + detail="Only the group owner can choose its hosts") + return group + + +@router.get("/{group_id}/hosts") +async def list_hosts( + group_id: str, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + from meshbay_hub.api.revocation import is_node_connected + await _owned(db, group_id, current_user) + rows = (await db.execute( + select(GroupHost, Node, User.username) + .join(Node, Node.id == GroupHost.node_id) + .outerjoin(User, User.id == Node.user_id) + .where(GroupHost.group_id == group_id) + .order_by(GroupHost.requested_at))).all() + return {"hosts": [ + {"node_id": n.id, "pk_node": n.pk_node, "username": uname or "", + "status": h.status, "online": is_node_connected(n.id), + "requested_at": h.requested_at.isoformat() if h.requested_at else None} + for h, n, uname in rows + ]} + + +@router.post("/{group_id}/hosts/{node_id}") +async def approve_host( + group_id: str, + node_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Approve a node that asked to host this group. Only a request the node + itself made can be approved: the owner picks from what asked, and never + names a node that did not.""" + from meshbay_hub.api.revocation import refresh_node_groups + group = await _owned(db, group_id, current_user) + host = await db.get(GroupHost, (group_id, node_id)) + if host is None: + raise HTTPException(status_code=404, detail="That node has not asked to host this group") + host.status = "approved" + host.decided_at = datetime.now(UTC) + db.add(IPLog(user_id=current_user.id, event="group_host_approve", + ip_address=client_ip(request), detail=f"{group.name}: {node_id[:8]}")) + await db.commit() + await refresh_node_groups(node_id) + return {"status": "approved", "group_id": group_id, "node_id": node_id} + + +@router.delete("/{group_id}/hosts/{node_id}") +async def remove_host( + group_id: str, + node_id: str, + request: Request, + current_user: User = Depends(require_user_scope), + db: AsyncSession = Depends(get_db), +): + """Withdraw an approval, or turn a request down. Takes effect at once for a + connected node. The row stays, marked `refused`, so the node asking again + on every reconnection does not notify the owner every time; approving it + later is still one call.""" + from meshbay_hub.api.revocation import refresh_node_groups + group = await _owned(db, group_id, current_user) + host = await db.get(GroupHost, (group_id, node_id)) + if host is None: + raise HTTPException(status_code=404, detail="No such host") + host.status = "refused" + host.decided_at = datetime.now(UTC) + db.add(IPLog(user_id=current_user.id, event="group_host_remove", + ip_address=client_ip(request), detail=f"{group.name}: {node_id[:8]}")) + await db.commit() + await refresh_node_groups(node_id) + return {"status": "refused", "group_id": group_id, "node_id": node_id} |