diff --git a/alembic/versions/0024_chat_bans.py b/alembic/versions/0024_chat_bans.py new file mode 100644 index 0000000..1ba679c --- /dev/null +++ b/alembic/versions/0024_chat_bans.py @@ -0,0 +1,46 @@ +"""add chat bans for moderation + +Revision ID: 0024_chat_bans +Revises: 0023_privacy_pm_level +Create Date: 2026-03-09 01:10:00.000000 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "0024_chat_bans" +down_revision: Union[str, Sequence[str], None] = "0023_privacy_pm_level" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "chat_bans", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("chat_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("banned_by_user_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["chat_id"], ["chats.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["banned_by_user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("chat_id", "user_id", name="uq_chat_bans_chat_user"), + ) + op.create_index("ix_chat_bans_id", "chat_bans", ["id"], unique=False) + op.create_index("ix_chat_bans_chat_id", "chat_bans", ["chat_id"], unique=False) + op.create_index("ix_chat_bans_user_id", "chat_bans", ["user_id"], unique=False) + op.create_index("ix_chat_bans_banned_by_user_id", "chat_bans", ["banned_by_user_id"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_chat_bans_banned_by_user_id", table_name="chat_bans") + op.drop_index("ix_chat_bans_user_id", table_name="chat_bans") + op.drop_index("ix_chat_bans_chat_id", table_name="chat_bans") + op.drop_index("ix_chat_bans_id", table_name="chat_bans") + op.drop_table("chat_bans") + diff --git a/app/chats/models.py b/app/chats/models.py index c4abccb..2b9d07d 100644 --- a/app/chats/models.py +++ b/app/chats/models.py @@ -108,3 +108,14 @@ class ChatInviteLink(Base): token: Mapped[str] = mapped_column(String(64), nullable=False, index=True) is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + +class ChatBan(Base): + __tablename__ = "chat_bans" + __table_args__ = (UniqueConstraint("chat_id", "user_id", name="uq_chat_bans_chat_user"),) + + id: Mapped[int] = mapped_column(primary_key=True, index=True) + chat_id: Mapped[int] = mapped_column(ForeignKey("chats.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + banned_by_user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False) diff --git a/app/chats/repository.py b/app/chats/repository.py index 82c2d84..8f2bf15 100644 --- a/app/chats/repository.py +++ b/app/chats/repository.py @@ -2,7 +2,7 @@ from sqlalchemy import Select, String, func, or_, select from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession -from app.chats.models import Chat, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting +from app.chats.models import Chat, ChatBan, ChatInviteLink, ChatMember, ChatMemberRole, ChatNotificationSetting, ChatType, ChatUserSetting from app.messages.models import Message, MessageHidden, MessageReceipt @@ -401,3 +401,31 @@ async def get_active_chat_invite_by_token(db: AsyncSession, *, token: str) -> Ch .limit(1) ) return result.scalar_one_or_none() + + +async def get_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int) -> ChatBan | None: + result = await db.execute(select(ChatBan).where(ChatBan.chat_id == chat_id, ChatBan.user_id == user_id)) + return result.scalar_one_or_none() + + +async def is_user_banned_in_chat(db: AsyncSession, *, chat_id: int, user_id: int) -> bool: + result = await db.execute(select(ChatBan.id).where(ChatBan.chat_id == chat_id, ChatBan.user_id == user_id).limit(1)) + return result.scalar_one_or_none() is not None + + +async def upsert_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int, banned_by_user_id: int) -> ChatBan: + existing = await get_chat_ban(db, chat_id=chat_id, user_id=user_id) + if existing: + existing.banned_by_user_id = banned_by_user_id + await db.flush() + return existing + ban = ChatBan(chat_id=chat_id, user_id=user_id, banned_by_user_id=banned_by_user_id) + db.add(ban) + await db.flush() + return ban + + +async def remove_chat_ban(db: AsyncSession, *, chat_id: int, user_id: int) -> None: + ban = await get_chat_ban(db, chat_id=chat_id, user_id=user_id) + if ban: + await db.delete(ban) diff --git a/app/chats/router.py b/app/chats/router.py index aa43808..fcc5007 100644 --- a/app/chats/router.py +++ b/app/chats/router.py @@ -21,6 +21,7 @@ from app.chats.schemas import ( ) from app.chats.service import ( add_chat_member_for_user, + ban_chat_member_for_user, create_chat_for_user, create_chat_invite_link_for_user, clear_chat_for_user, @@ -39,6 +40,7 @@ from app.chats.service import ( serialize_chats_for_user, set_chat_archived_for_user, set_chat_pinned_for_user, + unban_chat_member_for_user, update_chat_member_role_for_user, update_chat_notification_settings_for_user, update_chat_profile_for_user, @@ -207,6 +209,29 @@ async def remove_chat_member( await realtime_gateway.publish_chat_updated(chat_id=chat_id) +@router.post("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def ban_chat_member( + chat_id: int, + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + await ban_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id) + realtime_gateway.remove_chat_subscription(chat_id=chat_id, user_id=user_id) + await realtime_gateway.publish_chat_updated(chat_id=chat_id) + + +@router.delete("/{chat_id}/bans/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def unban_chat_member( + chat_id: int, + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> None: + await unban_chat_member_for_user(db, chat_id=chat_id, actor_user_id=current_user.id, target_user_id=user_id) + await realtime_gateway.publish_chat_updated(chat_id=chat_id) + + @router.post("/{chat_id}/leave", status_code=status.HTTP_204_NO_CONTENT) async def leave_chat( chat_id: int, diff --git a/app/chats/service.py b/app/chats/service.py index 9e2cf17..15180c4 100644 --- a/app/chats/service.py +++ b/app/chats/service.py @@ -350,6 +350,8 @@ async def add_chat_member_for_user( chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id) _ensure_group_or_channel(chat.type) _ensure_manage_permission(membership.role) + if await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=target_user_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is banned from this chat") if target_user_id == actor_user_id: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="User is already in chat") target_user = await get_user_by_id(db, target_user_id) @@ -427,6 +429,45 @@ async def remove_chat_member_for_user( await db.commit() +async def ban_chat_member_for_user( + db: AsyncSession, + *, + chat_id: int, + actor_user_id: int, + target_user_id: int, +) -> None: + chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id) + _ensure_group_or_channel(chat.type) + if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + if target_user_id == actor_user_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Cannot ban yourself") + target_membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=target_user_id) + if target_membership: + if target_membership.role == ChatMemberRole.OWNER: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Owner cannot be banned") + if actor_membership.role == ChatMemberRole.ADMIN and target_membership.role != ChatMemberRole.MEMBER: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin can ban only members") + await repository.delete_chat_member(db, target_membership) + await repository.upsert_chat_ban(db, chat_id=chat_id, user_id=target_user_id, banned_by_user_id=actor_user_id) + await db.commit() + + +async def unban_chat_member_for_user( + db: AsyncSession, + *, + chat_id: int, + actor_user_id: int, + target_user_id: int, +) -> None: + chat, actor_membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=actor_user_id) + _ensure_group_or_channel(chat.type) + if actor_membership.role not in {ChatMemberRole.OWNER, ChatMemberRole.ADMIN}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient permissions") + await repository.remove_chat_ban(db, chat_id=chat_id, user_id=target_user_id) + await db.commit() + + async def leave_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: int) -> None: chat, membership = await _get_chat_and_membership(db, chat_id=chat_id, user_id=user_id) _ensure_group_or_channel(chat.type) @@ -514,6 +555,8 @@ async def join_public_chat_for_user(db: AsyncSession, *, chat_id: int, user_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found") if not chat.is_public or chat.type not in {ChatType.GROUP, ChatType.CHANNEL}: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Chat is not joinable") + if await repository.is_user_banned_in_chat(db, chat_id=chat_id, user_id=user_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat") membership = await repository.get_chat_member(db, chat_id=chat_id, user_id=user_id) if membership: return chat @@ -650,6 +693,8 @@ async def join_chat_by_invite_for_user( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found") if chat.type not in {ChatType.GROUP, ChatType.CHANNEL}: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite link is not valid for this chat") + if await repository.is_user_banned_in_chat(db, chat_id=chat.id, user_id=user_id): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You are banned from this chat") membership = await repository.get_chat_member(db, chat_id=chat.id, user_id=user_id) if membership: return chat diff --git a/docs/api-reference.md b/docs/api-reference.md index fd3de02..4dc7d5a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -747,6 +747,17 @@ Response: `200` + `ChatMemberRead` Auth required. Response: `204` +### POST `/api/v1/chats/{chat_id}/bans/{user_id}` + +Auth required (`owner/admin` in group/channel). +Response: `204` +Behavior: bans user from chat and removes membership if present. + +### DELETE `/api/v1/chats/{chat_id}/bans/{user_id}` + +Auth required (`owner/admin` in group/channel). +Response: `204` + ### POST `/api/v1/chats/{chat_id}/leave` Auth required. diff --git a/docs/core-checklist-status.md b/docs/core-checklist-status.md index 910e466..9885200 100644 --- a/docs/core-checklist-status.md +++ b/docs/core-checklist-status.md @@ -31,7 +31,7 @@ Legend: 22. Text Formatting - `PARTIAL` (bold/italic/underline/spoiler/mono/links + strikethrough + quote/code block; toolbar still evolving) 23. Groups - `PARTIAL` (create/add/remove/invite link; advanced moderation partial) 24. Roles - `DONE` (owner/admin/member) -25. Admin Rights - `PARTIAL` (delete/ban-like remove/pin/edit info; full ban system limited) +25. Admin Rights - `PARTIAL` (delete/pin/edit info + explicit ban API for groups/channels; remaining UX moderation tools limited) 26. Channels - `PARTIAL` (create/post/edit/delete/subscribe/unsubscribe; UX edge-cases still polishing) 27. Channel Types - `DONE` (public/private) 28. Notifications - `PARTIAL` (browser notifications + mute/settings; no mobile push infra) diff --git a/tests/test_chat_message_flow.py b/tests/test_chat_message_flow.py index 05c4cc5..eb9949a 100644 --- a/tests/test_chat_message_flow.py +++ b/tests/test_chat_message_flow.py @@ -96,3 +96,31 @@ async def test_private_chat_respects_contacts_only_policy(client, db_session): json={"type": ChatType.PRIVATE.value, "title": None, "member_ids": [u2_id]}, ) assert create_chat_allowed.status_code == 200 + + +async def test_group_ban_blocks_rejoin(client, db_session): + owner = await _create_verified_user(client, db_session, "ban_owner@example.com", "ban_owner", "strongpass123") + member = await _create_verified_user(client, db_session, "ban_member@example.com", "ban_member", "strongpass123") + + me_member = await client.get("/api/v1/auth/me", headers={"Authorization": f"Bearer {member['access_token']}"}) + member_id = me_member.json()["id"] + + create_group = await client.post( + "/api/v1/chats", + headers={"Authorization": f"Bearer {owner['access_token']}"}, + json={"type": ChatType.GROUP.value, "title": "Test group", "member_ids": [member_id], "is_public": True, "handle": "ban_test_group"}, + ) + assert create_group.status_code == 200 + chat_id = create_group.json()["id"] + + ban_response = await client.post( + f"/api/v1/chats/{chat_id}/bans/{member_id}", + headers={"Authorization": f"Bearer {owner['access_token']}"}, + ) + assert ban_response.status_code == 204 + + rejoin_response = await client.post( + f"/api/v1/chats/{chat_id}/join", + headers={"Authorization": f"Bearer {member['access_token']}"}, + ) + assert rejoin_response.status_code == 403 diff --git a/web/src/api/chats.ts b/web/src/api/chats.ts index 37f0240..0ec4767 100644 --- a/web/src/api/chats.ts +++ b/web/src/api/chats.ts @@ -329,6 +329,14 @@ export async function removeChatMember(chatId: number, userId: number): Promise< await http.delete(`/chats/${chatId}/members/${userId}`); } +export async function banChatMember(chatId: number, userId: number): Promise { + await http.post(`/chats/${chatId}/bans/${userId}`); +} + +export async function unbanChatMember(chatId: number, userId: number): Promise { + await http.delete(`/chats/${chatId}/bans/${userId}`); +} + export async function leaveChat(chatId: number): Promise { await http.post(`/chats/${chatId}/leave`); } diff --git a/web/src/components/ChatInfoPanel.tsx b/web/src/components/ChatInfoPanel.tsx index 0a616c7..1e99f91 100644 --- a/web/src/components/ChatInfoPanel.tsx +++ b/web/src/components/ChatInfoPanel.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { addChatMember, + banChatMember, createInviteLink, getChatAttachments, getMessages, @@ -785,6 +786,24 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) { Transfer ownership ) : null} + {(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? ( + + ) : null} {(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? ( ) : null}