aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/api/groups.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/api/groups.py')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py206
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}