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/invite_links.py336
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/users.py5
2 files changed, 341 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
new file mode 100644
index 0000000..d44576b
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/api/invite_links.py
@@ -0,0 +1,336 @@
+"""
+Invitation links — the hub's half (docs/MESHBAY_DESIGN.md §3.4, §7.3).
+
+A link carries two secrets with two jobs. The node's code decides who gets the
+group key, and it is shown to the hub only when the inviter asks the hub to mail
+the link. The ticket here decides who may *reach* the node — membership,
+which is all the hub has to give (§7.1) — and it gives it to one account only:
+the one whose verified address the inviter named. A ticket that leaks, through a
+messaging service that previews links or a forwarded mail, is therefore useless
+without that mailbox.
+
+Stated per the design's convention: that binding holds against third parties and
+not against this hub, which verifies the addresses it compares. An active hub
+could already be anybody.
+
+No route here answers without an account, and nothing tells the inviter whether
+an address has an account (M1): creating a link looks the same either way.
+"""
+
+import hashlib
+import re
+import secrets
+from datetime import UTC, datetime, timedelta
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from pydantic import BaseModel, field_validator
+from sqlalchemy import delete, func, select, update
+from sqlalchemy.ext.asyncio import AsyncSession
+
+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.auth import hash_email_blind
+from meshbay_hub.db.engine import get_db
+from meshbay_hub.db.models import Group, GroupInviteLink, GroupMember, User
+
+router = APIRouter(prefix="/v1/groups", tags=["invite-links"])
+redeem_router = APIRouter(prefix="/v1/invite-links", tags=["invite-links"])
+
+# The node holds at most as many unredeemed link codes per group; one more
+# ticket here than codes there would be a link that cannot work.
+MAX_OUTSTANDING_PER_GROUP = 20
+# A node's invitation lifetime is the operator's setting (7 days by default);
+# the ticket follows it, up to this.
+MAX_LIFETIME = timedelta(days=30)
+# How long a redeemed link stays in the owner's list, saying who used it.
+KEEP_REDEEMED = timedelta(days=30)
+
+_TICKET = re.compile(r"^[A-Za-z0-9_-]{22}$") # secrets.token_urlsafe(16)
+_NODE_INVITE_ID = re.compile(r"^[0-9a-f]{32}$") # roster.create_link_invite
+_CODE = re.compile(r"^[0-9A-Za-z]{4}-[0-9A-Za-z]{4}$") # roster.generate_code
+_B64URL_KEY = re.compile(r"^[A-Za-z0-9_-]{43}$") # 32 bytes, unpadded
+
+
+def ticket_hash(ticket: str) -> str:
+ return hashlib.sha256(ticket.encode()).hexdigest()
+
+
+def invite_url(group_id: str, ticket: str, node_pk: str, code: str) -> str:
+ """
+ The one shape an invitation link has (docs/MESHBAY_DESIGN.md §3.4).
+
+ Everything after `#`, so none of it is sent to this hub in a request — it
+ reaches the page and the page alone, which parses this same shape.
+ """
+ return (f"{mail.hub_url()}/#/invite?v=1&g={group_id}&t={ticket}"
+ f"&n={node_pk}&c={code}")
+
+
+def _mask(email: str) -> str:
+ """`al***@ex***.com` — enough for the owner to recognise, not to reuse."""
+ local, _, domain = email.strip().lower().partition("@")
+ name, _, tld = domain.rpartition(".")
+ return f"{local[:2]}***@{name[:2]}***.{tld}"
+
+
+def _aware(when: datetime) -> datetime:
+ # SQLite hands a timezone-aware column back naive.
+ return when if when.tzinfo else when.replace(tzinfo=UTC)
+
+
+def _valid_email(v: str) -> str:
+ v = v.strip()
+ local, sep, domain = v.partition("@")
+ if (not sep or not local or not domain or "." not in domain.strip(".")
+ or len(v) > 254 or any(c.isspace() or ord(c) < 32 for c in v)):
+ raise ValueError("invalid email address")
+ return v
+
+
+class CreateLinkRequest(BaseModel):
+ email: str
+ expires_at: str
+ node_invite_id: str
+ # Only when the hub is to mail the link, since only then must it write it:
+ # the node key in its URL-safe form, and the node's code — which the hub
+ # therefore reads. Unchecked in the interface, neither is sent.
+ send_email: bool = False
+ node_pk: str = ""
+ code: str = ""
+
+ @field_validator("email")
+ @classmethod
+ def email_valid(cls, v: str) -> str:
+ return _valid_email(v)
+
+
+class TicketRequest(BaseModel):
+ ticket: str
+
+
+async def _owned_group(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 manage its invitation links")
+ return group
+
+
+@router.post("/{group_id}/invite-links", status_code=201)
+@limiter.limit("20/hour")
+async def create_invite_link(
+ group_id: str,
+ body: CreateLinkRequest,
+ request: Request,
+ payload: dict = Depends(_decode_token),
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Mint the ticket for a link whose node half already exists.
+
+ `get_current_user`, not `require_user_scope`: `meshbay-node member invite
+ --link` asks with the node's token, as the CLI invitation already does, and
+ the owner check is the guard — a node can only act for its own operator's
+ groups. The CLI mails nothing (`USERGUIDE.md` §7), so a node token asking
+ for mail is refused rather than quietly obeyed.
+ """
+ group = await _owned_group(db, group_id, current_user)
+ if group.status != "active":
+ raise HTTPException(status_code=409, detail="This group is not active")
+ if group.join_policy == "open":
+ raise HTTPException(status_code=409,
+ detail="An open group admits anyone; it needs no invitation")
+ if not _NODE_INVITE_ID.match(body.node_invite_id):
+ raise HTTPException(status_code=422, detail="Not a node invitation id")
+ if body.send_email:
+ if payload.get("scope") == "node":
+ raise HTTPException(status_code=403,
+ detail="Invitation mail is sent from the interface only")
+ if not (_CODE.match(body.code) and _B64URL_KEY.match(body.node_pk)):
+ raise HTTPException(status_code=422,
+ detail="Mailing a link needs its code and node key")
+
+ now = datetime.now(UTC)
+ try:
+ expires = _aware(datetime.fromisoformat(body.expires_at))
+ except ValueError:
+ raise HTTPException(status_code=422, detail="Not a date") from None
+ if expires <= now:
+ raise HTTPException(status_code=422, detail="That invitation has already expired")
+ expires = min(expires, now + MAX_LIFETIME)
+
+ await _purge(db, group_id, now)
+ outstanding = (await db.execute(
+ select(func.count()).select_from(GroupInviteLink).where(
+ GroupInviteLink.group_id == group_id,
+ GroupInviteLink.redeemed_by.is_(None),
+ GroupInviteLink.expires_at > now))).scalar() or 0
+ if outstanding >= MAX_OUTSTANDING_PER_GROUP:
+ raise HTTPException(
+ status_code=429,
+ detail=f"This group already has {outstanding} unused invitation links")
+
+ ticket = secrets.token_urlsafe(16)
+ row = GroupInviteLink(
+ group_id=group_id, created_by=current_user.id, ticket_hash=ticket_hash(ticket),
+ email_hash=hash_email_blind(body.email), email_masked=_mask(body.email),
+ node_invite_id=body.node_invite_id, expires_at=expires)
+ db.add(row)
+ await db.flush()
+
+ email_status = "not_requested"
+ if body.send_email:
+ try:
+ sent = await mail.send_off_loop(
+ db, mail.send_invite_link, body.email,
+ invite_url(group_id, ticket, body.node_pk, body.code.upper()),
+ current_user.username, group.name,
+ purpose="invite_link", sender=current_user.id)
+ email_status = "sent" if sent else "refused"
+ except Exception:
+ email_status = "send_failed"
+
+ await db.commit()
+ return {"link_id": row.id, "ticket": ticket, "expires_at": expires.isoformat(),
+ "email_status": email_status}
+
+
+@router.get("/{group_id}/invite-links")
+async def list_invite_links(
+ group_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """The owner's view: who each link was for, masked, and whether it was used."""
+ await _owned_group(db, group_id, current_user)
+ rows = (await db.execute(
+ select(GroupInviteLink, User.username)
+ .outerjoin(User, User.id == GroupInviteLink.redeemed_by)
+ .where(GroupInviteLink.group_id == group_id)
+ .order_by(GroupInviteLink.created_at.desc())
+ .limit(200))).all()
+ now = datetime.now(UTC)
+ return {"links": [{
+ "link_id": r.id,
+ "email": r.email_masked,
+ "node_invite_id": r.node_invite_id,
+ "created_at": _aware(r.created_at).isoformat(),
+ "expires_at": _aware(r.expires_at).isoformat(),
+ "status": ("redeemed" if r.redeemed_by
+ else "expired" if _aware(r.expires_at) <= now else "pending"),
+ "redeemed_by": name,
+ } for r, name in rows]}
+
+
+@router.delete("/{group_id}/invite-links/{link_id}")
+async def delete_invite_link(
+ group_id: str,
+ link_id: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Take the ticket back. The node's code is the client's half to cancel,
+ first, with a signed `invite_cancel` — this only closes the door here."""
+ await _owned_group(db, group_id, current_user)
+ row = await db.get(GroupInviteLink, link_id)
+ if not row or row.group_id != group_id:
+ raise HTTPException(status_code=404, detail="No such invitation link")
+ if row.redeemed_by:
+ raise HTTPException(status_code=409,
+ detail="This link has been used; remove the member instead")
+ await db.delete(row)
+ await db.commit()
+ return {"status": "deleted", "link_id": link_id}
+
+
+async def _resolve(db: AsyncSession, ticket: str, user: User
+ ) -> tuple[GroupInviteLink, Group]:
+ """
+ The link this ticket names, if this account may use it.
+
+ One uniform refusal for everything that says nothing about the account —
+ unknown, spent by someone else, expired, a group no longer active — and one
+ distinct answer, `invite_other_account`, for the case the person can act
+ on: signed in as somebody other than the address it was sent to. That
+ answer names no address, and it is given only to someone holding the
+ ticket, who already knows a link exists.
+ """
+ invalid = HTTPException(status_code=404, detail="invite_not_valid")
+ if not _TICKET.match(ticket or ""):
+ raise invalid
+ row = (await db.execute(select(GroupInviteLink).where(
+ GroupInviteLink.ticket_hash == ticket_hash(ticket)))).scalar_one_or_none()
+ if row is None:
+ raise invalid
+ group = await db.get(Group, row.group_id)
+ if group is None or group.status != "active":
+ raise invalid
+ if row.redeemed_by and row.redeemed_by != user.id:
+ raise invalid
+ if not row.redeemed_by and _aware(row.expires_at) <= datetime.now(UTC):
+ raise invalid
+ if not user.email_hash or user.email_hash != row.email_hash:
+ raise HTTPException(status_code=403, detail="invite_other_account")
+ return row, group
+
+
+@redeem_router.post("/preview")
+@limiter.limit("30/minute")
+async def preview_invite_link(
+ body: TicketRequest,
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """What the confirmation screen shows before anyone joins anything."""
+ row, group = await _resolve(db, body.ticket, current_user)
+ inviter = await db.get(User, row.created_by)
+ member = await db.get(GroupMember, (group.id, current_user.id))
+ return {"group_id": group.id, "group_name": group.name,
+ "inviter": inviter.username if inviter else "",
+ "expires_at": _aware(row.expires_at).isoformat(),
+ "already_member": member is not None}
+
+
+@redeem_router.post("/redeem")
+@limiter.limit("30/minute")
+async def redeem_invite_link(
+ body: TicketRequest,
+ request: Request,
+ current_user: User = Depends(require_user_scope),
+ db: AsyncSession = Depends(get_db),
+):
+ """
+ Membership for the addressed account, once — and the same answer again for
+ that account, because a second tab or a reload is the same person.
+ """
+ row, group = await _resolve(db, body.ticket, current_user)
+ if not row.redeemed_by:
+ claimed = await db.execute(
+ update(GroupInviteLink)
+ .where(GroupInviteLink.id == row.id, GroupInviteLink.redeemed_by.is_(None))
+ .values(redeemed_by=current_user.id, redeemed_at=datetime.now(UTC)))
+ if claimed.rowcount == 0:
+ 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))
+ from meshbay_hub.api.notifications import create_notification
+ await create_notification(
+ db, row.created_by, "invite_link_redeemed",
+ f"{current_user.username} joined {group.name} through your invitation link",
+ link=f"#/group/{group.id}", group_id=group.id)
+ await db.commit()
+ return {"group_id": group.id, "group_name": group.name}
+
+
+async def _purge(db: AsyncSession, group_id: str, now: datetime) -> None:
+ """Forget this group's dead links: unused ones once expired, used ones after
+ `KEEP_REDEEMED`. Done on the way in, so the table cannot outgrow its use."""
+ await db.execute(delete(GroupInviteLink).where(
+ GroupInviteLink.group_id == group_id,
+ ((GroupInviteLink.redeemed_by.is_(None) & (GroupInviteLink.expires_at <= now))
+ | (GroupInviteLink.redeemed_at < now - KEEP_REDEEMED))))
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/users.py b/packages/meshbay-hub/src/meshbay_hub/api/users.py
index 9b9a189..a994acb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/users.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/users.py
@@ -35,6 +35,7 @@ from meshbay_hub.db.engine import get_db
from meshbay_hub.db.models import (
EmailVerification,
Group,
+ GroupInviteLink,
GroupMember,
IPLog,
Node,
@@ -1396,6 +1397,10 @@ async def erase_account(db: AsyncSession, user: User, owned_groups: str = "refus
await db.execute(delete(UserDevice).where(UserDevice.user_id == user.id))
await db.execute(delete(SwarmSource).where(SwarmSource.node_id == user.id))
await db.execute(delete(EmailVerification).where(EmailVerification.user_id == user.id))
+ # Links this account issued for a group it no longer owns; the ones for its
+ # own groups went with them above. A used link keeps pointing at the
+ # tombstone of whoever used it, which is the record of the join.
+ await db.execute(delete(GroupInviteLink).where(GroupInviteLink.created_by == user.id))
username = user.username
# Before the name is released: the connection log is kept for its legal