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 | 62 |
1 files changed, 49 insertions, 13 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py index 91aa9c4..49902de 100644 --- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py +++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py @@ -4,6 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from datetime import datetime, timezone from sqlalchemy import func, or_, select, update +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from meshbay_hub import hub_settings @@ -31,8 +32,9 @@ async def my_groups( from meshbay_hub.api.revocation import get_online_nodes_for_group result = await db.execute( - select(Group) + select(Group, User.username) .join(GroupMember, Group.id == GroupMember.group_id) + .join(User, User.id == Group.admin_id) # the owner, for the @handle .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 @@ -40,7 +42,9 @@ async def my_groups( or_(Group.hosted_at.is_not(None), Group.admin_id == current_user.id)) .order_by(Group.last_activity_at.desc()) ) - groups = result.scalars().all() + rows = result.all() + groups = [g for g, _ in rows] + owner_by_id = {g.id: owner for g, owner in rows} muted_rows = await db.execute( select(GroupMember.group_id, GroupMember.muted) .where(GroupMember.user_id == current_user.id)) @@ -50,6 +54,7 @@ async def my_groups( { "id": g.id, "name": g.name, + "owner_username": owner_by_id.get(g.id), "visibility": g.visibility, "muted": muted_map.get(g.id, False), "join_policy": g.join_policy, @@ -147,22 +152,24 @@ async def list_public_groups( # 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)) + query = (select(Group, User.username) + .join(User, User.id == Group.admin_id) + .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( query.order_by(Group.created_at.desc()).limit(limit).offset(offset) ) - local = result.scalars().all() + local = result.all() groups = [ { "id": g.id, "name": g.name, "join_policy": g.join_policy, - "description": g.description or "", + "owner_username": owner, "description": g.description or "", "created_at": g.created_at.isoformat(), "source": "local", } - for g in local + for g, owner in local ] if include_federated: @@ -306,7 +313,9 @@ async def join_group( db.add(IPLog(user_id=current_user.id, event="group_join", ip_address=client_ip(request), detail=group.name)) await db.commit() - return {"status": "joined", "group_id": group_id, "name": group.name} + 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} class GroupCreateRequest(BaseModel): @@ -386,6 +395,25 @@ async def create_group( status_code=422, detail="A private group is invite-only. Make it public if you want " "anyone to be able to join.") + + name = body.name.strip() + if not name: + raise HTTPException(status_code=422, detail="A group needs a name.") + # One name per owner, case-insensitively. Two *different* owners may each + # have a "photos" — that is why the check is scoped to `admin_id` and why + # the group's real identity stays its UUID. The DB has a unique index too + # (uq_groups_owner_name); this is the friendly error, that is the race + # backstop below. + clash = await db.scalar( + select(Group.id).where( + Group.admin_id == current_user.id, + func.lower(Group.name) == name.lower())) + if clash: + raise HTTPException( + status_code=409, + detail=f"You already have a group called “{name}”. Pick another name " + "— names only have to be unique among your own groups.") + if body.visibility == "public": if not await hub_settings.public_groups_allowed(db): # Instance policy, set by a hub admin in the panel. Absolute — staff @@ -399,7 +427,7 @@ async def create_group( desc = (body.description or "")[:512] if body.description else None group = Group( - name=body.name, + name=name, admin_id=current_user.id, visibility=body.visibility, join_policy=body.join_policy, @@ -410,10 +438,18 @@ async def create_group( db.add(GroupMember(group_id=group.id, user_id=current_user.id)) db.add(IPLog(user_id=current_user.id, event="group_create", - ip_address=client_ip(request), detail=body.name)) - await db.commit() + ip_address=client_ip(request), detail=name)) + try: + await db.commit() + except IntegrityError: + # Two creates for the same owner+name raced past the check above. + await db.rollback() + raise HTTPException( + status_code=409, + detail=f"You already have a group called “{name}”.") await db.refresh(group) - return {"group_id": group.id, "name": group.name} + return {"group_id": group.id, "name": group.name, + "owner_username": current_user.username} @router.delete("/{group_id}/members/{username}") |