Skip to content

Commit

Permalink
Squashed commit of the following:
Browse files Browse the repository at this point in the history
commit 52ea784
Author: Usagi no Niku <[email protected]>
Date:   Thu Dec 7 13:39:56 2023 +0000

    fix: provide full replay

commit 921c9b3
Author: Usagi no Niku <[email protected]>
Date:   Thu Dec 7 12:52:46 2023 +0000

    fix: fix lost fields

commit 4034e4c
Author: Usagi no Niku <[email protected]>
Date:   Thu Dec 7 09:58:46 2023 +0000

    feat: include base anticheat methods

commit cfbe297
Author: CI <[email protected]>
Date:   Thu Dec 7 04:03:30 2023 +0000

    [CI] Generated new Pipfile.lock

commit 46934ed
Author: Usagi no Niku <[email protected]>
Date:   Thu Dec 7 11:46:35 2023 +0800

    feat: add orm based anticheat and foreign scores
  • Loading branch information
arily committed Dec 7, 2023
1 parent ae0b523 commit 55a1ecd
Show file tree
Hide file tree
Showing 12 changed files with 434 additions and 23 deletions.
3 changes: 3 additions & 0 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@
from . import packets
from . import state
from . import utils
from . import usecases

from .repositories.addition import orm_utils
47 changes: 26 additions & 21 deletions app/api/domains/osu.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
""" osu: handle connections from web, api, and beyond? """
from __future__ import annotations
import asyncio

