summaryrefslogtreecommitdiffstats
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.py62
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}")