Files
g0v0-server/app/database/user.py
MingxuanGame febc1d761f 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>
2025-10-06 11:10:25 +08:00

537 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from datetime import datetime, timedelta
import json
from typing import TYPE_CHECKING, Literal, NotRequired, TypedDict, overload
from app.config import settings
from app.database.auth import TotpKeys
from app.models.model import UTCBaseModel
from app.models.score import GameMode
from app.models.user import Country, Page
from app.path import STATIC_DIR
from app.utils import utcnow
from .achievement import UserAchievement, UserAchievementResp
from .beatmap_playcounts import BeatmapPlaycounts
from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount
from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp
from .events import Event
from .rank_history import RankHistory, RankHistoryResp, RankTop
from .statistics import UserStatistics, UserStatisticsResp
from .team import Team, TeamMember
from .user_account_history import UserAccountHistory, UserAccountHistoryResp, UserAccountHistoryType
from pydantic import field_validator
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import Mapped
from sqlmodel import (
JSON,
BigInteger,
Column,
DateTime,
Field,
Relationship,
SQLModel,
col,
exists,
func,
select,
text,
)
from sqlmodel.ext.asyncio.session import AsyncSession
if TYPE_CHECKING:
from .favourite_beatmapset import FavouriteBeatmapset
from .relationship import RelationshipResp
class Kudosu(TypedDict):
available: int
total: int
class RankHighest(TypedDict):
rank: int
updated_at: datetime
class UserProfileCover(TypedDict):
url: str
custom_url: NotRequired[str]
id: NotRequired[str]
Badge = TypedDict(
"Badge",
{
"awarded_at": datetime,
"description": str,
"image@2x_url": str,
"image_url": str,
"url": str,
},
)
COUNTRIES = json.loads((STATIC_DIR / "iso3166.json").read_text())
class UserBase(UTCBaseModel, SQLModel):
avatar_url: str = "https://lazer-data.g0v0.top/default.jpg"
country_code: str = Field(default="CN", max_length=2, index=True)
# ? default_group: str|None
is_active: bool = True
is_bot: bool = False
is_supporter: bool = False
last_visit: datetime | None = Field(default_factory=utcnow, sa_column=Column(DateTime(timezone=True)))
pm_friends_only: bool = False
profile_colour: str | None = None
username: str = Field(max_length=32, unique=True, index=True)
page: Page = Field(sa_column=Column(JSON), default=Page(html="", raw=""))
previous_usernames: list[str] = Field(default_factory=list, sa_column=Column(JSON))
support_level: int = 0
badges: list[Badge] = Field(default_factory=list, sa_column=Column(JSON))
# optional
# blocks
cover: UserProfileCover = Field(
default=UserProfileCover(url=""),
sa_column=Column(JSON),
)
beatmap_playcounts_count: int = 0
# kudosu
# UserExtended
playmode: GameMode = GameMode.OSU
discord: str | None = None
has_supported: bool = False
interests: str | None = None
join_date: datetime = Field(default_factory=utcnow)
location: str | None = None
max_blocks: int = 50
max_friends: int = 500
occupation: str | None = None
playstyle: list[str] = Field(default_factory=list, sa_column=Column(JSON))
# TODO: post_count
profile_hue: int | None = None
profile_order: list[str] = Field(
default_factory=lambda: [
"me",
"recent_activity",
"top_ranks",
"medals",
"historical",
"beatmaps",
"kudosu",
],
sa_column=Column(JSON),
)
title: str | None = None
title_url: str | None = None
twitter: str | None = None
website: str | None = None
# undocumented
comments_count: int = 0
post_count: int = 0
is_admin: bool = False
is_gmt: bool = False
is_qat: bool = False
is_bng: bool = False
@field_validator("playmode", mode="before")
@classmethod
def validate_playmode(cls, v):
"""将字符串转换为 GameMode 枚举"""
if isinstance(v, str):
try:
return GameMode(v)
except ValueError:
# 如果转换失败,返回默认值
return GameMode.OSU
return v
class User(AsyncAttrs, UserBase, table=True):
__tablename__: str = "lazer_users"
id: int = Field(
default=None,
sa_column=Column(BigInteger, primary_key=True, autoincrement=True, index=True),
)
account_history: list[UserAccountHistory] = Relationship(back_populates="user")
statistics: list[UserStatistics] = Relationship(back_populates="user")
achievement: list[UserAchievement] = Relationship(back_populates="user")
team_membership: TeamMember | None = Relationship(back_populates="user")
daily_challenge_stats: DailyChallengeStats | None = Relationship(back_populates="user")
monthly_playcounts: list[MonthlyPlaycounts] = Relationship(back_populates="user")
replays_watched_counts: list[ReplayWatchedCount] = Relationship(back_populates="user")
favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship(back_populates="user")
rank_history: list[RankHistory] = Relationship(
back_populates="user",
)
events: list[Event] = Relationship(back_populates="user")
totp_key: TotpKeys | None = Relationship(back_populates="user")
email: str = Field(max_length=254, unique=True, index=True, exclude=True)
priv: int = Field(default=1, exclude=True)
pw_bcrypt: str = Field(max_length=60, exclude=True)
silence_end_at: datetime | None = Field(default=None, sa_column=Column(DateTime(timezone=True)), exclude=True)
donor_end_at: datetime | None = Field(default=None, sa_column=Column(DateTime(timezone=True)), exclude=True)
async def is_user_can_pm(self, from_user: "User", session: AsyncSession) -> tuple[bool, str]:
from .relationship import Relationship, RelationshipType
from_relationship = (
await session.exec(
select(Relationship).where(
Relationship.user_id == from_user.id,
Relationship.target_id == self.id,
)
)
).first()
if from_relationship and from_relationship.type == RelationshipType.BLOCK:
return False, "You have blocked the target user."
if from_user.pm_friends_only and (not from_relationship or from_relationship.type != RelationshipType.FOLLOW):
return (
False,
"You have disabled non-friend communications and target user is not your friend.",
)
relationship = (
await session.exec(
select(Relationship).where(
Relationship.user_id == self.id,
Relationship.target_id == from_user.id,
)
)
).first()
if relationship and relationship.type == RelationshipType.BLOCK:
return False, "Target user has blocked you."
if self.pm_friends_only and (not relationship or relationship.type != RelationshipType.FOLLOW):
return False, "Target user has disabled non-friend communications"
if await self.is_restricted(session):
return False, "Target user is restricted"
return True, ""
@classmethod
@overload
def is_restricted_query(cls, user_id: int): ...
@classmethod
@overload
def is_restricted_query(cls, user_id: Mapped[int]): ...
@classmethod
def is_restricted_query(cls, user_id: int | Mapped[int]):
return exists().where(
(col(UserAccountHistory.user_id) == user_id)
& (col(UserAccountHistory.type) == UserAccountHistoryType.RESTRICTION)
& (
(col(UserAccountHistory.permanent).is_(True))
| (
(
func.timestampadd(
text("SECOND"),
col(UserAccountHistory.length),
col(UserAccountHistory.timestamp),
)
> func.now()
)
& (func.now() > col(UserAccountHistory.timestamp))
)
),
)
async def is_restricted(self, session: AsyncSession) -> bool:
active_restrictions = (await session.exec(select(self.is_restricted_query(self.id)))).first()
return active_restrictions or False
class UserResp(UserBase):
id: int | None = None
is_online: bool = False
groups: list = [] # TODO
country: Country = Field(default_factory=lambda: Country(code="CN", name="China"))
favourite_beatmapset_count: int = 0
graveyard_beatmapset_count: int = 0 # TODO
guest_beatmapset_count: int = 0 # TODO
loved_beatmapset_count: int = 0 # TODO
mapping_follower_count: int = 0 # TODO
nominated_beatmapset_count: int = 0 # TODO
pending_beatmapset_count: int = 0 # TODO
ranked_beatmapset_count: int = 0 # TODO
follow_user_mapping: list[int] = Field(default_factory=list)
follower_count: int = 0
friends: list["RelationshipResp"] | None = None
scores_best_count: int = 0
scores_first_count: int = 0
scores_recent_count: int = 0
scores_pinned_count: int = 0
beatmap_playcounts_count: int = 0
account_history: list[UserAccountHistoryResp] = []
active_tournament_banners: list[dict] = [] # TODO
kudosu: Kudosu = Field(default_factory=lambda: Kudosu(available=0, total=0)) # TODO
monthly_playcounts: list[CountResp] = Field(default_factory=list)
replay_watched_counts: list[CountResp] = Field(default_factory=list)
unread_pm_count: int = 0 # TODO
rank_history: RankHistoryResp | None = None
rank_highest: RankHighest | None = None
statistics: UserStatisticsResp | None = None
statistics_rulesets: dict[str, UserStatisticsResp] | None = None
user_achievements: list[UserAchievementResp] = Field(default_factory=list)
cover_url: str = "" # deprecated
team: Team | None = None
daily_challenge_user_stats: DailyChallengeStatsResp | None = None
default_group: str = ""
is_deleted: bool = False # TODO
is_restricted: bool = False
# TODO: monthly_playcounts, unread_pm_count rank_history, user_preferences
@classmethod
async def from_db(
cls,
obj: User,
session: AsyncSession,
include: list[str] = [],
ruleset: GameMode | None = None,
) -> "UserResp":
from app.dependencies.database import get_redis
from .best_scores import BestScore
from .favourite_beatmapset import FavouriteBeatmapset
from .relationship import Relationship, RelationshipResp, RelationshipType
from .score import Score, get_user_first_score_count
from .total_score_best_scores import TotalScoreBestScore
ruleset = ruleset or obj.playmode
u = cls.model_validate(obj.model_dump())
u.id = obj.id
u.default_group = "bot" if u.is_bot else "default"
u.country = Country(code=obj.country_code, name=COUNTRIES.get(obj.country_code, "Unknown"))
u.follower_count = (
await session.exec(
select(func.count())
.select_from(Relationship)
.where(
Relationship.target_id == obj.id,
Relationship.type == RelationshipType.FOLLOW,
)
)
).one()
u.scores_best_count = (
await session.exec(
select(func.count())
.select_from(TotalScoreBestScore)
.where(
TotalScoreBestScore.user_id == obj.id,
)
.limit(200)
)
).one()
redis = get_redis()
u.is_online = bool(await redis.exists(f"metadata:online:{obj.id}"))
u.cover_url = obj.cover.get("url", "") if obj.cover else ""
if "friends" in include:
u.friends = [
await RelationshipResp.from_db(session, r)
for r in (
await session.exec(
select(Relationship).where(
Relationship.user_id == obj.id,
Relationship.type == RelationshipType.FOLLOW,
)
)
).all()
]
if "team" in include and (team_membership := await obj.awaitable_attrs.team_membership):
u.team = team_membership.team
if "account_history" in include:
u.account_history = [UserAccountHistoryResp.from_db(ah) for ah in await obj.awaitable_attrs.account_history]
if "daily_challenge_user_stats" in include and (
daily_challenge_stats := await obj.awaitable_attrs.daily_challenge_stats
):
u.daily_challenge_user_stats = DailyChallengeStatsResp.from_db(daily_challenge_stats)
if "statistics" in include:
current_stattistics = None
for i in await obj.awaitable_attrs.statistics:
if i.mode == ruleset:
current_stattistics = i
break
u.statistics = (
await UserStatisticsResp.from_db(current_stattistics, session, obj.country_code)
if current_stattistics
else None
)
if "statistics_rulesets" in include:
u.statistics_rulesets = {
i.mode.value: await UserStatisticsResp.from_db(i, session, obj.country_code)
for i in await obj.awaitable_attrs.statistics
}
if "monthly_playcounts" in include:
u.monthly_playcounts = [CountResp.from_db(pc) for pc in await obj.awaitable_attrs.monthly_playcounts]
if len(u.monthly_playcounts) == 1:
d = u.monthly_playcounts[0].start_date
u.monthly_playcounts.insert(0, CountResp(start_date=d - timedelta(days=20), count=0))
if "replays_watched_counts" in include:
u.replay_watched_counts = [
CountResp.from_db(rwc) for rwc in await obj.awaitable_attrs.replays_watched_counts
]
if len(u.replay_watched_counts) == 1:
d = u.replay_watched_counts[0].start_date
u.replay_watched_counts.insert(0, CountResp(start_date=d - timedelta(days=20), count=0))
if "achievements" in include:
u.user_achievements = [UserAchievementResp.from_db(ua) for ua in await obj.awaitable_attrs.achievement]
if "rank_history" in include:
rank_history = await RankHistoryResp.from_db(session, obj.id, ruleset)
if len(rank_history.data) != 0:
u.rank_history = rank_history
rank_top = (
await session.exec(select(RankTop).where(RankTop.user_id == obj.id, RankTop.mode == ruleset))
).first()
if rank_top:
u.rank_highest = (
RankHighest(
rank=rank_top.rank,
updated_at=datetime.combine(rank_top.date, datetime.min.time()),
)
if rank_top
else None
)
if "is_restricted" in include:
u.is_restricted = await obj.is_restricted(session)
u.favourite_beatmapset_count = (
await session.exec(
select(func.count()).select_from(FavouriteBeatmapset).where(FavouriteBeatmapset.user_id == obj.id)
)
).one()
u.scores_pinned_count = (
await session.exec(
select(func.count())
.select_from(Score)
.where(
Score.user_id == obj.id,
Score.pinned_order > 0,
Score.gamemode == ruleset,
col(Score.passed).is_(True),
)
)
).one()
u.scores_best_count = (
await session.exec(
select(func.count())
.select_from(BestScore)
.where(
BestScore.user_id == obj.id,
BestScore.gamemode == ruleset,
)
.limit(200)
)
).one()
u.scores_recent_count = (
await session.exec(
select(func.count())
.select_from(Score)
.where(
Score.user_id == obj.id,
Score.gamemode == ruleset,
col(Score.passed).is_(True),
Score.ended_at > utcnow() - timedelta(hours=24),
)
)
).one()
u.scores_first_count = await get_user_first_score_count(session, obj.id, ruleset)
u.beatmap_playcounts_count = (
await session.exec(
select(func.count())
.select_from(BeatmapPlaycounts)
.where(
BeatmapPlaycounts.user_id == obj.id,
)
)
).one()
return u
class MeResp(UserResp):
session_verification_method: Literal["totp", "mail"] | None = None
session_verified: bool = True
@classmethod
async def from_db(
cls,
obj: User,
session: AsyncSession,
ruleset: GameMode | None = None,
*,
token_id: int | None = None,
) -> "MeResp":
from app.dependencies.database import get_redis
from app.service.verification_service import LoginSessionService
u = await super().from_db(obj, session, ALL_INCLUDED, ruleset)
u.session_verified = (
not await LoginSessionService.check_is_need_verification(session, user_id=obj.id, token_id=token_id)
if token_id
else True
)
u = cls.model_validate(u.model_dump())
if (settings.enable_totp_verification or settings.enable_email_verification) and token_id:
redis = get_redis()
if not u.session_verified:
u.session_verification_method = await LoginSessionService.get_login_method(obj.id, token_id, redis)
else:
u.session_verification_method = None
return u
ALL_INCLUDED = [
"friends",
"team",
"account_history",
"daily_challenge_user_stats",
"statistics",
"statistics_rulesets",
"achievements",
"monthly_playcounts",
"replays_watched_counts",
"rank_history",
"is_restricted",
"session_verified",
]
SEARCH_INCLUDED = [
"team",
"daily_challenge_user_stats",
"statistics",
"statistics_rulesets",
"achievements",
"monthly_playcounts",
"replays_watched_counts",
"rank_history",
]
BASE_INCLUDES = [
"team",
"daily_challenge_user_stats",
"statistics",
]
RANKING_INCLUDES = [
"team",
"statistics",
]