feat(user): implement user restrictions

## APIs Restricted for Restricted Users

A restricted user is blocked from performing the following actions, and will typically receive a `403 Forbidden` error:

*   **Chat & Notifications:**
    *   Sending any chat messages (public or private).
    *   Joining or leaving chat channels.
    *   Creating new PM channels.
*   **User Profile & Content:**
    *   Uploading a new avatar.
    *   Uploading a new profile cover image.
    *   Changing their username.
    *   Updating their userpage content.
*   **Scores & Gameplay:**
    *   Submitting scores in multiplayer rooms.
    *   Deleting their own scores (to prevent hiding evidence of cheating).
*   **Beatmaps:**
    *   Rating beatmaps.
    *   Taging beatmaps.
*   **Relationship:**
    *   Adding friends or blocking users.
    *   Removing friends or unblocking users.
*   **Teams:**
    *   Creating, updating, or deleting a team.
    *   Requesting to join a team.
    *   Handling join requests for a team they manage.
    *   Kicking a member from a team they manage.
*   **Multiplayer:**
    *   Creating or deleting multiplayer rooms.
    *   Joining or leaving multiplayer rooms.

## What is Invisible to Normal Users

*   **Leaderboards:**
    *   Beatmap leaderboards.
    *   Multiplayer (playlist) room leaderboards.
*   **User Search/Lists:**
    *   Restricted users will not appear in the results of the `/api/v2/users` endpoint.
    *   They will not appear in the list of a team's members.
*   **Relationship:**
    *   They will not appear in a user's friend list (`/friends`).
*   **Profile & History:**
    *   Attempting to view a restricted user's profile, events, kudosu history, or score history will result in a `404 Not Found` error, effectively making their profile invisible (unless the user viewing the profile is the restricted user themselves).
*   **Chat:**
    *   Normal users cannot start a new PM with a restricted user (they will get a `404 Not Found` error).
*   **Ranking:**
    *   Restricted users are excluded from any rankings.

### How to Restrict a User

Insert into `user_account_history` with `type=restriction`.

```sql
-- length is in seconds
INSERT INTO user_account_history (`description`, `length`, `permanent`, `timestamp`, `type`, `user_id`) VALUE ('some description', 86400, 0, '2025-10-05 01:00:00', 'RESTRICTION', 1);
```

---

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
MingxuanGame
2025-10-06 11:10:25 +08:00
committed by GitHub
parent d19f82df80
commit febc1d761f
25 changed files with 354 additions and 222 deletions

View File

@@ -90,6 +90,9 @@ async def join_channel(
user: Annotated[str, Path(..., description="用户 ID")],
current_user: Annotated[User, Security(get_current_user, scopes=["chat.write_manage"])],
):
if await current_user.is_restricted(session):
raise HTTPException(status_code=403, detail="You are restricted from sending messages")
# 使用明确的查询避免延迟加载
if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
@@ -114,6 +117,9 @@ async def leave_channel(
user: Annotated[str, Path(..., description="用户 ID")],
current_user: Annotated[User, Security(get_current_user, scopes=["chat.write_manage"])],
):
if await current_user.is_restricted(session):
raise HTTPException(status_code=403, detail="You are restricted from sending messages")
# 使用明确的查询避免延迟加载
if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
@@ -198,7 +204,7 @@ async def get_channel(
if int(id_) == current_user.id:
continue
target_user = await session.get(User, int(id_))
if target_user is None:
if target_user is None or await target_user.is_restricted(session):
raise HTTPException(status_code=404, detail="Target user not found")
users.extend([target_user, current_user])
break
@@ -249,9 +255,12 @@ async def create_channel(
current_user: Annotated[User, Security(get_current_user, scopes=["chat.write_manage"])],
redis: Redis,
):
if await current_user.is_restricted(session):
raise HTTPException(status_code=403, detail="You are restricted from sending messages")
if req.type == "PM":
target = await session.get(User, req.target_id)
if not target:
if not target or await target.is_restricted(session):
raise HTTPException(status_code=404, detail="Target user not found")
is_can_pm, block = await target.is_user_can_pm(current_user, session)
if not is_can_pm:

View File

@@ -11,7 +11,7 @@ from app.database.chat import (
UserSilenceResp,
)
from app.database.user import User
from app.dependencies.database import Database, Redis
from app.dependencies.database import Database, Redis, redis_message_client
from app.dependencies.param import BodyOrForm
from app.dependencies.user import get_current_user
from app.log import log
@@ -79,6 +79,9 @@ async def send_message(
req: Annotated[MessageReq, Depends(BodyOrForm(MessageReq))],
current_user: Annotated[User, Security(get_current_user, scopes=["chat.write"])],
):
if await current_user.is_restricted(session):
raise HTTPException(status_code=403, detail="You are restricted from sending messages")
# 使用明确的查询来获取 channel避免延迟加载
if channel.isdigit():
db_channel = (await session.exec(select(ChatChannel).where(ChatChannel.channel_id == int(channel)))).first()
@@ -97,9 +100,7 @@ async def send_message(
# 对于多人游戏房间在发送消息前进行Redis键检查
if channel_type == ChannelType.MULTIPLAYER:
try:
from app.dependencies.database import get_redis
redis = get_redis()
redis = redis_message_client
key = f"channel:{channel_id}:messages"
key_type = await redis.type(key)
if key_type not in ["none", "zset"]:
@@ -265,9 +266,12 @@ async def create_new_pm(
current_user: Annotated[User, Security(get_current_user, scopes=["chat.write"])],
redis: Redis,
):
if await current_user.is_restricted(session):
raise HTTPException(status_code=403, detail="You are restricted from sending messages")
user_id = current_user.id
target = await session.get(User, req.target_id)
if target is None:
if target is None or await target.is_restricted(session):
raise HTTPException(status_code=404, detail="Target user not found")
is_can_pm, block = await target.is_user_can_pm(current_user, session)
if not is_can_pm: