moderation: add chat bans for groups/channels with web actions
All checks were successful
CI / test (push) Successful in 26s

This commit is contained in:
2026-03-08 14:29:21 +03:00
parent 76cc5e0f12
commit db700bcbcd
10 changed files with 224 additions and 3 deletions

View File

@@ -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")

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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

View File

@@ -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<void> {
await http.post(`/chats/${chatId}/bans/${userId}`);
}
export async function unbanChatMember(chatId: number, userId: number): Promise<void> {
await http.delete(`/chats/${chatId}/bans/${userId}`);
}
export async function leaveChat(chatId: number): Promise<void> {
await http.post(`/chats/${chatId}/leave`);
}

View File

@@ -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
</button>
) : null}
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
onClick={async () => {
try {
await banChatMember(chatId, memberCtx.member.user_id);
await refreshMembers(chatId);
} catch {
setError("Failed to ban member");
} finally {
setMemberCtx(null);
}
}}
type="button"
>
Ban from chat
</button>
) : null}
{(myRoleNormalized === "owner" || (myRoleNormalized === "admin" && memberCtx.member.role === "member")) ? (
<button
className="block w-full rounded px-2 py-1.5 text-left text-sm text-red-300 hover:bg-slate-800"
@@ -800,7 +819,7 @@ export function ChatInfoPanel({ chatId, open, onClose }: Props) {
}}
type="button"
>
Remove from chat
Remove from chat (without ban)
</button>
) : null}
<button