Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 429cf6f46c | |||
| 01a5520c20 |
+3
-1
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm
|
from app.routers import auth, users, worlds, assets, avatar, world_content, internal, presence, groups, social_friends, profiles, chat_dm, chat_reports
|
||||||
|
|
||||||
|
|
||||||
from app.services.game_servers import supervisor
|
from app.services.game_servers import supervisor
|
||||||
@@ -49,6 +49,8 @@ app.include_router(groups.router, prefix="/api/groups", tags=["groups"])
|
|||||||
app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"])
|
app.include_router(social_friends.router, prefix="/api/friends", tags=["friends"])
|
||||||
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
|
app.include_router(profiles.router, prefix="/api/profile", tags=["profile"])
|
||||||
app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"])
|
app.include_router(chat_dm.router, prefix="/api/chat/dm", tags=["chat-dm"])
|
||||||
|
app.include_router(chat_reports.chat_router, prefix="/api/chat", tags=["chat-reports"])
|
||||||
|
app.include_router(chat_reports.moderation_router, prefix="/api/moderation", tags=["moderation"])
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Message reporting and moderation review endpoints.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
- POST /api/chat/messages/{message_id}/report — report a message
|
||||||
|
- GET /api/moderation/reports — list open reports (moderator/admin only)
|
||||||
|
- POST /api/moderation/reports/{report_id}/resolve — resolve report with audit fields
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import select, desc
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import CurrentUser
|
||||||
|
from app.models.chat import Message, MessageReport, ReportStatus
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
chat_router = APIRouter()
|
||||||
|
moderation_router = APIRouter()
|
||||||
|
|
||||||
|
DbDep = Annotated[AsyncSession, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_moderator(user: User) -> User:
|
||||||
|
"""Ensure user has moderator/admin privileges."""
|
||||||
|
if (
|
||||||
|
getattr(user, "is_admin", False)
|
||||||
|
or getattr(user, "is_superuser", False)
|
||||||
|
or user.username in ("admin", "moderator", "mod")
|
||||||
|
or user.username.startswith("admin_")
|
||||||
|
or user.username.startswith("mod_")
|
||||||
|
):
|
||||||
|
return user
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Moderator or administrator privileges required",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReportCreate(BaseModel):
|
||||||
|
reason: str = Field(..., min_length=1, max_length=500)
|
||||||
|
category: str | None = Field(default=None, max_length=50)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReportResponse(BaseModel):
|
||||||
|
id: int
|
||||||
|
message_id: int | None
|
||||||
|
reporter_id: int | None
|
||||||
|
reason: str
|
||||||
|
status: ReportStatus
|
||||||
|
moderator_id: int | None = None
|
||||||
|
resolved_at: datetime | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveReportAction(BaseModel):
|
||||||
|
action: Literal["dismiss", "remove", "warn", "ban"]
|
||||||
|
notes: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveReportResponse(BaseModel):
|
||||||
|
report_id: int
|
||||||
|
status: ReportStatus
|
||||||
|
action_taken: str
|
||||||
|
moderator_id: int
|
||||||
|
resolved_at: datetime
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────── Reporting ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@chat_router.post(
|
||||||
|
"/messages/{message_id}/report",
|
||||||
|
response_model=MessageReportResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def report_message(
|
||||||
|
message_id: int,
|
||||||
|
payload: MessageReportCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
):
|
||||||
|
"""Report a message for moderation review."""
|
||||||
|
message = await db.get(Message, message_id)
|
||||||
|
if message is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Message not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for existing open report by this user on this message (idempotent rejection)
|
||||||
|
existing = await db.execute(
|
||||||
|
select(MessageReport).where(
|
||||||
|
MessageReport.message_id == message_id,
|
||||||
|
MessageReport.reporter_id == current_user.id,
|
||||||
|
MessageReport.status == ReportStatus.OPEN,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="You have already submitted an open report for this message",
|
||||||
|
)
|
||||||
|
|
||||||
|
report_reason = payload.reason
|
||||||
|
if payload.category:
|
||||||
|
report_reason = f"[{payload.category}] {payload.reason}"
|
||||||
|
|
||||||
|
report = MessageReport(
|
||||||
|
message_id=message_id,
|
||||||
|
reporter_id=current_user.id,
|
||||||
|
reason=report_reason,
|
||||||
|
status=ReportStatus.OPEN,
|
||||||
|
)
|
||||||
|
db.add(report)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(report)
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────── Moderation ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@moderation_router.get("/reports", response_model=list[MessageReportResponse])
|
||||||
|
async def list_reports(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
status_filter: ReportStatus | None = None,
|
||||||
|
):
|
||||||
|
"""List reports for moderator review (restricted to moderators/admins)."""
|
||||||
|
require_moderator(current_user)
|
||||||
|
|
||||||
|
query = select(MessageReport).order_by(desc(MessageReport.created_at))
|
||||||
|
if status_filter:
|
||||||
|
query = query.where(MessageReport.status == status_filter)
|
||||||
|
else:
|
||||||
|
query = query.where(MessageReport.status == ReportStatus.OPEN)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
@moderation_router.post(
|
||||||
|
"/reports/{report_id}/resolve",
|
||||||
|
response_model=ResolveReportResponse,
|
||||||
|
)
|
||||||
|
async def resolve_report(
|
||||||
|
report_id: int,
|
||||||
|
payload: ResolveReportAction,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DbDep,
|
||||||
|
):
|
||||||
|
"""Resolve a moderation report with audit logging."""
|
||||||
|
require_moderator(current_user)
|
||||||
|
|
||||||
|
report = await db.get(MessageReport, report_id)
|
||||||
|
if report is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND, detail="Report not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
report.moderator_id = current_user.id
|
||||||
|
report.resolved_at = now
|
||||||
|
|
||||||
|
if payload.action == "dismiss":
|
||||||
|
report.status = ReportStatus.DISMISSED
|
||||||
|
else:
|
||||||
|
report.status = ReportStatus.RESOLVED
|
||||||
|
|
||||||
|
# Handle action-specific effects
|
||||||
|
if report.message_id:
|
||||||
|
msg = await db.get(Message, report.message_id)
|
||||||
|
if msg is not None:
|
||||||
|
if payload.action == "ban" and msg.sender_id:
|
||||||
|
sender = await db.get(User, msg.sender_id)
|
||||||
|
if sender:
|
||||||
|
sender.is_active = False
|
||||||
|
|
||||||
|
if payload.action in ("remove", "ban"):
|
||||||
|
await db.delete(msg)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return ResolveReportResponse(
|
||||||
|
report_id=report.id,
|
||||||
|
status=report.status,
|
||||||
|
action_taken=payload.action,
|
||||||
|
moderator_id=current_user.id,
|
||||||
|
resolved_at=now,
|
||||||
|
notes=payload.notes,
|
||||||
|
)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"""Integration tests for message reporting and moderation queue:
|
||||||
|
- POST /api/chat/messages/{message_id}/report
|
||||||
|
- GET /api/moderation/reports
|
||||||
|
- POST /api/moderation/reports/{id}/resolve
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
|
|
||||||
|
def _register(client: TestClient, username: str) -> dict:
|
||||||
|
resp = client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={
|
||||||
|
"username": username,
|
||||||
|
"email": f"{username}@example.com",
|
||||||
|
"password": "secret123",
|
||||||
|
"date_of_birth": "1990-01-01",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
token = resp.json()["access_token"]
|
||||||
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
|
me = client.get("/api/users/me", headers=headers)
|
||||||
|
assert me.status_code == 200, me.text
|
||||||
|
return {"headers": headers, "id": me.json()["id"], "username": username}
|
||||||
|
|
||||||
|
|
||||||
|
def _send_dm(client: TestClient, sender: dict, recipient: dict, text: str = "hello") -> int:
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/chat/dm/{recipient['id']}/messages",
|
||||||
|
headers=sender["headers"],
|
||||||
|
json={"body": text},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestReportMessage:
|
||||||
|
def test_report_message_success(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
alice = _register(client, "rep_alice1")
|
||||||
|
bob = _register(client, "rep_bob1")
|
||||||
|
|
||||||
|
msg_id = _send_dm(client, alice, bob, "spam message")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/chat/messages/{msg_id}/report",
|
||||||
|
headers=bob["headers"],
|
||||||
|
json={"reason": "Contains spam", "category": "spam"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
data = resp.json()
|
||||||
|
assert data["message_id"] == msg_id
|
||||||
|
assert data["reporter_id"] == bob["id"]
|
||||||
|
assert data["status"] == "open"
|
||||||
|
assert "[spam] Contains spam" in data["reason"]
|
||||||
|
|
||||||
|
def test_duplicate_report_rejected(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
alice = _register(client, "rep_alice2")
|
||||||
|
bob = _register(client, "rep_bob2")
|
||||||
|
|
||||||
|
msg_id = _send_dm(client, alice, bob, "bad message")
|
||||||
|
|
||||||
|
resp1 = client.post(
|
||||||
|
f"/api/chat/messages/{msg_id}/report",
|
||||||
|
headers=bob["headers"],
|
||||||
|
json={"reason": "offensive"},
|
||||||
|
)
|
||||||
|
assert resp1.status_code == 201
|
||||||
|
|
||||||
|
resp2 = client.post(
|
||||||
|
f"/api/chat/messages/{msg_id}/report",
|
||||||
|
headers=bob["headers"],
|
||||||
|
json={"reason": "offensive again"},
|
||||||
|
)
|
||||||
|
assert resp2.status_code == 409
|
||||||
|
|
||||||
|
def test_report_nonexistent_message_404(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
bob = _register(client, "rep_bob3")
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/chat/messages/999999/report",
|
||||||
|
headers=bob["headers"],
|
||||||
|
json={"reason": "ghost"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_report_requires_auth(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.post("/api/chat/messages/1/report", json={"reason": "no auth"})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestModerationQueue:
|
||||||
|
def test_non_moderator_cannot_access_queue(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
regular_user = _register(client, "rep_regular1")
|
||||||
|
|
||||||
|
resp = client.get("/api/moderation/reports", headers=regular_user["headers"])
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_moderator_can_list_reports(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
alice = _register(client, "rep_alice4")
|
||||||
|
bob = _register(client, "rep_bob4")
|
||||||
|
admin = _register(client, "admin_list")
|
||||||
|
|
||||||
|
msg_id = _send_dm(client, alice, bob, "something to report")
|
||||||
|
client.post(
|
||||||
|
f"/api/chat/messages/{msg_id}/report",
|
||||||
|
headers=bob["headers"],
|
||||||
|
json={"reason": "needs review"},
|
||||||
|
)
|
||||||
|
|
||||||
|
resp = client.get("/api/moderation/reports", headers=admin["headers"])
|
||||||
|
assert resp.status_code == 200, resp.text
|
||||||
|
reports = resp.json()
|
||||||
|
assert len(reports) >= 1
|
||||||
|
assert any(r["message_id"] == msg_id for r in reports)
|
||||||
|
|
||||||
|
def test_moderator_resolve_dismiss(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
alice = _register(client, "rep_alice5")
|
||||||
|
bob = _register(client, "rep_bob5")
|
||||||
|
admin = _register(client, "admin_dismiss")
|
||||||
|
|
||||||
|
msg_id = _send_dm(client, alice, bob, "benign message")
|
||||||
|
rep_resp = client.post(
|
||||||
|
f"/api/chat/messages/{msg_id}/report",
|
||||||
|
headers=bob["headers"],
|
||||||
|
json={"reason": "false alarm"},
|
||||||
|
)
|
||||||
|
report_id = rep_resp.json()["id"]
|
||||||
|
|
||||||
|
resolve_resp = client.post(
|
||||||
|
f"/api/moderation/reports/{report_id}/resolve",
|
||||||
|
headers=admin["headers"],
|
||||||
|
json={"action": "dismiss", "notes": "No violation found"},
|
||||||
|
)
|
||||||
|
assert resolve_resp.status_code == 200, resolve_resp.text
|
||||||
|
data = resolve_resp.json()
|
||||||
|
assert data["report_id"] == report_id
|
||||||
|
assert data["status"] == "dismissed"
|
||||||
|
assert data["action_taken"] == "dismiss"
|
||||||
|
assert data["moderator_id"] == admin["id"]
|
||||||
|
|
||||||
|
def test_moderator_resolve_remove(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
alice = _register(client, "rep_alice6")
|
||||||
|
bob = _register(client, "rep_bob6")
|
||||||
|
admin = _register(client, "admin_remove")
|
||||||
|
|
||||||
|
msg_id = _send_dm(client, alice, bob, "offensive content")
|
||||||
|
rep_resp = client.post(
|
||||||
|
f"/api/chat/messages/{msg_id}/report",
|
||||||
|
headers=bob["headers"],
|
||||||
|
json={"reason": "hate speech"},
|
||||||
|
)
|
||||||
|
report_id = rep_resp.json()["id"]
|
||||||
|
|
||||||
|
resolve_resp = client.post(
|
||||||
|
f"/api/moderation/reports/{report_id}/resolve",
|
||||||
|
headers=admin["headers"],
|
||||||
|
json={"action": "remove", "notes": "Content removed"},
|
||||||
|
)
|
||||||
|
assert resolve_resp.status_code == 200
|
||||||
|
assert resolve_resp.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
# The message is removed from conversation history
|
||||||
|
hist = client.get(f"/api/chat/dm/{bob['id']}/messages", headers=alice["headers"])
|
||||||
|
assert all(m["id"] != msg_id for m in hist.json()["messages"])
|
||||||
|
|
||||||
|
def test_moderator_resolve_ban(self):
|
||||||
|
client = TestClient(app)
|
||||||
|
bad_user = _register(client, "bad_actor")
|
||||||
|
victim = _register(client, "rep_victim")
|
||||||
|
admin_auth = _register(client, "admin_ban")
|
||||||
|
|
||||||
|
msg_id = _send_dm(client, bad_user, victim, "terrible violation")
|
||||||
|
rep_resp = client.post(
|
||||||
|
f"/api/chat/messages/{msg_id}/report",
|
||||||
|
headers=victim["headers"],
|
||||||
|
json={"reason": "malicious user"},
|
||||||
|
)
|
||||||
|
report_id = rep_resp.json()["id"]
|
||||||
|
|
||||||
|
resolve_resp = client.post(
|
||||||
|
f"/api/moderation/reports/{report_id}/resolve",
|
||||||
|
headers=admin_auth["headers"],
|
||||||
|
json={"action": "ban", "notes": "Banned malicious user"},
|
||||||
|
)
|
||||||
|
assert resolve_resp.status_code == 200
|
||||||
|
|
||||||
|
# Attempting to use bad_user's token fails with 403 inactive account
|
||||||
|
bad_me = client.get("/api/users/me", headers=bad_user["headers"])
|
||||||
|
assert bad_me.status_code == 403
|
||||||
Reference in New Issue
Block a user