import copy
import hashlib
Expand Down Expand Up @@ -67,7 +68,7 @@
from app.repositories import scores as scores_repo
from app.repositories import stats as stats_repo
from app.repositories.achievements import Achievement
from app.usecases import achievements as achievements_usecases
from app.usecases import achievements as achievements_usecases, anticheat
from app.usecases import user_achievements as user_achievements_usecases
from app.utils import escape_enum
from app.utils import pymysql_encode
Expand Down Expand Up @@ -650,7 +651,7 @@ async def osuSubmitModularSelector(
# through but ac'd if not found?
# TODO: validate token format
# TODO: save token in the database
token: str | None = Header(None), # ppysb feature: none when using ppysb client
token: str | None = Header(None), # ppysb feature: none when using ppysb client
# TODO: do ft & st contain pauses?
exited_out: bool = Form(..., alias="x"),
fail_time: int = Form(..., alias="ft"),
Expand Down Expand Up @@ -726,11 +727,12 @@ async def osuSubmitModularSelector(

try:
assert player.client_details is not None

bypass_client_check = player.client_details.osu_version.stream == OsuStream.PPYSB # ppysb feature


bypass_client_check = (
player.client_details.osu_version.stream == OsuStream.PPYSB
) # ppysb feature

if not bypass_client_check:

if osu_version != f"{player.client_details.osu_version.date:%Y%m%d}":
raise ValueError("osu! version mismatch")

Expand Down Expand Up @@ -763,7 +765,7 @@ async def osuSubmitModularSelector(
raise ValueError(
f"beatmap hash mismatch ({bmap_md5} != {updated_beatmap_hash})",
)

except (ValueError, AssertionError) as error:
if error.args.count == 1:
await player.restrict(
Expand Down Expand Up @@ -970,6 +972,10 @@ async def osuSubmitModularSelector(
if score.player.is_online:
score.player.logout()

# suspect the score after the replay file written

asyncio.ensure_future(anticheat.check_suspicion(player, score))

""" Update the user's & beatmap's stats """

# get the current stats, and take a
Expand Down Expand Up @@ -1501,14 +1507,14 @@ async def getScores(

if app.state.services.datadog:
app.state.services.datadog.increment("bancho.leaderboards_served")

### ppysb feature begin

# if bmap.status < RankedStatus.Ranked:
# only show leaderboards for ranked,
# approved, qualified, or loved maps.
# return f"{int(bmap.status)}|false".encode()
# only show leaderboards for ranked,
# approved, qualified, or loved maps.
# return f"{int(bmap.status)}|false".encode()

### ppysb feature end

# fetch scores & personal best
Expand All @@ -1532,9 +1538,9 @@ async def getScores(
rating = 0.0

## construct response for osu! client

### ppysb feature begin

response_status = bmap.status
if bmap.status < RankedStatus.Ranked:
response_status = RankedStatus.Approved
Expand All @@ -1547,7 +1553,7 @@ async def getScores(
# TODO: server side beatmap offsets
f"0\n{bmap.full_name}\n{rating}",
]

### ppysb feature end

if not score_rows:
Expand Down Expand Up @@ -1769,13 +1775,13 @@ async def get_screenshot(
path=screenshot_path,
media_type=app.utils.get_media_type(extension),
)



geo_cache: dict = {}



async def get_osz_url(
headers: Mapping[str, str],
beatmapset_id: str,
no_video: bool
headers: Mapping[str, str], beatmapset_id: str, no_video: bool
) -> str:
ip_address = app.state.services.ip_resolver.get_ip(headers)
geo_country = geo_cache.get(ip_address)
Expand All @@ -1789,7 +1795,6 @@ async def get_osz_url(
return f"https://dl.sayobot.cn/beatmaps/download/{prefix}/{beatmapset_id}"
query_str = f"{beatmapset_id}?n={int(not no_video)}"
return f"{app.settings.MIRROR_DOWNLOAD_ENDPOINT}/{query_str}"



@router.get("/d/{map_set_id}")
Expand Down
1 change: 1 addition & 0 deletions app/api/init_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ async def on_startup() -> None:
)

await app.state.services.database.connect()
await app.state.services.create_db_and_tables() # for sqlalchemy orm
await app.state.services.redis.initialize()

if app.state.services.datadog is not None:
Expand Down
55 changes: 55 additions & 0 deletions app/objects/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
from enum import unique
from pathlib import Path
from typing import TYPE_CHECKING
from app.repositories.addition.scores_suspicion import ScoresSuspicion

import app.state
import app.usecases.performance
import app.utils
from app import settings
from app.constants.clientflags import ClientFlags
from app.constants.gamemodes import GameMode
from app.constants.mods import Mods
Expand All @@ -19,14 +21,17 @@
from app.usecases.performance import ScoreParams
from app.utils import escape_enum
from app.utils import pymysql_encode
from circleguard import Circleguard, ReplayPath

if TYPE_CHECKING:
from app.objects.player import Player

__all__ = ("Grade", "SubmissionStatus", "Score")

BEATMAPS_PATH = Path.cwd() / ".data/osu"
REPLAYS_PATH = Path.cwd() / ".data/osr"

circleguard = Circleguard(settings.OSU_API_KEY)

@unique
class Grade(IntEnum):
Expand Down Expand Up @@ -455,3 +460,53 @@ async def increment_replay_views(self) -> None:
"WHERE id = :user_id AND mode = :mode",
{"user_id": self.player.id, "mode": self.mode},
)

async def save_suspicion(self, reason: str, detail: dict):
async with app.state.services.db_session() as session:
obj = ScoresSuspicion(score_id=self.id, suspicion_reason=reason, suspicion_time=datetime.now(), detail=detail)
await app.orm_utils.add_model(session, obj)

async def check_suspicion(self):
replay_path = REPLAYS_PATH / f"{self.id}.osr"

frametime_limition = 14
vanilla_ur_limition = 70
snaps_limition = 20

replay = ReplayPath(replay_path)
snaps = circleguard.snaps(replay)
frametime = circleguard.frametime(replay)
ur = circleguard.ur(replay)

detail = {
'beatmap': {
'title': self.bmap.title,
'bid': self.bmap.id,
'sid': self.bmap.set_id,
'md5': self.bmap.md5,
},
'score': {
'score_id': self.id,
'pp': self.pp,
'mode': repr(self.mode),
'mods': repr(self.mods)
},
'user': {
'user_id': self.player.id,
'username': self.player.full_name
},
'analysis': {
'frametime': frametime,
'ur': ur,
'snaps': len(snaps)
}
}

if frametime < frametime_limition:
await self.save_suspicion(f"timewarp cheating (frametime: {frametime:.2f}) / {frametime_limition})", detail)

if (not self.mods & Mods.RELAX) and ur < vanilla_ur_limition:
await self.save_suspicion(f"potential relax (ur: {ur:.2f} / {vanilla_ur_limition})", detail)

if len(snaps) > snaps_limition:
await self.save_suspicion(f"potential assist (snaps: {len(snaps):.2f} / {snaps_limition})", detail)
91 changes: 91 additions & 0 deletions app/repositories/addition/orm_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
from typing import Generic, TypeVar
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.engine import ScalarResult
from sqlalchemy import select, delete, func

V = TypeVar("V")

async def add_model(session: AsyncSession, obj: V) -> V:
session.add(obj)
await session.commit()
await session.refresh(obj)
return obj


async def merge_model(session: AsyncSession, obj: V) -> V:
obj = await session.merge(obj)
await session.commit()
await session.refresh(obj)
return obj


async def delete_model(session: AsyncSession, ident, model):
target = await session.get(model, ident)
await session.delete(target)
await session.flush()
await session.commit() # Ensure deletion were operated


async def delete_models(session: AsyncSession, obj: Generic[V], condition):
sentence = delete(obj).where(condition)
await session.execute(sentence)


async def get_model(session: AsyncSession, ident, model: Generic[V]):
return await session.get(model, ident)


def _build_select_sentence(obj: Generic[V], condition=None, offset=-1, limit=-1, order_by=None):
return _enlarge_sentence(select(obj), condition, offset, limit, order_by)


def _enlarge_sentence(base, condition=None, offset=-1, limit=-1, order_by=None):
if condition is not None:
base = base.where(condition)
if order_by is not None:
base = base.order_by(order_by)
if offset != -1:
base = base.offset(offset)
if limit != -1:
base = base.limit(limit)
return base


async def select_model(session: AsyncSession, obj: Generic[V], condition=None, offset=-1, limit=-1, order_by=None) -> V:
sentence = _build_select_sentence(obj, condition, offset, limit, order_by)
model = await session.scalar(sentence)
return model


async def query_model(session: AsyncSession, sentence, condition=None, offset=-1, limit=-1, order_by=None):
sentence = _enlarge_sentence(sentence, condition, offset, limit, order_by)
model = await session.scalar(sentence)
return model


async def select_models(session: AsyncSession, obj: Generic[V], condition=None, offset=-1, limit=-1, order_by=None) -> ScalarResult:
sentence = _build_select_sentence(obj, condition, offset, limit, order_by)
model = await session.scalars(sentence)
return model


async def query_models(session: AsyncSession, sentence, condition=None, offset=-1, limit=-1, order_by=None):
sentence = _enlarge_sentence(sentence, condition, offset, limit, order_by)
model = await session.scalars(sentence)
return model


async def select_models_count(session: AsyncSession, obj: Generic[V], condition=None, offset=-1, limit=-1, order_by=None) -> int:
sentence = _build_select_sentence(obj, condition, offset, limit, order_by)
sentence = sentence.with_only_columns(func.count(obj.id)).order_by(None)
model = await session.scalar(sentence)
return model


async def partial_update(session: AsyncSession, item: Generic[V], updates) -> V:
update_data = updates.dict(exclude_unset=True)
for key, value in update_data.items():
setattr(item, key, value)
await session.commit()
await session.refresh(item)
return item
13 changes: 13 additions & 0 deletions app/repositories/addition/scores_foreign.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from app.state.services import orm_base
from sqlalchemy import Column, Integer, String, Boolean, DateTime, BigInteger

class ScoresForeign(orm_base):
__tablename__ = "scores_foreign"

id = Column(BigInteger, primary_key=True)
server = Column(String(32), nullable=False)
original_score_id = Column(BigInteger, nullable=True)
original_player_id = Column(Integer, nullable=True)
recipient_id = Column(Integer, nullable=False)
has_replay = Column(Boolean, nullable=False)
receipt_time = Column(DateTime, nullable=False)
11 changes: 11 additions & 0 deletions app/repositories/addition/scores_suspicion.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from app.state.services import orm_base
from sqlalchemy import Column, BigInteger, String, Boolean, DateTime, JSON

class ScoresSuspicion(orm_base):
__tablename__ = "scores_suspicion"

score_id = Column(BigInteger, primary_key=True)
suspicion_reason = Column(String(128), nullable=False)
ignored = Column(Boolean, nullable=False, default=False)
detail = Column(JSON, nullable=True)
suspicion_time = Column(DateTime, nullable=False)
9 changes: 9 additions & 0 deletions app/repositories/addition/username_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from app.state.services import orm_base
from sqlalchemy import Column, Integer, String, DateTime, text

class UsernameHistory(orm_base):
__tablename__ = "username_history"

user_id = Column(Integer, primary_key=True)
change_date = Column(DateTime, primary_key=True, nullable=False)
username = Column(String(32), nullable=False)
1 change: 1 addition & 0 deletions app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
DB_PASS = os.environ["DB_PASS"]
DB_NAME = os.environ["DB_NAME"]
DB_DSN = f"mysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
DB_DSN_ASYNC = f"mysql+aiomysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"

REDIS_HOST = os.environ["REDIS_HOST"]
REDIS_PORT = int(os.environ["REDIS_PORT"])
Expand Down
Loading

0 comments on commit 55a1ecd

Please sign in to comment.