aboutsummaryrefslogtreecommitdiffstats
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/groups.py206
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/invite_links.py7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/nodes.py17
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/revocation.py91
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py7
5 files changed, 298 insertions, 30 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}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
index 3c50e19..86fbbb4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
@@ -30,7 +30,7 @@ from meshbay_hub import mail
from meshbay_hub.api.deps import _decode_token, get_current_user, require_user_scope
from meshbay_hub.api.middleware import limiter
from meshbay_hub.db.engine import get_db
-from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User
+from meshbay_hub.db.models import Group, GroupInvitation, GroupInviteLink, GroupMember, User
router = APIRouter(prefix="/v1/groups", tags=["invite-links"])
redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"])
@@ -322,6 +322,11 @@ async def redeem_invite_link(
raise HTTPException(status_code=404, detail="invite_not_valid")
if not await db.get(GroupMember, (group.id, current_user.id)):
db.add(GroupMember(group_id=group.id, user_id=current_user.id))
+ # Redeeming a link is the invitee's own act, so it answers an
+ # invitation to the same group as well.
+ pending = await db.get(GroupInvitation, (group.id, current_user.id))
+ if pending is not None:
+ await db.delete(pending)
from meshbay_hub.api.notifications import create_notification
await create_notification(
db, row.created_by, "invite_link_redeemed",
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
index 403f450..decd20e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/nodes.py
@@ -26,6 +26,10 @@ class MnpTokenRequest(BaseModel):
# to it (E10), so it cannot be replayed to another node. The client knows it
# from `/v1/groups/{id}/nodes` before it connects.
node_pk: str = ""
+ # The one group this connection is for. The token names that group and no
+ # other: it is handed to the node's operator, who has no business learning
+ # every other group the member belongs to.
+ group_id: str = ""
@router.post("/mnp-token")
@@ -50,11 +54,16 @@ async def mnp_token(
only *restricts* the token to whatever node holds that key, which is the one
the client is connecting to; a wrong key yields a token no node will accept.
"""
- rows = await db.execute(
- select(GroupMember.group_id).where(GroupMember.user_id == current_user.id))
- group_ids = [gid for (gid,) in rows.all()]
+ group_id = (body.group_id if body else "").strip()
+ if not group_id:
+ raise HTTPException(status_code=422,
+ detail="Name the group this connection is for (group_id)")
+ member = await db.get(GroupMember, (group_id, current_user.id))
return {
- "mnp_token": issue_mnp_token(current_user.id, groups=group_ids,
+ # Empty when the account is not a member: the node then refuses with
+ # `not_a_member`, which is the answer that case has always had.
+ "mnp_token": issue_mnp_token(current_user.id,
+ groups=[group_id] if member else [],
node_pk=(body.node_pk if body else "")),
"expires_in": 900,
}
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
index fe9edf3..6a6baa7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/revocation.py
@@ -52,6 +52,10 @@ router = APIRouter(tags=["revocation"])
_connected_nodes: dict[str, WebSocket] = {} # node_id → websocket
_node_groups: dict[str, list[str]] = {} # node_id → [group_id, ...]
+# What each connected node asked for, and whose it is, so that an owner
+# approving or withdrawing a host takes effect without the node reconnecting.
+_node_claims: dict[str, list[str]] = {} # node_id → claimed group ids
+_node_users: dict[str, str] = {} # node_id → owning account
_punch_events: dict[str, asyncio.Event] = {} # node_id → signaling event
# How long an unauthenticated socket may stay open before saying who it is. The
@@ -98,6 +102,8 @@ def forget_node(node_id: str) -> None:
"""
_connected_nodes.pop(node_id, None)
_node_groups.pop(node_id, None)
+ _node_claims.pop(node_id, None)
+ _node_users.pop(node_id, None)
def _notify_budget(node_id: str) -> bool:
@@ -283,6 +289,72 @@ async def _authorized_groups(user_id: str) -> set[str]:
return {gid for (gid,) in result.all()}
+async def _hostable_groups(db: AsyncSession, node_id: str, user_id: str) -> set[str]:
+ """The groups this node may host: its account's own, and those whose owner
+ approved it (`GroupHost`). Membership alone is not enough — every member
+ holds the group key, so a member's node would pass the handshake as though
+ it were the real host."""
+ from meshbay_hub.db.models import GroupHost
+ owned = set((await db.execute(
+ select(Group.id).where(Group.admin_id == user_id))).scalars().all())
+ approved = set((await db.execute(
+ select(GroupHost.group_id).where(GroupHost.node_id == node_id,
+ GroupHost.status == "approved"))).scalars().all())
+ return owned | approved
+
+
+async def _record_host_requests(db: AsyncSession, node_id: str, user_id: str,
+ group_ids: set[str]) -> None:
+ """Remember that this node would like to host these groups, and tell each
+ owner once — the first time — rather than on every reconnection."""
+ from meshbay_hub.api.notifications import create_notification
+ from meshbay_hub.db.models import GroupHost
+ if not group_ids:
+ return
+ known = set((await db.execute(
+ select(GroupHost.group_id).where(GroupHost.node_id == node_id,
+ GroupHost.group_id.in_(group_ids)))).scalars().all())
+ fresh = group_ids - known
+ if not fresh:
+ return
+ who = await db.scalar(select(User.username).where(User.id == user_id)) or ""
+ for gid in sorted(fresh):
+ db.add(GroupHost(group_id=gid, node_id=node_id, status="pending"))
+ group = await db.get(Group, gid)
+ if group is not None:
+ await create_notification(
+ db, group.admin_id, "host_request",
+ f"A node of {who} asks to host {group.name}",
+ link=f"#/group/{gid}", group_id=gid)
+ await db.commit()
+
+
+async def resolve_node_groups(node_id: str, user_id: str, claimed_groups) -> list[str]:
+ """What a node is registered for: what it claims, within what its account
+ belongs to and what it may host. The rest of its claim becomes a request
+ the owner can approve."""
+ from meshbay_hub.db.engine import get_session_factory
+ async with get_session_factory()() as db:
+ result = await db.execute(
+ select(GroupMember.group_id).where(GroupMember.user_id == user_id))
+ authorized = {gid for (gid,) in result.all()}
+ allowed = _claimable(claimed_groups, authorized)
+ hostable = await _hostable_groups(db, node_id, user_id)
+ await _record_host_requests(db, node_id, user_id,
+ set(allowed) - hostable)
+ return [gid for gid in allowed if gid in hostable]
+
+
+async def refresh_node_groups(node_id: str) -> None:
+ """Re-evaluate a connected node's registration after a host decision."""
+ if node_id not in _connected_nodes:
+ return
+ new_gids = await resolve_node_groups(
+ node_id, _node_users.get(node_id, ""), _node_claims.get(node_id))
+ _node_groups[node_id] = new_gids
+ await _mark_hosted(new_gids)
+
+
def _claimable(claimed_groups, authorized: set[str]) -> list[str]:
"""What a node actually gets registered for. Two rules.
@@ -337,14 +409,10 @@ async def _authorize_node_ws(token: str, claimed_id: str, claimed_groups) -> tup
if user is None or user.status != "active":
return None, "Account not active"
- # Groups come from the database. The node may narrow the set to what it
- # actually hosts, but it cannot widen it to groups it is not a member of —
- # otherwise it could advertise itself as a source for any group on the hub.
- result = await db.execute(
- select(GroupMember.group_id).where(GroupMember.user_id == user_id))
- authorized = {gid for (gid,) in result.all()}
-
- return claimed_id, _claimable(claimed_groups, authorized)
+ # Groups come from the database. The node may narrow the set to what it
+ # actually hosts, but it cannot widen it past what its account belongs to
+ # (C2) — nor, within that, past what its account owns or the owner approved.
+ return claimed_id, await resolve_node_groups(claimed_id, user_id, claimed_groups)
@router.websocket("/v1/nodes/ws")
@@ -404,6 +472,8 @@ async def node_websocket(ws: WebSocket):
node_id = resolved_id
_connected_nodes[node_id] = ws
_node_groups[node_id] = group_ids
+ _node_claims[node_id] = list(msg.get("group_ids") or [])
+ _node_users[node_id] = user_id
await _mark_hosted(group_ids)
log.info("Node WS connected: %s (user=%s, groups=%d)",
node_id[:8], user_id[:8], len(group_ids))
@@ -427,8 +497,9 @@ async def node_websocket(ws: WebSocket):
# assign the message's list verbatim, so the ceiling that makes
# C2 hold at authentication could be stepped over one message
# later: a node had only to reload to claim any group on the hub.
- new_gids = _claimable(msg.get("group_ids"),
- await _authorized_groups(user_id))
+ _node_claims[node_id] = list(msg.get("group_ids") or [])
+ new_gids = await resolve_node_groups(
+ node_id, user_id, msg.get("group_ids"))
_node_groups[node_id] = new_gids
await _mark_hosted(new_gids)
log.info("Node %s updated groups: %d", node_id[:8], len(new_gids))
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 3e996a7..660bd76 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -35,6 +35,8 @@ from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
EmailVerification,
Group,
+ GroupHost,
+ GroupInvitation,
GroupInviteLink,
GroupMember,
IPLog,
@@ -1415,6 +1417,11 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(GroupMember).where(GroupMember.user_id == user.id))
await db.execute(delete(Notification).where(Notification.user_id == user.id))
await db.execute(delete(RefreshToken).where(RefreshToken.user_id == user.id))
+ await db.execute(delete(GroupInvitation).where(GroupInvitation.user_id == user.id))
+ await db.execute(update(GroupInvitation).where(GroupInvitation.invited_by == user.id)
+ .values(invited_by=None))
+ await db.execute(delete(GroupHost).where(
+ GroupHost.node_id.in_(select(Node.id).where(Node.user_id == user.id))))
await db.execute(delete(Node).where(Node.user_id == user.id))
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))