diff --git a/app/api/__init__.py b/app/api/__init__.py index bbdf5d334..af178a204 100644 --- a/app/api/__init__.py +++ b/app/api/__init__.py @@ -3,13 +3,22 @@ from fastapi import APIRouter -from .v1 import apiv1_router -from .v2 import apiv2_router +from .bancho import bancho_router +from .redirect import redirect_router +from .rest.v1 import apiv1_router +from .rest.v2 import apiv2_router +from .static import static_router +from .web import web_router -api_router = APIRouter() +router = APIRouter() -api_router.include_router(apiv1_router) -api_router.include_router(apiv2_router) +router.include_router(bancho_router) +router.include_router(redirect_router) +router.include_router(static_router) +router.include_router(web_router) + +router.include_router(apiv1_router) +router.include_router(apiv2_router) from . import domains from . import init_api diff --git a/app/api/bancho/__init__.py b/app/api/bancho/__init__.py new file mode 100644 index 000000000..095fe0ac0 --- /dev/null +++ b/app/api/bancho/__init__.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import users + +bancho_router = APIRouter(tags=["Bancho"]) + +bancho_router.include_router(users.router) diff --git a/app/api/bancho/users.py b/app/api/bancho/users.py new file mode 100644 index 000000000..62b3a195e --- /dev/null +++ b/app/api/bancho/users.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +import copy +import hashlib +import random +import secrets +from collections import defaultdict +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Mapping +from enum import IntEnum +from enum import unique +from functools import cache +from pathlib import Path as SystemPath +from typing import Any +from typing import Literal +from urllib.parse import unquote +from urllib.parse import unquote_plus + +import bcrypt +from fastapi import status +from fastapi.datastructures import FormData +from fastapi.datastructures import UploadFile +from fastapi.exceptions import HTTPException +from fastapi.param_functions import Depends +from fastapi.param_functions import File +from fastapi.param_functions import Form +from fastapi.param_functions import Header +from fastapi.param_functions import Path +from fastapi.param_functions import Query +from fastapi.requests import Request +from fastapi.responses import FileResponse +from fastapi.responses import ORJSONResponse +from fastapi.responses import RedirectResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter +from starlette.datastructures import UploadFile as StarletteUploadFile + +import app.packets +import app.settings +import app.state +import app.utils +from app import encryption +from app._typing import UNSET +from app.api.web.authentication import authenticate_player_session +from app.constants import regexes +from app.constants.clientflags import LastFMFlags +from app.constants.gamemodes import GameMode +from app.constants.mods import Mods +from app.constants.privileges import Privileges +from app.logging import Ansi +from app.logging import log +from app.objects import models +from app.objects.beatmap import Beatmap +from app.objects.beatmap import RankedStatus +from app.objects.beatmap import ensure_osu_file_is_available +from app.objects.player import Player +from app.objects.score import Grade +from app.objects.score import Score +from app.objects.score import SubmissionStatus +from app.repositories import clans as clans_repo +from app.repositories import comments as comments_repo +from app.repositories import favourites as favourites_repo +from app.repositories import mail as mail_repo +from app.repositories import maps as maps_repo +from app.repositories import ratings as ratings_repo +from app.repositories import scores as scores_repo +from app.repositories import stats as stats_repo +from app.repositories import users as users_repo +from app.repositories.achievements import Achievement +from app.usecases import achievements as achievements_usecases +from app.usecases import user_achievements as user_achievements_usecases +from app.utils import escape_enum +from app.utils import pymysql_encode + +router = APIRouter() + + +INGAME_REGISTRATION_DISALLOWED_ERROR = { + "form_error": { + "user": { + "password": [ + "In-game registration is disabled. Please register on the website.", + ], + }, + }, +} + + +@router.post("/users") +async def register_account( + request: Request, + username: str = Form(..., alias="user[username]"), + email: str = Form(..., alias="user[user_email]"), + pw_plaintext: str = Form(..., alias="user[password]"), + check: int = Form(...), + # XXX: require/validate these headers; they are used later + # on in the registration process for resolving geolocation + forwarded_ip: str = Header(..., alias="X-Forwarded-For"), + real_ip: str = Header(..., alias="X-Real-IP"), +) -> Response: + if not all((username, email, pw_plaintext)): + return Response( + content=b"Missing required params", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # Disable in-game registration if enabled + if app.settings.DISALLOW_INGAME_REGISTRATION: + return ORJSONResponse( + content=INGAME_REGISTRATION_DISALLOWED_ERROR, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + # ensure all args passed + # are safe for registration. + errors: Mapping[str, list[str]] = defaultdict(list) + + # Usernames must: + # - be within 2-15 characters in length + # - not contain both ' ' and '_', one is fine + # - not be in the config's `disallowed_names` list + # - not already be taken by another player + if not regexes.USERNAME.match(username): + errors["username"].append("Must be 2-15 characters in length.") + + if "_" in username and " " in username: + errors["username"].append('May contain "_" and " ", but not both.') + + if username in app.settings.DISALLOWED_NAMES: + errors["username"].append("Disallowed username; pick another.") + + if "username" not in errors: + if await users_repo.fetch_one(name=username): + errors["username"].append("Username already taken by another player.") + + # Emails must: + # - match the regex `^[^@\s]{1,200}@[^@\s\.]{1,30}\.[^@\.\s]{1,24}$` + # - not already be taken by another player + if not regexes.EMAIL.match(email): + errors["user_email"].append("Invalid email syntax.") + else: + if await users_repo.fetch_one(email=email): + errors["user_email"].append("Email already taken by another player.") + + # Passwords must: + # - be within 8-32 characters in length + # - have more than 3 unique characters + # - not be in the config's `disallowed_passwords` list + if not 8 <= len(pw_plaintext) <= 32: + errors["password"].append("Must be 8-32 characters in length.") + + if len(set(pw_plaintext)) <= 3: + errors["password"].append("Must have more than 3 unique characters.") + + if pw_plaintext.lower() in app.settings.DISALLOWED_PASSWORDS: + errors["password"].append("That password was deemed too simple.") + + if errors: + # we have errors to send back, send them back delimited by newlines. + errors = {k: ["\n".join(v)] for k, v in errors.items()} + errors_full = {"form_error": {"user": errors}} + return ORJSONResponse( + content=errors_full, + status_code=status.HTTP_400_BAD_REQUEST, + ) + + if check == 0: + # the client isn't just checking values, + # they want to register the account now. + # make the md5 & bcrypt the md5 for sql. + pw_md5 = hashlib.md5(pw_plaintext.encode()).hexdigest().encode() + pw_bcrypt = bcrypt.hashpw(pw_md5, bcrypt.gensalt()) + app.state.cache.bcrypt[pw_bcrypt] = pw_md5 # cache result for login + + ip = app.state.services.ip_resolver.get_ip(request.headers) + + geoloc = await app.state.services.fetch_geoloc(ip, request.headers) + country = geoloc["country"]["acronym"] if geoloc is not None else "XX" + + async with app.state.services.database.transaction(): + # add to `users` table. + player = await users_repo.create( + name=username, + email=email, + pw_bcrypt=pw_bcrypt, + country=country, + ) + + # add to `stats` table. + await stats_repo.create_all_modes(player_id=player["id"]) + + if app.state.services.datadog: + app.state.services.datadog.increment("bancho.registrations") # type: ignore[no-untyped-call] + + log(f"<{username} ({player['id']})> has registered!", Ansi.LGREEN) + + return Response(content=b"ok") # success diff --git a/app/api/domains/__init__.py b/app/api/domains/__init__.py index dc62e05ff..80bbdf738 100644 --- a/app/api/domains/__init__.py +++ b/app/api/domains/__init__.py @@ -2,4 +2,3 @@ from . import cho from . import map -from . import osu diff --git a/app/api/domains/osu.py b/app/api/domains/osu.py deleted file mode 100644 index c6d400a38..000000000 --- a/app/api/domains/osu.py +++ /dev/null @@ -1,1778 +0,0 @@ -"""osu: handle connections from web, api, and beyond?""" - -from __future__ import annotations - -import copy -import hashlib -import random -import secrets -from collections import defaultdict -from collections.abc import Awaitable -from collections.abc import Callable -from collections.abc import Mapping -from enum import IntEnum -from enum import unique -from functools import cache -from pathlib import Path as SystemPath -from typing import Any -from typing import Literal -from urllib.parse import unquote -from urllib.parse import unquote_plus - -import bcrypt -from fastapi import status -from fastapi.datastructures import FormData -from fastapi.datastructures import UploadFile -from fastapi.exceptions import HTTPException -from fastapi.param_functions import Depends -from fastapi.param_functions import File -from fastapi.param_functions import Form -from fastapi.param_functions import Header -from fastapi.param_functions import Path -from fastapi.param_functions import Query -from fastapi.requests import Request -from fastapi.responses import FileResponse -from fastapi.responses import ORJSONResponse -from fastapi.responses import RedirectResponse -from fastapi.responses import Response -from fastapi.routing import APIRouter -from starlette.datastructures import UploadFile as StarletteUploadFile - -import app.packets -import app.settings -import app.state -import app.utils -from app import encryption -from app._typing import UNSET -from app.constants import regexes -from app.constants.clientflags import LastFMFlags -from app.constants.gamemodes import GameMode -from app.constants.mods import Mods -from app.constants.privileges import Privileges -from app.logging import Ansi -from app.logging import log -from app.objects import models -from app.objects.beatmap import Beatmap -from app.objects.beatmap import RankedStatus -from app.objects.beatmap import ensure_osu_file_is_available -from app.objects.player import Player -from app.objects.score import Grade -from app.objects.score import Score -from app.objects.score import SubmissionStatus -from app.repositories import clans as clans_repo -from app.repositories import comments as comments_repo -from app.repositories import favourites as favourites_repo -from app.repositories import mail as mail_repo -from app.repositories import maps as maps_repo -from app.repositories import ratings as ratings_repo -from app.repositories import scores as scores_repo -from app.repositories import stats as stats_repo -from app.repositories import users as users_repo -from app.repositories.achievements import Achievement -from app.usecases import achievements as achievements_usecases -from app.usecases import user_achievements as user_achievements_usecases -from app.utils import escape_enum -from app.utils import pymysql_encode - -BEATMAPS_PATH = SystemPath.cwd() / ".data/osu" -REPLAYS_PATH = SystemPath.cwd() / ".data/osr" -SCREENSHOTS_PATH = SystemPath.cwd() / ".data/ss" - - -router = APIRouter( - tags=["osu! web API"], - default_response_class=Response, -) - - -@cache -def authenticate_player_session( - param_function: Callable[..., Any], - username_alias: str = "u", - pw_md5_alias: str = "p", - err: Any | None = None, -) -> Callable[[str, str], Awaitable[Player]]: - async def wrapper( - username: str = param_function(..., alias=username_alias), - pw_md5: str = param_function(..., alias=pw_md5_alias), - ) -> Player: - player = await app.state.sessions.players.from_login( - name=unquote(username), - pw_md5=pw_md5, - ) - if player: - return player - - # player login incorrect - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail=err, - ) - - return wrapper - - -""" /web/ handlers """ - -# Unhandled endpoints: -# POST /web/osu-error.php -# POST /web/osu-session.php -# POST /web/osu-osz2-bmsubmit-post.php -# POST /web/osu-osz2-bmsubmit-upload.php -# GET /web/osu-osz2-bmsubmit-getid.php -# GET /web/osu-get-beatmap-topic.php - - -@router.post("/web/osu-screenshot.php") -async def osuScreenshot( - player: Player = Depends(authenticate_player_session(Form, "u", "p")), - endpoint_version: int = Form(..., alias="v"), - screenshot_file: UploadFile = File(..., alias="ss"), -) -> Response: - with memoryview(await screenshot_file.read()) as screenshot_view: - # png sizes: 1080p: ~300-800kB | 4k: ~1-2mB - if len(screenshot_view) > (4 * 1024 * 1024): - return Response( - content=b"Screenshot file too large.", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - if endpoint_version != 1: - await app.state.services.log_strange_occurrence( - f"Incorrect endpoint version (/web/osu-screenshot.php v{endpoint_version})", - ) - - if app.utils.has_jpeg_headers_and_trailers(screenshot_view): - extension = "jpeg" - elif app.utils.has_png_headers_and_trailers(screenshot_view): - extension = "png" - else: - return Response( - content=b"Invalid file type", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - while True: - filename = f"{secrets.token_urlsafe(6)}.{extension}" - ss_file = SCREENSHOTS_PATH / filename - if not ss_file.exists(): - break - - with ss_file.open("wb") as f: - f.write(screenshot_view) - - log(f"{player} uploaded {filename}.") - return Response(filename.encode()) - - -@router.get("/web/osu-getfriends.php") -async def osuGetFriends( - player: Player = Depends(authenticate_player_session(Query, "u", "h")), -) -> Response: - return Response("\n".join(map(str, player.friends)).encode()) - - -def bancho_to_osuapi_status(bancho_status: int) -> int: - return { - 0: 0, - 2: 1, - 3: 2, - 4: 3, - 5: 4, - }[bancho_status] - - -@router.post("/web/osu-getbeatmapinfo.php") -async def osuGetBeatmapInfo( - form_data: models.OsuBeatmapRequestForm, - player: Player = Depends(authenticate_player_session(Query, "u", "h")), -) -> Response: - num_requests = len(form_data.Filenames) + len(form_data.Ids) - log(f"{player} requested info for {num_requests} maps.", Ansi.LCYAN) - - response_lines: list[str] = [] - - for idx, map_filename in enumerate(form_data.Filenames): - # try getting the map from sql - - beatmap = await maps_repo.fetch_one(filename=map_filename) - - if not beatmap: - continue - - # try to get the user's grades on the map - # NOTE: osu! only allows us to send back one per gamemode, - # so we've decided to send back *vanilla* grades. - # (in theory we could make this user-customizable) - grades = ["N", "N", "N", "N"] - - for score in await scores_repo.fetch_many( - map_md5=beatmap["md5"], - user_id=player.id, - mode=player.status.mode.as_vanilla, - status=SubmissionStatus.BEST, - ): - grades[score["mode"]] = score["grade"] - - response_lines.append( - "{i}|{id}|{set_id}|{md5}|{status}|{grades}".format( - i=idx, - id=beatmap["id"], - set_id=beatmap["set_id"], - md5=beatmap["md5"], - status=bancho_to_osuapi_status(beatmap["status"]), - grades="|".join(grades), - ), - ) - - if form_data.Ids: # still have yet to see this used - await app.state.services.log_strange_occurrence( - f"{player} requested map(s) info by id ({form_data.Ids})", - ) - - return Response("\n".join(response_lines).encode()) - - -@router.get("/web/osu-getfavourites.php") -async def osuGetFavourites( - player: Player = Depends(authenticate_player_session(Query, "u", "h")), -) -> Response: - favourites = await favourites_repo.fetch_all(userid=player.id) - - return Response( - "\n".join([str(favourite["setid"]) for favourite in favourites]).encode(), - ) - - -@router.get("/web/osu-addfavourite.php") -async def osuAddFavourite( - player: Player = Depends(authenticate_player_session(Query, "u", "h")), - map_set_id: int = Query(..., alias="a"), -) -> Response: - # check if they already have this favourited. - if await favourites_repo.fetch_one(player.id, map_set_id): - return Response(b"You've already favourited this beatmap!") - - # add favourite - await favourites_repo.create( - userid=player.id, - setid=map_set_id, - ) - - return Response(b"Added favourite!") - - -@router.get("/web/lastfm.php") -async def lastFM( - action: Literal["scrobble", "np"], - beatmap_id_or_hidden_flag: str = Query( - ..., - description=( - "This flag is normally a beatmap ID, but is also " - "used as a hidden anticheat flag within osu!" - ), - alias="b", - ), - player: Player = Depends(authenticate_player_session(Query, "us", "ha")), -) -> Response: - if beatmap_id_or_hidden_flag[0] != "a": - # not anticheat related, tell the - # client not to send any more for now. - return Response(b"-3") - - flags = LastFMFlags(int(beatmap_id_or_hidden_flag[1:])) - - if flags & (LastFMFlags.HQ_ASSEMBLY | LastFMFlags.HQ_FILE): - # Player is currently running hq!osu; could possibly - # be a separate client, buuuut prooobably not lol. - - await player.restrict( - admin=app.state.sessions.bot, - reason=f"hq!osu running ({flags})", - ) - - # refresh their client state - if player.is_online: - player.logout() - - return Response(b"-3") - - if flags & LastFMFlags.REGISTRY_EDITS: - # Player has registry edits left from - # hq!osu's multiaccounting tool. This - # does not necessarily mean they are - # using it now, but they have in the past. - - if random.randrange(32) == 0: - # Random chance (1/32) for a ban. - await player.restrict( - admin=app.state.sessions.bot, - reason="hq!osu relife 1/32", - ) - - # refresh their client state - if player.is_online: - player.logout() - - return Response(b"-3") - - player.enqueue( - app.packets.notification( - "\n".join( - [ - "Hey!", - "It appears you have hq!osu's multiaccounting tool (relife) enabled.", - "This tool leaves a change in your registry that the osu! client can detect.", - "Please re-install relife and disable the program to avoid any restrictions.", - ], - ), - ), - ) - - player.logout() - - return Response(b"-3") - - """ These checks only worked for ~5 hours from release. rumoi's quick! - if flags & ( - LastFMFlags.SDL2_LIBRARY - | LastFMFlags.OPENSSL_LIBRARY - | LastFMFlags.AQN_MENU_SAMPLE - ): - # AQN has been detected in the client, either - # through the 'libeay32.dll' library being found - # onboard, or from the menu sound being played in - # the AQN menu while being in an inappropriate menu - # for the context of the sound effect. - pass - """ - - return Response(b"") - - -DIRECT_SET_INFO_FMTSTR = ( - "{SetID}.osz|{Artist}|{Title}|{Creator}|" - "{RankedStatus}|10.0|{LastUpdate}|{SetID}|" - "0|{HasVideo}|0|0|0|{diffs}" # 0s are threadid, has_story, - # filesize, filesize_novid. -) - -DIRECT_MAP_INFO_FMTSTR = ( - "[{DifficultyRating:.2f}⭐] {DiffName} " - "{{cs: {CS} / od: {OD} / ar: {AR} / hp: {HP}}}@{Mode}" -) - - -@router.get("/web/osu-search.php") -async def osuSearchHandler( - player: Player = Depends(authenticate_player_session(Query, "u", "h")), - ranked_status: int = Query(..., alias="r", ge=0, le=8), - query: str = Query(..., alias="q"), - mode: int = Query(..., alias="m", ge=-1, le=3), # -1 for all - page_num: int = Query(..., alias="p"), -) -> Response: - params: dict[str, Any] = {"amount": 100, "offset": page_num * 100} - - # eventually we could try supporting these, - # but it mostly depends on the mirror. - if query not in ("Newest", "Top+Rated", "Most+Played"): - params["query"] = query - - if mode != -1: # -1 for all - params["mode"] = mode - - if ranked_status != 4: # 4 for all - # convert to osu!api status - params["status"] = RankedStatus.from_osudirect(ranked_status).osu_api - - response = await app.state.services.http_client.get( - app.settings.MIRROR_SEARCH_ENDPOINT, - params=params, - ) - if response.status_code != status.HTTP_200_OK: - return Response(b"-1\nFailed to retrieve data from the beatmap mirror.") - - result = response.json() - - lresult = len(result) # send over 100 if we receive - # 100 matches, so the client - # knows there are more to get - ret = [f"{'101' if lresult == 100 else lresult}"] - for bmapset in result: - if bmapset["ChildrenBeatmaps"] is None: - continue - - # some mirrors use a true/false instead of 0 or 1 - bmapset["HasVideo"] = int(bmapset["HasVideo"]) - - diff_sorted_maps = sorted( - bmapset["ChildrenBeatmaps"], - key=lambda m: m["DifficultyRating"], - ) - - def handle_invalid_characters(s: str) -> str: - # XXX: this is a bug that exists on official servers (lmao) - # | is used to delimit the set data, so the difficulty name - # cannot contain this or it will be ignored. we fix it here - # by using a different character. - return s.replace("|", "I") - - diffs_str = ",".join( - [ - DIRECT_MAP_INFO_FMTSTR.format( - DifficultyRating=row["DifficultyRating"], - DiffName=handle_invalid_characters(row["DiffName"]), - CS=row["CS"], - OD=row["OD"], - AR=row["AR"], - HP=row["HP"], - Mode=row["Mode"], - ) - for row in diff_sorted_maps - ], - ) - - ret.append( - DIRECT_SET_INFO_FMTSTR.format( - Artist=handle_invalid_characters(bmapset["Artist"]), - Title=handle_invalid_characters(bmapset["Title"]), - Creator=bmapset["Creator"], - RankedStatus=bmapset["RankedStatus"], - LastUpdate=bmapset["LastUpdate"], - SetID=bmapset["SetID"], - HasVideo=bmapset["HasVideo"], - diffs=diffs_str, - ), - ) - - return Response("\n".join(ret).encode()) - - -# TODO: video support (needs db change) -@router.get("/web/osu-search-set.php") -async def osuSearchSetHandler( - player: Player = Depends(authenticate_player_session(Query, "u", "h")), - map_set_id: int | None = Query(None, alias="s"), - map_id: int | None = Query(None, alias="b"), - checksum: str | None = Query(None, alias="c"), -) -> Response: - # Since we only need set-specific data, we can basically - # just do same query with either bid or bsid. - - v: int | str - if map_set_id is not None: - # this is just a normal request - k, v = ("set_id", map_set_id) - elif map_id is not None: - k, v = ("id", map_id) - elif checksum is not None: - k, v = ("md5", checksum) - else: - return Response(b"") # invalid args - - # Get all set data. - bmapset = await app.state.services.database.fetch_one( - "SELECT DISTINCT set_id, artist, " - "title, status, creator, last_update " - f"FROM maps WHERE {k} = :v", - {"v": v}, - ) - if bmapset is None: - # TODO: get from osu! - return Response(b"") - - rating = 10.0 # TODO: real data - - return Response( - ( - "{set_id}.osz|{artist}|{title}|{creator}|" - "{status}|{rating:.1f}|{last_update}|{set_id}|" - "0|0|0|0|0" - ) - .format(**bmapset, rating=rating) - .encode(), - ) - # 0s are threadid, has_vid, has_story, filesize, filesize_novid - - -def chart_entry(name: str, before: float | None, after: float | None) -> str: - return f"{name}Before:{before or ''}|{name}After:{after or ''}" - - -def format_achievement_string(file: str, name: str, description: str) -> str: - return f"{file}+{name}+{description}" - - -def parse_form_data_score_params( - score_data: FormData, -) -> tuple[bytes, StarletteUploadFile] | None: - """Parse the score data, and replay file - from the form data's 'score' parameters.""" - try: - score_parts = score_data.getlist("score") - assert len(score_parts) == 2, "Invalid score data" - - score_data_b64 = score_data.getlist("score")[0] - assert isinstance(score_data_b64, str), "Invalid score data" - replay_file = score_data.getlist("score")[1] - assert isinstance(replay_file, StarletteUploadFile), "Invalid replay data" - except AssertionError as exc: - log(f"Failed to validate score multipart data: ({exc.args[0]})", Ansi.LRED) - return None - else: - return ( - score_data_b64.encode(), - replay_file, - ) - - -@router.post("/web/osu-submit-modular-selector.php") -async def osuSubmitModularSelector( - request: Request, - # TODO: should token be allowed - # through but ac'd if not found? - # TODO: validate token format - # TODO: save token in the database - token: str = Header(...), - # TODO: do ft & st contain pauses? - exited_out: bool = Form(..., alias="x"), - fail_time: int = Form(..., alias="ft"), - visual_settings_b64: bytes = Form(..., alias="fs"), - updated_beatmap_hash: str = Form(..., alias="bmk"), - storyboard_md5: str | None = Form(None, alias="sbk"), - iv_b64: bytes = Form(..., alias="iv"), - unique_ids: str = Form(..., alias="c1"), - score_time: int = Form(..., alias="st"), - pw_md5: str = Form(..., alias="pass"), - osu_version: str = Form(..., alias="osuver"), - client_hash_b64: bytes = Form(..., alias="s"), - fl_cheat_screenshot: bytes | None = File(None, alias="i"), -) -> Response: - """Handle a score submission from an osu! client with an active session.""" - - if fl_cheat_screenshot: - stacktrace = app.utils.get_appropriate_stacktrace() - await app.state.services.log_strange_occurrence(stacktrace) - - # NOTE: the bancho protocol uses the "score" parameter name for both - # the base64'ed score data, and the replay file in the multipart - # starlette/fastapi do not support this, so we've moved it out - score_parameters = parse_form_data_score_params(await request.form()) - if score_parameters is None: - return Response(b"") - - # extract the score data and replay file from the score data - score_data_b64, replay_file = score_parameters - - # decrypt the score data (aes) - score_data, client_hash_decoded = encryption.decrypt_score_aes_data( - score_data_b64, - client_hash_b64, - iv_b64, - osu_version, - ) - - # fetch map & player - - bmap_md5 = score_data[0] - bmap = await Beatmap.from_md5(bmap_md5) - if not bmap: - # Map does not exist, most likely unsubmitted. - return Response(b"error: beatmap") - - # if the client has supporter, a space is appended - # but usernames may also end with a space, which must be preserved - username = score_data[1] - if username[-1] == " ": - username = username[:-1] - - player = await app.state.sessions.players.from_login(username, pw_md5) - if not player: - # Player is not online, return nothing so that their - # client will retry submission when they log in. - return Response(b"") - - # parse the score from the remaining data - score = Score.from_submission(score_data[2:]) - - # attach bmap & player - score.bmap = bmap - score.player = player - - ## perform checksum validation - - unique_id1, unique_id2 = unique_ids.split("|", maxsplit=1) - unique_id1_md5 = hashlib.md5(unique_id1.encode()).hexdigest() - unique_id2_md5 = hashlib.md5(unique_id2.encode()).hexdigest() - - try: - assert player.client_details is not None - - if osu_version != f"{player.client_details.osu_version.date:%Y%m%d}": - raise ValueError("osu! version mismatch") - - if client_hash_decoded != player.client_details.client_hash: - raise ValueError("client hash mismatch") - # assert unique ids (c1) are correct and match login params - if unique_id1_md5 != player.client_details.uninstall_md5: - raise ValueError( - f"unique_id1 mismatch ({unique_id1_md5} != {player.client_details.uninstall_md5})", - ) - - if unique_id2_md5 != player.client_details.disk_signature_md5: - raise ValueError( - f"unique_id2 mismatch ({unique_id2_md5} != {player.client_details.disk_signature_md5})", - ) - - # assert online checksums match - server_score_checksum = score.compute_online_checksum( - osu_version=osu_version, - osu_client_hash=client_hash_decoded, - storyboard_checksum=storyboard_md5 or "", - ) - if score.client_checksum != server_score_checksum: - raise ValueError( - f"online score checksum mismatch ({server_score_checksum} != {score.client_checksum})", - ) - - # assert beatmap hashes match - if bmap_md5 != updated_beatmap_hash: - raise ValueError( - f"beatmap hash mismatch ({bmap_md5} != {updated_beatmap_hash})", - ) - - except (ValueError, AssertionError): - # NOTE: this is undergoing a temporary trial period, - # after which, it will be enabled & perform restrictions. - stacktrace = app.utils.get_appropriate_stacktrace() - await app.state.services.log_strange_occurrence(stacktrace) - - # await player.restrict( - # admin=app.state.sessions.bot, - # reason="mismatching hashes on score submission", - # ) - - # refresh their client state - # if player.online: - # player.logout() - - # return b"error: ban" - - # we should update their activity no matter - # what the result of the score submission is. - score.player.update_latest_activity_soon() - - # make sure the player's client displays the correct mode's stats - if score.mode != score.player.status.mode: - score.player.status.mods = score.mods - score.player.status.mode = score.mode - - if not score.player.restricted: - app.state.sessions.players.enqueue(app.packets.user_stats(score.player)) - - # hold a lock around (check if submitted, submission) to ensure no duplicates - # are submitted to the database, and potentially award duplicate score/pp/etc. - async with app.state.score_submission_locks[score.client_checksum]: - # stop here if this is a duplicate score - if await app.state.services.database.fetch_one( - "SELECT 1 FROM scores WHERE online_checksum = :checksum", - {"checksum": score.client_checksum}, - ): - log(f"{score.player} submitted a duplicate score.", Ansi.LYELLOW) - return Response(b"error: no") - - # all data read from submission. - # now we can calculate things based on our data. - score.acc = score.calculate_accuracy() - - osu_file_available = await ensure_osu_file_is_available( - bmap.id, - expected_md5=bmap.md5, - ) - if osu_file_available: - score.pp, score.sr = score.calculate_performance(bmap.id) - - if score.passed: - await score.calculate_status() - - if score.bmap.status != RankedStatus.Pending: - score.rank = await score.calculate_placement() - else: - score.status = SubmissionStatus.FAILED - - score.time_elapsed = score_time if score.passed else fail_time - - # TODO: re-implement pp caps for non-whitelisted players? - - """ Score submission checks completed; submit the score. """ - - if app.state.services.datadog: - app.state.services.datadog.increment("bancho.submitted_scores") # type: ignore[no-untyped-call] - - if score.status == SubmissionStatus.BEST: - if app.state.services.datadog: - app.state.services.datadog.increment("bancho.submitted_scores_best") # type: ignore[no-untyped-call] - - if score.bmap.has_leaderboard: - if score.bmap.status == RankedStatus.Loved and score.mode in ( - GameMode.VANILLA_OSU, - GameMode.VANILLA_TAIKO, - GameMode.VANILLA_CATCH, - GameMode.VANILLA_MANIA, - ): - performance = f"{score.score:,} score" - else: - performance = f"{score.pp:,.2f}pp" - - score.player.enqueue( - app.packets.notification( - f"You achieved #{score.rank}! ({performance})", - ), - ) - - if score.rank == 1 and not score.player.restricted: - announce_chan = app.state.sessions.channels.get_by_name("#announce") - - ann = [ - f"\x01ACTION achieved #1 on {score.bmap.embed}", - f"with {score.acc:.2f}% for {performance}.", - ] - - if score.mods: - ann.insert(1, f"+{score.mods!r}") - - scoring_metric = ( - "pp" if score.mode >= GameMode.RELAX_OSU else "score" - ) - - # If there was previously a score on the map, add old #1. - prev_n1 = await app.state.services.database.fetch_one( - "SELECT u.id, name FROM users u " - "INNER JOIN scores s ON u.id = s.userid " - "WHERE s.map_md5 = :map_md5 AND s.mode = :mode " - "AND s.status = 2 AND u.priv & 1 " - f"ORDER BY s.{scoring_metric} DESC LIMIT 1", - {"map_md5": score.bmap.md5, "mode": score.mode}, - ) - - if prev_n1: - if score.player.id != prev_n1["id"]: - ann.append( - f"(Previous #1: [https://{app.settings.DOMAIN}/u/" - "{id} {name}])".format( - id=prev_n1["id"], - name=prev_n1["name"], - ), - ) - - assert announce_chan is not None - announce_chan.send(" ".join(ann), sender=score.player, to_self=True) - - # this score is our best score. - # update any preexisting personal best - # records with SubmissionStatus.SUBMITTED. - await app.state.services.database.execute( - "UPDATE scores SET status = 1 " - "WHERE status = 2 AND map_md5 = :map_md5 " - "AND userid = :user_id AND mode = :mode", - { - "map_md5": score.bmap.md5, - "user_id": score.player.id, - "mode": score.mode, - }, - ) - - score.id = await app.state.services.database.execute( - "INSERT INTO scores " - "VALUES (NULL, " - ":map_md5, :score, :pp, :acc, " - ":max_combo, :mods, :n300, :n100, " - ":n50, :nmiss, :ngeki, :nkatu, " - ":grade, :status, :mode, :play_time, " - ":time_elapsed, :client_flags, :user_id, :perfect, " - ":checksum)", - { - "map_md5": score.bmap.md5, - "score": score.score, - "pp": score.pp, - "acc": score.acc, - "max_combo": score.max_combo, - "mods": score.mods, - "n300": score.n300, - "n100": score.n100, - "n50": score.n50, - "nmiss": score.nmiss, - "ngeki": score.ngeki, - "nkatu": score.nkatu, - "grade": score.grade.name, - "status": score.status, - "mode": score.mode, - "play_time": score.server_time, - "time_elapsed": score.time_elapsed, - "client_flags": score.client_flags, - "user_id": score.player.id, - "perfect": score.perfect, - "checksum": score.client_checksum, - }, - ) - - if score.passed: - replay_data = await replay_file.read() - - MIN_REPLAY_SIZE = 24 - - if len(replay_data) >= MIN_REPLAY_SIZE: - replay_disk_file = REPLAYS_PATH / f"{score.id}.osr" - replay_disk_file.write_bytes(replay_data) - else: - log(f"{score.player} submitted a score without a replay!", Ansi.LRED) - - if not score.player.restricted: - await score.player.restrict( - admin=app.state.sessions.bot, - reason="submitted score with no replay", - ) - if score.player.is_online: - score.player.logout() - - """ Update the user's & beatmap's stats """ - - # get the current stats, and take a - # shallow copy for the response charts. - stats = score.player.stats[score.mode] - prev_stats = copy.copy(stats) - - # stuff update for all submitted scores - stats.playtime += score.time_elapsed // 1000 - stats.plays += 1 - stats.tscore += score.score - stats.total_hits += score.n300 + score.n100 + score.n50 - - if score.mode.as_vanilla in (1, 3): - # taiko uses geki & katu for hitting big notes with 2 keys - # mania uses geki & katu for rainbow 300 & 200 - stats.total_hits += score.ngeki + score.nkatu - - stats_updates: dict[str, Any] = { - "plays": stats.plays, - "playtime": stats.playtime, - "tscore": stats.tscore, - "total_hits": stats.total_hits, - } - - if score.passed and score.bmap.has_leaderboard: - # player passed & map is ranked, approved, or loved. - - if score.max_combo > stats.max_combo: - stats.max_combo = score.max_combo - stats_updates["max_combo"] = stats.max_combo - - if score.bmap.awards_ranked_pp and score.status == SubmissionStatus.BEST: - # map is ranked or approved, and it's our (new) - # best score on the map. update the player's - # ranked score, grades, pp, acc and global rank. - - additional_rscore = score.score - if score.prev_best: - # we previously had a score, so remove - # it's score from our ranked score. - additional_rscore -= score.prev_best.score - - if score.grade != score.prev_best.grade: - if score.grade >= Grade.A: - stats.grades[score.grade] += 1 - grade_col = format(score.grade, "stats_column") - stats_updates[grade_col] = stats.grades[score.grade] - - if score.prev_best.grade >= Grade.A: - stats.grades[score.prev_best.grade] -= 1 - grade_col = format(score.prev_best.grade, "stats_column") - stats_updates[grade_col] = stats.grades[score.prev_best.grade] - else: - # this is our first submitted score on the map - if score.grade >= Grade.A: - stats.grades[score.grade] += 1 - grade_col = format(score.grade, "stats_column") - stats_updates[grade_col] = stats.grades[score.grade] - - stats.rscore += additional_rscore - stats_updates["rscore"] = stats.rscore - - # fetch scores sorted by pp for total acc/pp calc - # NOTE: we select all plays (and not just top100) - # because bonus pp counts the total amount of ranked - # scores. I'm aware this scales horribly, and it'll - # likely be split into two queries in the future. - best_scores = await app.state.services.database.fetch_all( - "SELECT s.pp, s.acc FROM scores s " - "INNER JOIN maps m ON s.map_md5 = m.md5 " - "WHERE s.userid = :user_id AND s.mode = :mode " - "AND s.status = 2 AND m.status IN (2, 3) " # ranked, approved - "ORDER BY s.pp DESC", - {"user_id": score.player.id, "mode": score.mode}, - ) - - # calculate new total weighted accuracy - weighted_acc = sum( - row["acc"] * 0.95**i for i, row in enumerate(best_scores) - ) - bonus_acc = 100.0 / (20 * (1 - 0.95 ** len(best_scores))) - stats.acc = (weighted_acc * bonus_acc) / 100 - stats_updates["acc"] = stats.acc - - # calculate new total weighted pp - weighted_pp = sum(row["pp"] * 0.95**i for i, row in enumerate(best_scores)) - bonus_pp = 416.6667 * (1 - 0.9994 ** len(best_scores)) - stats.pp = round(weighted_pp + bonus_pp) - stats_updates["pp"] = stats.pp - - # update global & country ranking - stats.rank = await score.player.update_rank(score.mode) - - await stats_repo.partial_update( - score.player.id, - score.mode.value, - plays=stats_updates.get("plays", UNSET), - playtime=stats_updates.get("playtime", UNSET), - tscore=stats_updates.get("tscore", UNSET), - total_hits=stats_updates.get("total_hits", UNSET), - max_combo=stats_updates.get("max_combo", UNSET), - xh_count=stats_updates.get("xh_count", UNSET), - x_count=stats_updates.get("x_count", UNSET), - sh_count=stats_updates.get("sh_count", UNSET), - s_count=stats_updates.get("s_count", UNSET), - a_count=stats_updates.get("a_count", UNSET), - rscore=stats_updates.get("rscore", UNSET), - acc=stats_updates.get("acc", UNSET), - pp=stats_updates.get("pp", UNSET), - ) - - if not score.player.restricted: - # enqueue new stats info to all other users - app.state.sessions.players.enqueue(app.packets.user_stats(score.player)) - - # update beatmap with new stats - score.bmap.plays += 1 - if score.passed: - score.bmap.passes += 1 - - await app.state.services.database.execute( - "UPDATE maps SET plays = :plays, passes = :passes WHERE md5 = :map_md5", - { - "plays": score.bmap.plays, - "passes": score.bmap.passes, - "map_md5": score.bmap.md5, - }, - ) - - # update their recent score - score.player.recent_scores[score.mode] = score - - """ score submission charts """ - - # charts are only displayed for passes vanilla gamemodes. - if not score.passed: # TODO: check if this is correct - response = b"error: no" - else: - # construct and send achievements & ranking charts to the client - if score.bmap.awards_ranked_pp and not score.player.restricted: - unlocked_achievements: list[Achievement] = [] - - server_achievements = await achievements_usecases.fetch_many() - player_achievements = await user_achievements_usecases.fetch_many( - user_id=score.player.id, - ) - - for server_achievement in server_achievements: - player_unlocked_achievement = any( - player_achievement - for player_achievement in player_achievements - if player_achievement["achid"] == server_achievement["id"] - ) - if player_unlocked_achievement: - # player already has this achievement. - continue - - achievement_condition = server_achievement["cond"] - if achievement_condition(score, score.mode.as_vanilla): - await user_achievements_usecases.create( - score.player.id, - server_achievement["id"], - ) - unlocked_achievements.append(server_achievement) - - achievements_str = "/".join( - format_achievement_string(a["file"], a["name"], a["desc"]) - for a in unlocked_achievements - ) - else: - achievements_str = "" - - # create score submission charts for osu! client to display - - if score.prev_best: - beatmap_ranking_chart_entries = ( - chart_entry("rank", score.prev_best.rank, score.rank), - chart_entry("rankedScore", score.prev_best.score, score.score), - chart_entry("totalScore", score.prev_best.score, score.score), - chart_entry("maxCombo", score.prev_best.max_combo, score.max_combo), - chart_entry( - "accuracy", - round(score.prev_best.acc, 2), - round(score.acc, 2), - ), - chart_entry("pp", score.prev_best.pp, score.pp), - ) - else: - # no previous best score - beatmap_ranking_chart_entries = ( - chart_entry("rank", None, score.rank), - chart_entry("rankedScore", None, score.score), - chart_entry("totalScore", None, score.score), - chart_entry("maxCombo", None, score.max_combo), - chart_entry("accuracy", None, round(score.acc, 2)), - chart_entry("pp", None, score.pp), - ) - - overall_ranking_chart_entries = ( - chart_entry("rank", prev_stats.rank, stats.rank), - chart_entry("rankedScore", prev_stats.rscore, stats.rscore), - chart_entry("totalScore", prev_stats.tscore, stats.tscore), - chart_entry("maxCombo", prev_stats.max_combo, stats.max_combo), - chart_entry("accuracy", round(prev_stats.acc, 2), round(stats.acc, 2)), - chart_entry("pp", prev_stats.pp, stats.pp), - ) - - submission_charts = [ - # beatmap info chart - f"beatmapId:{score.bmap.id}", - f"beatmapSetId:{score.bmap.set_id}", - f"beatmapPlaycount:{score.bmap.plays}", - f"beatmapPasscount:{score.bmap.passes}", - f"approvedDate:{score.bmap.last_update}", - "\n", - # beatmap ranking chart - "chartId:beatmap", - f"chartUrl:{score.bmap.set.url}", - "chartName:Beatmap Ranking", - *beatmap_ranking_chart_entries, - f"onlineScoreId:{score.id}", - "\n", - # overall ranking chart - "chartId:overall", - f"chartUrl:https://{app.settings.DOMAIN}/u/{score.player.id}", - "chartName:Overall Ranking", - *overall_ranking_chart_entries, - f"achievements-new:{achievements_str}", - ] - - response = "|".join(submission_charts).encode() - - log( - f"[{score.mode!r}] {score.player} submitted a score! " - f"({score.status!r}, {score.pp:,.2f}pp / {stats.pp:,}pp)", - Ansi.LGREEN, - ) - - return Response(response) - - -@router.get("/web/osu-getreplay.php") -async def getReplay( - player: Player = Depends(authenticate_player_session(Query, "u", "h")), - mode: int = Query(..., alias="m", ge=0, le=3), - score_id: int = Query(..., alias="c", min=0, max=9_223_372_036_854_775_807), -) -> Response: - score = await Score.from_sql(score_id) - if not score: - return Response(b"", status_code=404) - - file = REPLAYS_PATH / f"{score_id}.osr" - if not file.exists(): - return Response(b"", status_code=404) - - # increment replay views for this score - if score.player is not None and player.id != score.player.id: - app.state.loop.create_task(score.increment_replay_views()) # type: ignore[unused-awaitable] - - return FileResponse(file) - - -@router.get("/web/osu-rate.php") -async def osuRate( - player: Player = Depends( - authenticate_player_session(Query, "u", "p", err=b"auth fail"), - ), - map_md5: str = Query(..., alias="c", min_length=32, max_length=32), - rating: int | None = Query(None, alias="v", ge=1, le=10), -) -> Response: - if rating is None: - # check if we have the map in our cache; - # if not, the map probably doesn't exist. - if map_md5 not in app.state.cache.beatmap: - return Response(b"no exist") - - cached = app.state.cache.beatmap[map_md5] - - # only allow rating on maps with a leaderboard. - if cached.status < RankedStatus.Ranked: - return Response(b"not ranked") - - # osu! client is checking whether we can rate the map or not. - # the client hasn't rated the map, so simply - # tell them that they can submit a rating. - if not await ratings_repo.fetch_one(map_md5=map_md5, userid=player.id): - return Response(b"ok") - else: - # the client is submitting a rating for the map. - await ratings_repo.create(userid=player.id, map_md5=map_md5, rating=rating) - - map_ratings = await ratings_repo.fetch_many(map_md5=map_md5) - ratings = [row["rating"] for row in map_ratings] - - # send back the average rating - avg = sum(ratings) / len(ratings) - return Response(f"alreadyvoted\n{avg}".encode()) - - -@unique -@pymysql_encode(escape_enum) -class LeaderboardType(IntEnum): - Local = 0 - Top = 1 - Mods = 2 - Friends = 3 - Country = 4 - - -async def get_leaderboard_scores( - leaderboard_type: LeaderboardType | int, - map_md5: str, - mode: int, - mods: Mods, - player: Player, - scoring_metric: Literal["pp", "score"], -) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: - query = [ - f"SELECT s.id, s.{scoring_metric} AS _score, " - "s.max_combo, s.n50, s.n100, s.n300, " - "s.nmiss, s.nkatu, s.ngeki, s.perfect, s.mods, " - "UNIX_TIMESTAMP(s.play_time) time, u.id userid, " - "COALESCE(CONCAT('[', c.tag, '] ', u.name), u.name) AS name " - "FROM scores s " - "INNER JOIN users u ON u.id = s.userid " - "LEFT JOIN clans c ON c.id = u.clan_id " - "WHERE s.map_md5 = :map_md5 AND s.status = 2 " # 2: =best score - "AND (u.priv & 1 OR u.id = :user_id) AND mode = :mode", - ] - - params: dict[str, Any] = { - "map_md5": map_md5, - "user_id": player.id, - "mode": mode, - } - - if leaderboard_type == LeaderboardType.Mods: - query.append("AND s.mods = :mods") - params["mods"] = mods - elif leaderboard_type == LeaderboardType.Friends: - query.append("AND s.userid IN :friends") - params["friends"] = player.friends | {player.id} - elif leaderboard_type == LeaderboardType.Country: - query.append("AND u.country = :country") - params["country"] = player.geoloc["country"]["acronym"] - - # TODO: customizability of the number of scores - query.append("ORDER BY _score DESC LIMIT 50") - - score_rows = await app.state.services.database.fetch_all( - " ".join(query), - params, - ) - - if score_rows: # None or [] - # fetch player's personal best score - personal_best_score_row = await app.state.services.database.fetch_one( - f"SELECT id, {scoring_metric} AS _score, " - "max_combo, n50, n100, n300, " - "nmiss, nkatu, ngeki, perfect, mods, " - "UNIX_TIMESTAMP(play_time) time " - "FROM scores " - "WHERE map_md5 = :map_md5 AND mode = :mode " - "AND userid = :user_id AND status = 2 " - "ORDER BY _score DESC LIMIT 1", - {"map_md5": map_md5, "mode": mode, "user_id": player.id}, - ) - - if personal_best_score_row is not None: - # calculate the rank of the score. - p_best_rank = 1 + await app.state.services.database.fetch_val( - "SELECT COUNT(*) FROM scores s " - "INNER JOIN users u ON u.id = s.userid " - "WHERE s.map_md5 = :map_md5 AND s.mode = :mode " - "AND s.status = 2 AND u.priv & 1 " - f"AND s.{scoring_metric} > :score", - { - "map_md5": map_md5, - "mode": mode, - "score": personal_best_score_row["_score"], - }, - column=0, # COUNT(*) - ) - - # attach rank to personal best row - personal_best_score_row["rank"] = p_best_rank - else: - score_rows = [] - personal_best_score_row = None - - return score_rows, personal_best_score_row - - -SCORE_LISTING_FMTSTR = ( - "{id}|{name}|{score}|{max_combo}|" - "{n50}|{n100}|{n300}|{nmiss}|{nkatu}|{ngeki}|" - "{perfect}|{mods}|{userid}|{rank}|{time}|{has_replay}" -) - - -@router.get("/web/osu-osz2-getscores.php") -async def getScores( - player: Player = Depends(authenticate_player_session(Query, "us", "ha")), - requesting_from_editor_song_select: bool = Query(..., alias="s"), - leaderboard_version: int = Query(..., alias="vv"), - leaderboard_type: int = Query(..., alias="v", ge=0, le=4), - map_md5: str = Query(..., alias="c", min_length=32, max_length=32), - map_filename: str = Query(..., alias="f"), - mode_arg: int = Query(..., alias="m", ge=0, le=3), - map_set_id: int = Query(..., alias="i", ge=-1, le=2_147_483_647), - mods_arg: int = Query(..., alias="mods", ge=0, le=2_147_483_647), - map_package_hash: str = Query(..., alias="h"), # TODO: further validation - aqn_files_found: bool = Query(..., alias="a"), -) -> Response: - if aqn_files_found: - stacktrace = app.utils.get_appropriate_stacktrace() - await app.state.services.log_strange_occurrence(stacktrace) - - # check if this md5 has already been cached as - # unsubmitted/needs update to reduce osu!api spam - if map_md5 in app.state.cache.unsubmitted: - return Response(b"-1|false") - if map_md5 in app.state.cache.needs_update: - return Response(b"1|false") - - if mods_arg & Mods.RELAX: - if mode_arg == 3: # rx!mania doesn't exist - mods_arg &= ~Mods.RELAX - else: - mode_arg += 4 - elif mods_arg & Mods.AUTOPILOT: - if mode_arg in (1, 2, 3): # ap!catch, taiko and mania don't exist - mods_arg &= ~Mods.AUTOPILOT - else: - mode_arg += 8 - - mods = Mods(mods_arg) - mode = GameMode(mode_arg) - - # attempt to update their stats if their - # gm/gm-affecting-mods change at all. - if mode != player.status.mode: - player.status.mods = mods - player.status.mode = mode - - if not player.restricted: - app.state.sessions.players.enqueue(app.packets.user_stats(player)) - - scoring_metric: Literal["pp", "score"] = ( - "pp" if mode >= GameMode.RELAX_OSU else "score" - ) - - bmap = await Beatmap.from_md5(map_md5, set_id=map_set_id) - has_set_id = map_set_id > 0 - - if not bmap: - # map not found, figure out whether it needs an - # update or isn't submitted using its filename. - - if has_set_id and map_set_id not in app.state.cache.beatmapset: - # set not cached, it doesn't exist - app.state.cache.unsubmitted.add(map_md5) - return Response(b"-1|false") - - map_filename = unquote_plus(map_filename) # TODO: is unquote needed? - - map_exists = False - if has_set_id: - # we can look it up in the specific set from cache - for bmap in app.state.cache.beatmapset[map_set_id].maps: - if map_filename == bmap.filename: - map_exists = True - break - else: - map_exists = False - else: - # we can't find it on the osu!api by md5, - # and we don't have the set id, so we must - # look it up in sql from the filename. - map_exists = ( - await maps_repo.fetch_one( - filename=map_filename, - ) - is not None - ) - - if map_exists: - # map can be updated. - app.state.cache.needs_update.add(map_md5) - return Response(b"1|false") - else: - # map is unsubmitted. - # add this map to the unsubmitted cache, so - # that we don't have to make this request again. - app.state.cache.unsubmitted.add(map_md5) - return Response(b"-1|false") - - # we've found a beatmap for the request. - - if app.state.services.datadog: - app.state.services.datadog.increment("bancho.leaderboards_served") # type: ignore[no-untyped-call] - - if bmap.status < RankedStatus.Ranked: - # only show leaderboards for ranked, - # approved, qualified, or loved maps. - return Response(f"{int(bmap.status)}|false".encode()) - - # fetch scores & personal best - # TODO: create a leaderboard cache - if not requesting_from_editor_song_select: - score_rows, personal_best_score_row = await get_leaderboard_scores( - leaderboard_type, - bmap.md5, - mode, - mods, - player, - scoring_metric, - ) - else: - score_rows = [] - personal_best_score_row = None - - # fetch beatmap rating - map_ratings = await ratings_repo.fetch_many( - map_md5=bmap.md5, - page=None, - page_size=None, - ) - ratings = [row["rating"] for row in map_ratings] - map_avg_rating = sum(ratings) / len(ratings) if ratings else 0.0 - - ## construct response for osu! client - - response_lines: list[str] = [ - # NOTE: fa stands for featured artist (for the ones that may not know) - # {ranked_status}|{serv_has_osz2}|{bid}|{bsid}|{len(scores)}|{fa_track_id}|{fa_license_text} - f"{int(bmap.status)}|false|{bmap.id}|{bmap.set_id}|{len(score_rows)}|0|", - # {offset}\n{beatmap_name}\n{rating} - # TODO: server side beatmap offsets - f"0\n{bmap.full_name}\n{map_avg_rating}", - ] - - if not score_rows: - response_lines.extend(("", "")) # no scores, no personal best - return Response("\n".join(response_lines).encode()) - - if personal_best_score_row is not None: - user_clan = ( - await clans_repo.fetch_one(id=player.clan_id) - if player.clan_id is not None - else None - ) - display_name = ( - f"[{user_clan['tag']}] {player.name}" - if user_clan is not None - else player.name - ) - response_lines.append( - SCORE_LISTING_FMTSTR.format( - **personal_best_score_row, - name=display_name, - userid=player.id, - score=int(round(personal_best_score_row["_score"])), - has_replay="1", - ), - ) - else: - response_lines.append("") - - response_lines.extend( - [ - SCORE_LISTING_FMTSTR.format( - **s, - score=int(round(s["_score"])), - has_replay="1", - rank=idx + 1, - ) - for idx, s in enumerate(score_rows) - ], - ) - - return Response("\n".join(response_lines).encode()) - - -@router.post("/web/osu-comment.php") -async def osuComment( - player: Player = Depends(authenticate_player_session(Form, "u", "p")), - map_id: int = Form(..., alias="b"), - map_set_id: int = Form(..., alias="s"), - score_id: int = Form(..., alias="r", ge=0, le=9_223_372_036_854_775_807), - mode_vn: int = Form(..., alias="m", ge=0, le=3), - action: Literal["get", "post"] = Form(..., alias="a"), - # only sent for post - target: Literal["song", "map", "replay"] | None = Form(None), - colour: str | None = Form(None, alias="f", min_length=6, max_length=6), - start_time: int | None = Form(None, alias="starttime"), - comment: str | None = Form(None, min_length=1, max_length=80), -) -> Response: - if action == "get": - # client is requesting all comments - comments = await comments_repo.fetch_all_relevant_to_replay( - score_id=score_id, - map_set_id=map_set_id, - map_id=map_id, - ) - - ret: list[str] = [] - - for cmt in comments: - # note: this implementation does not support - # "player" or "creator" comment colours - if cmt["priv"] & Privileges.NOMINATOR: - fmt = "bat" - elif cmt["priv"] & Privileges.DONATOR: - fmt = "supporter" - else: - fmt = "" - - if cmt["colour"]: - fmt += f'|{cmt["colour"]}' - - ret.append( - "{time}\t{target_type}\t{fmt}\t{comment}".format(fmt=fmt, **cmt), - ) - - player.update_latest_activity_soon() - return Response("\n".join(ret).encode()) - - elif action == "post": - # client is submitting a new comment - - # validate all required params are provided - assert target is not None - assert start_time is not None - assert comment is not None - - # get the corresponding id from the request - if target == "song": - target_id = map_set_id - elif target == "map": - target_id = map_id - else: # target == "replay" - target_id = score_id - - if colour and not player.priv & Privileges.DONATOR: - # only supporters can use colours. - colour = None - - log( - f"User {player} attempted to use a coloured comment without " - "supporter status. Submitting comment without a colour.", - ) - - # insert into sql - await comments_repo.create( - target_id=target_id, - target_type=comments_repo.TargetType(target), - userid=player.id, - time=start_time, - comment=comment, - colour=colour, - ) - - player.update_latest_activity_soon() - - return Response(b"") # empty resp is fine - - -@router.get("/web/osu-markasread.php") -async def osuMarkAsRead( - player: Player = Depends(authenticate_player_session(Query, "u", "h")), - channel: str = Query(..., min_length=0, max_length=32), -) -> Response: - target_name = unquote(channel) # TODO: unquote needed? - if not target_name: - log( - f"User {player} attempted to mark a channel as read without a target.", - Ansi.LYELLOW, - ) - return Response(b"") # no channel specified - - target = await app.state.sessions.players.from_cache_or_sql(name=target_name) - if target: - # mark any unread mail from this user as read. - await mail_repo.mark_conversation_as_read( - to_id=player.id, - from_id=target.id, - ) - - return Response(b"") - - -@router.get("/web/osu-getseasonal.php") -async def osuSeasonal() -> Response: - return ORJSONResponse(app.settings.SEASONAL_BGS) - - -@router.get("/web/bancho_connect.php") -async def banchoConnect( - # NOTE: this is disabled as this endpoint can be called - # before a player has been granted a session - # player: Player = Depends(authenticate_player_session(Query, "u", "h")), - osu_ver: str = Query(..., alias="v"), - active_endpoint: str | None = Query(None, alias="fail"), - net_framework_vers: str | None = Query(None, alias="fx"), # delimited by | - client_hash: str | None = Query(None, alias="ch"), - retrying: bool | None = Query(None, alias="retry"), # '0' or '1' -) -> Response: - return Response(b"") - - -@router.get("/web/check-updates.php") -async def checkUpdates( - request: Request, - action: Literal["check", "path", "error"], - stream: Literal["cuttingedge", "stable40", "beta40", "stable"], -) -> Response: - return Response(b"") - - -""" Misc handlers """ - - -if app.settings.REDIRECT_OSU_URLS: - # NOTE: this will likely be removed with the addition of a frontend. - async def osu_redirect(request: Request, _: int = Path(...)) -> Response: - return RedirectResponse( - url=f"https://osu.ppy.sh{request['path']}", - status_code=status.HTTP_301_MOVED_PERMANENTLY, - ) - - for pattern in ( - "/beatmapsets/{_}", - "/beatmaps/{_}", - "/beatmapsets/{_}/discussion", - "/community/forums/topics/{_}", - ): - router.get(pattern)(osu_redirect) - - -@router.get("/ss/{screenshot_id}.{extension}") -async def get_screenshot( - screenshot_id: str = Path(..., pattern=r"[a-zA-Z0-9-_]{8}"), - extension: Literal["jpg", "jpeg", "png"] = Path(...), -) -> Response: - """Serve a screenshot from the server, by filename.""" - screenshot_path = SCREENSHOTS_PATH / f"{screenshot_id}.{extension}" - - if not screenshot_path.exists(): - return ORJSONResponse( - content={"status": "Screenshot not found."}, - status_code=status.HTTP_404_NOT_FOUND, - ) - - if extension in ("jpg", "jpeg"): - media_type = "image/jpeg" - elif extension == "png": - media_type = "image/png" - else: - media_type = None - - return FileResponse( - path=screenshot_path, - media_type=media_type, - ) - - -@router.get("/d/{map_set_id}") -async def get_osz( - map_set_id: str = Path(...), -) -> Response: - """Handle a map download request (osu.ppy.sh/d/*).""" - no_video = map_set_id[-1] == "n" - if no_video: - map_set_id = map_set_id[:-1] - - query_str = f"{map_set_id}?n={int(not no_video)}" - - return RedirectResponse( - url=f"{app.settings.MIRROR_DOWNLOAD_ENDPOINT}/{query_str}", - status_code=status.HTTP_301_MOVED_PERMANENTLY, - ) - - -@router.get("/web/maps/{map_filename}") -async def get_updated_beatmap( - request: Request, - map_filename: str, - host: str = Header(...), -) -> Response: - """Send the latest .osu file the server has for a given map.""" - if host == "osu.ppy.sh": - return Response("bancho.py only supports the -devserver connection method") - - return RedirectResponse( - url=f"https://osu.ppy.sh{request['raw_path'].decode()}", - status_code=status.HTTP_301_MOVED_PERMANENTLY, - ) - - -@router.get("/p/doyoureallywanttoaskpeppy") -async def peppyDMHandler() -> Response: - return Response( - content=( - b"This user's ID is usually peppy's (when on bancho), " - b"and is blocked from being messaged by the osu! client." - ), - ) - - -""" ingame registration """ - -INGAME_REGISTRATION_DISALLOWED_ERROR = { - "form_error": { - "user": { - "password": [ - "In-game registration is disabled. Please register on the website.", - ], - }, - }, -} - - -@router.post("/users") -async def register_account( - request: Request, - username: str = Form(..., alias="user[username]"), - email: str = Form(..., alias="user[user_email]"), - pw_plaintext: str = Form(..., alias="user[password]"), - check: int = Form(...), - # XXX: require/validate these headers; they are used later - # on in the registration process for resolving geolocation - forwarded_ip: str = Header(..., alias="X-Forwarded-For"), - real_ip: str = Header(..., alias="X-Real-IP"), -) -> Response: - if not all((username, email, pw_plaintext)): - return Response( - content=b"Missing required params", - status_code=status.HTTP_400_BAD_REQUEST, - ) - - # Disable in-game registration if enabled - if app.settings.DISALLOW_INGAME_REGISTRATION: - return ORJSONResponse( - content=INGAME_REGISTRATION_DISALLOWED_ERROR, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - # ensure all args passed - # are safe for registration. - errors: Mapping[str, list[str]] = defaultdict(list) - - # Usernames must: - # - be within 2-15 characters in length - # - not contain both ' ' and '_', one is fine - # - not be in the config's `disallowed_names` list - # - not already be taken by another player - if not regexes.USERNAME.match(username): - errors["username"].append("Must be 2-15 characters in length.") - - if "_" in username and " " in username: - errors["username"].append('May contain "_" and " ", but not both.') - - if username in app.settings.DISALLOWED_NAMES: - errors["username"].append("Disallowed username; pick another.") - - if "username" not in errors: - if await users_repo.fetch_one(name=username): - errors["username"].append("Username already taken by another player.") - - # Emails must: - # - match the regex `^[^@\s]{1,200}@[^@\s\.]{1,30}\.[^@\.\s]{1,24}$` - # - not already be taken by another player - if not regexes.EMAIL.match(email): - errors["user_email"].append("Invalid email syntax.") - else: - if await users_repo.fetch_one(email=email): - errors["user_email"].append("Email already taken by another player.") - - # Passwords must: - # - be within 8-32 characters in length - # - have more than 3 unique characters - # - not be in the config's `disallowed_passwords` list - if not 8 <= len(pw_plaintext) <= 32: - errors["password"].append("Must be 8-32 characters in length.") - - if len(set(pw_plaintext)) <= 3: - errors["password"].append("Must have more than 3 unique characters.") - - if pw_plaintext.lower() in app.settings.DISALLOWED_PASSWORDS: - errors["password"].append("That password was deemed too simple.") - - if errors: - # we have errors to send back, send them back delimited by newlines. - errors = {k: ["\n".join(v)] for k, v in errors.items()} - errors_full = {"form_error": {"user": errors}} - return ORJSONResponse( - content=errors_full, - status_code=status.HTTP_400_BAD_REQUEST, - ) - - if check == 0: - # the client isn't just checking values, - # they want to register the account now. - # make the md5 & bcrypt the md5 for sql. - pw_md5 = hashlib.md5(pw_plaintext.encode()).hexdigest().encode() - pw_bcrypt = bcrypt.hashpw(pw_md5, bcrypt.gensalt()) - app.state.cache.bcrypt[pw_bcrypt] = pw_md5 # cache result for login - - ip = app.state.services.ip_resolver.get_ip(request.headers) - - geoloc = await app.state.services.fetch_geoloc(ip, request.headers) - country = geoloc["country"]["acronym"] if geoloc is not None else "XX" - - async with app.state.services.database.transaction(): - # add to `users` table. - player = await users_repo.create( - name=username, - email=email, - pw_bcrypt=pw_bcrypt, - country=country, - ) - - # add to `stats` table. - await stats_repo.create_all_modes(player_id=player["id"]) - - if app.state.services.datadog: - app.state.services.datadog.increment("bancho.registrations") # type: ignore[no-untyped-call] - - log(f"<{username} ({player['id']})> has registered!", Ansi.LGREEN) - - return Response(content=b"ok") # success - - -@router.post("/difficulty-rating") -async def difficultyRatingHandler(request: Request) -> Response: - return RedirectResponse( - url=f"https://osu.ppy.sh{request['path']}", - status_code=status.HTTP_307_TEMPORARY_REDIRECT, - ) diff --git a/app/api/init_api.py b/app/api/init_api.py index 8a9606881..102b986e1 100644 --- a/app/api/init_api.py +++ b/app/api/init_api.py @@ -25,9 +25,9 @@ import app.settings import app.state import app.utils -from app.api import api_router # type: ignore[attr-defined] from app.api import domains from app.api import middlewares +from app.api import router # type: ignore[attr-defined] from app.logging import Ansi from app.logging import log from app.objects import collections @@ -175,11 +175,11 @@ def init_routes(asgi_app: BanchoAPI) -> None: for subdomain in ("c", "ce", "c4", "c5", "c6"): asgi_app.host(f"{subdomain}.{domain}", domains.cho.router) - asgi_app.host(f"osu.{domain}", domains.osu.router) + asgi_app.host(f"osu.{domain}", router) asgi_app.host(f"b.{domain}", domains.map.router) # bancho.py's developer-facing api - asgi_app.host(f"api.{domain}", api_router) + asgi_app.host(f"api.{domain}", router) def init_api() -> BanchoAPI: diff --git a/app/api/redirect/__init__.py b/app/api/redirect/__init__.py new file mode 100644 index 000000000..ab0597873 --- /dev/null +++ b/app/api/redirect/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import beatmaps +from . import difficulty_rating + +redirect_router = APIRouter() + +redirect_router.include_router(beatmaps.router) +redirect_router.include_router(difficulty_rating.router) diff --git a/app/api/redirect/beatmaps.py b/app/api/redirect/beatmaps.py new file mode 100644 index 000000000..06cb24ac4 --- /dev/null +++ b/app/api/redirect/beatmaps.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from fastapi import status +from fastapi.param_functions import Path +from fastapi.requests import Request +from fastapi.responses import RedirectResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.settings + +router = APIRouter() + + +if app.settings.REDIRECT_OSU_URLS: + # NOTE: this will likely be removed with the addition of a frontend. + async def osu_redirect(request: Request, _: int = Path(...)) -> Response: + return RedirectResponse( + url=f"https://osu.ppy.sh{request['path']}", + status_code=status.HTTP_301_MOVED_PERMANENTLY, + ) + + for pattern in ( + "/beatmapsets/{_}", + "/beatmaps/{_}", + "/beatmapsets/{_}/discussion", + "/community/forums/topics/{_}", + ): + router.get(pattern)(osu_redirect) diff --git a/app/api/redirect/difficulty_rating.py b/app/api/redirect/difficulty_rating.py new file mode 100644 index 000000000..cbc67e452 --- /dev/null +++ b/app/api/redirect/difficulty_rating.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from fastapi import status +from fastapi.requests import Request +from fastapi.responses import RedirectResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter + +router = APIRouter() + + +@router.post("/difficulty-rating") +async def difficultyRatingHandler(request: Request) -> Response: + return RedirectResponse( + url=f"https://osu.ppy.sh{request['path']}", + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + ) diff --git a/app/api/v1/__init__.py b/app/api/rest/v1/__init__.py similarity index 100% rename from app/api/v1/__init__.py rename to app/api/rest/v1/__init__.py diff --git a/app/api/v1/api.py b/app/api/rest/v1/api.py similarity index 100% rename from app/api/v1/api.py rename to app/api/rest/v1/api.py diff --git a/app/api/v2/__init__.py b/app/api/rest/v2/__init__.py similarity index 100% rename from app/api/v2/__init__.py rename to app/api/rest/v2/__init__.py diff --git a/app/api/v2/clans.py b/app/api/rest/v2/clans.py similarity index 85% rename from app/api/v2/clans.py rename to app/api/rest/v2/clans.py index 35fb8035f..7cb019865 100644 --- a/app/api/v2/clans.py +++ b/app/api/rest/v2/clans.py @@ -6,10 +6,10 @@ from fastapi import status from fastapi.param_functions import Query -from app.api.v2.common import responses -from app.api.v2.common.responses import Failure -from app.api.v2.common.responses import Success -from app.api.v2.models.clans import Clan +from app.api.rest.v2.common import responses +from app.api.rest.v2.common.responses import Failure +from app.api.rest.v2.common.responses import Success +from app.api.rest.v2.models.clans import Clan from app.repositories import clans as clans_repo router = APIRouter() diff --git a/app/api/v2/common/json.py b/app/api/rest/v2/common/json.py similarity index 100% rename from app/api/v2/common/json.py rename to app/api/rest/v2/common/json.py diff --git a/app/api/v2/common/responses.py b/app/api/rest/v2/common/responses.py similarity index 96% rename from app/api/v2/common/responses.py rename to app/api/rest/v2/common/responses.py index 42f836497..3d403067a 100644 --- a/app/api/v2/common/responses.py +++ b/app/api/rest/v2/common/responses.py @@ -8,7 +8,7 @@ from pydantic import BaseModel -from app.api.v2.common import json +from app.api.rest.v2.common import json T = TypeVar("T") diff --git a/app/api/v2/maps.py b/app/api/rest/v2/maps.py similarity index 90% rename from app/api/v2/maps.py rename to app/api/rest/v2/maps.py index 3489191ab..80a922376 100644 --- a/app/api/v2/maps.py +++ b/app/api/rest/v2/maps.py @@ -6,10 +6,10 @@ from fastapi import status from fastapi.param_functions import Query -from app.api.v2.common import responses -from app.api.v2.common.responses import Failure -from app.api.v2.common.responses import Success -from app.api.v2.models.maps import Map +from app.api.rest.v2.common import responses +from app.api.rest.v2.common.responses import Failure +from app.api.rest.v2.common.responses import Success +from app.api.rest.v2.models.maps import Map from app.repositories import maps as maps_repo router = APIRouter() diff --git a/app/api/v2/models/__init__.py b/app/api/rest/v2/models/__init__.py similarity index 100% rename from app/api/v2/models/__init__.py rename to app/api/rest/v2/models/__init__.py diff --git a/app/api/v2/models/clans.py b/app/api/rest/v2/models/clans.py similarity index 100% rename from app/api/v2/models/clans.py rename to app/api/rest/v2/models/clans.py diff --git a/app/api/v2/models/maps.py b/app/api/rest/v2/models/maps.py similarity index 100% rename from app/api/v2/models/maps.py rename to app/api/rest/v2/models/maps.py diff --git a/app/api/v2/models/players.py b/app/api/rest/v2/models/players.py similarity index 100% rename from app/api/v2/models/players.py rename to app/api/rest/v2/models/players.py diff --git a/app/api/v2/models/scores.py b/app/api/rest/v2/models/scores.py similarity index 100% rename from app/api/v2/models/scores.py rename to app/api/rest/v2/models/scores.py diff --git a/app/api/v2/players.py b/app/api/rest/v2/players.py similarity index 92% rename from app/api/v2/players.py rename to app/api/rest/v2/players.py index a14571544..fd59d9867 100644 --- a/app/api/v2/players.py +++ b/app/api/rest/v2/players.py @@ -7,12 +7,12 @@ from fastapi.param_functions import Query import app.state.sessions -from app.api.v2.common import responses -from app.api.v2.common.responses import Failure -from app.api.v2.common.responses import Success -from app.api.v2.models.players import Player -from app.api.v2.models.players import PlayerStats -from app.api.v2.models.players import PlayerStatus +from app.api.rest.v2.common import responses +from app.api.rest.v2.common.responses import Failure +from app.api.rest.v2.common.responses import Success +from app.api.rest.v2.models.players import Player +from app.api.rest.v2.models.players import PlayerStats +from app.api.rest.v2.models.players import PlayerStatus from app.repositories import stats as stats_repo from app.repositories import users as users_repo diff --git a/app/api/v2/scores.py b/app/api/rest/v2/scores.py similarity index 88% rename from app/api/v2/scores.py rename to app/api/rest/v2/scores.py index b2d056af4..c84b9a296 100644 --- a/app/api/v2/scores.py +++ b/app/api/rest/v2/scores.py @@ -6,10 +6,10 @@ from fastapi import status from fastapi.param_functions import Query -from app.api.v2.common import responses -from app.api.v2.common.responses import Failure -from app.api.v2.common.responses import Success -from app.api.v2.models.scores import Score +from app.api.rest.v2.common import responses +from app.api.rest.v2.common.responses import Failure +from app.api.rest.v2.common.responses import Success +from app.api.rest.v2.models.scores import Score from app.repositories import scores as scores_repo router = APIRouter() diff --git a/app/api/static/__init__.py b/app/api/static/__init__.py new file mode 100644 index 000000000..87960f756 --- /dev/null +++ b/app/api/static/__init__.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import beatmaps +from . import screenshots + +static_router = APIRouter() + +static_router.include_router(beatmaps.router, prefix="/d") +static_router.include_router(screenshots.router, prefix="/ss") diff --git a/app/api/static/beatmaps.py b/app/api/static/beatmaps.py new file mode 100644 index 000000000..5730f3a27 --- /dev/null +++ b/app/api/static/beatmaps.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from fastapi import status +from fastapi.param_functions import Path +from fastapi.responses import RedirectResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.settings + +router = APIRouter() + + +@router.get("/{map_set_id}") +async def get_osz( + map_set_id: str = Path(...), +) -> Response: + """Handle a map download request (osu.ppy.sh/d/*).""" + no_video = map_set_id[-1] == "n" + if no_video: + map_set_id = map_set_id[:-1] + + query_str = f"{map_set_id}?n={int(not no_video)}" + + return RedirectResponse( + url=f"{app.settings.MIRROR_DOWNLOAD_ENDPOINT}/{query_str}", + status_code=status.HTTP_301_MOVED_PERMANENTLY, + ) diff --git a/app/api/static/screenshots.py b/app/api/static/screenshots.py new file mode 100644 index 000000000..b20f2238c --- /dev/null +++ b/app/api/static/screenshots.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from pathlib import Path as SystemPath +from typing import Literal + +from fastapi import status +from fastapi.param_functions import Path +from fastapi.responses import FileResponse +from fastapi.responses import ORJSONResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter + +SCREENSHOTS_PATH = SystemPath.cwd() / ".data/ss" + + +router = APIRouter() + + +@router.get("/{screenshot_id}.{extension}") +async def get_screenshot( + screenshot_id: str = Path(..., pattern=r"[a-zA-Z0-9-_]{8}"), + extension: Literal["jpg", "jpeg", "png"] = Path(...), +) -> Response: + """Serve a screenshot from the server, by filename.""" + screenshot_path = SCREENSHOTS_PATH / f"{screenshot_id}.{extension}" + + if not screenshot_path.exists(): + return ORJSONResponse( + content={"status": "Screenshot not found."}, + status_code=status.HTTP_404_NOT_FOUND, + ) + + if extension in ("jpg", "jpeg"): + media_type = "image/jpeg" + elif extension == "png": + media_type = "image/png" + else: + media_type = None + + return FileResponse( + path=screenshot_path, + media_type=media_type, + ) diff --git a/app/api/web/__init__.py b/app/api/web/__init__.py new file mode 100644 index 000000000..54f262f3d --- /dev/null +++ b/app/api/web/__init__.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from fastapi import APIRouter + +from . import backgrounds +from . import beatmaps +from . import channels +from . import comments +from . import connect +from . import direct +from . import favourites +from . import friends +from . import lastfm +from . import leaderboards +from . import peppy +from . import ratings +from . import replays +from . import scoring +from . import screenshots +from . import updates + +# Unhandled endpoints: +# POST /osu-error.php +# POST /osu-session.php +# POST /osu-osz2-bmsubmit-post.php +# POST /osu-osz2-bmsubmit-upload.php +# GET /osu-osz2-bmsubmit-getid.php +# GET /osu-get-beatmap-topic.php + +web_router = APIRouter(tags=["Web"], prefix="/web") + +web_router.include_router(backgrounds.router) +web_router.include_router(beatmaps.router) +web_router.include_router(channels.router) +web_router.include_router(comments.router) +web_router.include_router(connect.router) +web_router.include_router(direct.router) +web_router.include_router(favourites.router) +web_router.include_router(friends.router) +web_router.include_router(lastfm.router) +web_router.include_router(leaderboards.router) +web_router.include_router(peppy.router) +web_router.include_router(ratings.router) +web_router.include_router(replays.router) +web_router.include_router(scoring.router) +web_router.include_router(screenshots.router) +web_router.include_router(updates.router) diff --git a/app/api/web/authentication.py b/app/api/web/authentication.py new file mode 100644 index 000000000..4674fa7dc --- /dev/null +++ b/app/api/web/authentication.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from collections.abc import Awaitable +from collections.abc import Callable +from functools import cache +from typing import Any +from urllib.parse import unquote + +from fastapi import status +from fastapi.exceptions import HTTPException + +import app.state +from app.objects.player import Player + + +@cache +def authenticate_player_session( + param_function: Callable[..., Any], + username_alias: str = "u", + pw_md5_alias: str = "p", + err: Any | None = None, +) -> Callable[[str, str], Awaitable[Player]]: + async def wrapper( + username: str = param_function(..., alias=username_alias), + pw_md5: str = param_function(..., alias=pw_md5_alias), + ) -> Player: + player = await app.state.sessions.players.from_login( + name=unquote(username), + pw_md5=pw_md5, + ) + if player: + return player + + # player login incorrect + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=err, + ) + + return wrapper diff --git a/app/api/web/backgrounds.py b/app/api/web/backgrounds.py new file mode 100644 index 000000000..ec192fd3a --- /dev/null +++ b/app/api/web/backgrounds.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from fastapi.responses import ORJSONResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.settings + +router = APIRouter() + + +@router.get("/osu-getseasonal.php") +async def osuSeasonal() -> Response: + return ORJSONResponse(app.settings.SEASONAL_BGS) diff --git a/app/api/web/beatmaps.py b/app/api/web/beatmaps.py new file mode 100644 index 000000000..355826127 --- /dev/null +++ b/app/api/web/beatmaps.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from fastapi import status +from fastapi.param_functions import Depends +from fastapi.param_functions import Header +from fastapi.param_functions import Query +from fastapi.requests import Request +from fastapi.responses import RedirectResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.state +from app.api.web.authentication import authenticate_player_session +from app.logging import Ansi +from app.logging import log +from app.objects import models +from app.objects.player import Player +from app.objects.score import SubmissionStatus +from app.repositories import maps as maps_repo +from app.repositories import scores as scores_repo + +router = APIRouter() + + +def bancho_to_osuapi_status(bancho_status: int) -> int: + return { + 0: 0, + 2: 1, + 3: 2, + 4: 3, + 5: 4, + }[bancho_status] + + +@router.post("/osu-getbeatmapinfo.php") +async def osuGetBeatmapInfo( + form_data: models.OsuBeatmapRequestForm, + player: Player = Depends(authenticate_player_session(Query, "u", "h")), +) -> Response: + num_requests = len(form_data.Filenames) + len(form_data.Ids) + log(f"{player} requested info for {num_requests} maps.", Ansi.LCYAN) + + response_lines: list[str] = [] + + for idx, map_filename in enumerate(form_data.Filenames): + # try getting the map from sql + + beatmap = await maps_repo.fetch_one(filename=map_filename) + + if not beatmap: + continue + + # try to get the user's grades on the map + # NOTE: osu! only allows us to send back one per gamemode, + # so we've decided to send back *vanilla* grades. + # (in theory we could make this user-customizable) + grades = ["N", "N", "N", "N"] + + for score in await scores_repo.fetch_many( + map_md5=beatmap["md5"], + user_id=player.id, + mode=player.status.mode.as_vanilla, + status=SubmissionStatus.BEST, + ): + grades[score["mode"]] = score["grade"] + + response_lines.append( + "{i}|{id}|{set_id}|{md5}|{status}|{grades}".format( + i=idx, + id=beatmap["id"], + set_id=beatmap["set_id"], + md5=beatmap["md5"], + status=bancho_to_osuapi_status(beatmap["status"]), + grades="|".join(grades), + ), + ) + + if form_data.Ids: # still have yet to see this used + await app.state.services.log_strange_occurrence( + f"{player} requested map(s) info by id ({form_data.Ids})", + ) + + return Response("\n".join(response_lines).encode()) + + +@router.get("/maps/{map_filename}") +async def get_updated_beatmap( + request: Request, + map_filename: str, + host: str = Header(...), +) -> Response: + """Send the latest .osu file the server has for a given map.""" + if host == "osu.ppy.sh": + return Response("bancho.py only supports the -devserver connection method") + + return RedirectResponse( + url=f"https://osu.ppy.sh{request['raw_path'].decode()}", + status_code=status.HTTP_301_MOVED_PERMANENTLY, + ) diff --git a/app/api/web/channels.py b/app/api/web/channels.py new file mode 100644 index 000000000..e1bfee3f3 --- /dev/null +++ b/app/api/web/channels.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from urllib.parse import unquote + +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.state +from app.api.web.authentication import authenticate_player_session +from app.logging import Ansi +from app.logging import log +from app.objects.player import Player +from app.repositories import mail as mail_repo + +router = APIRouter() + + +@router.get("/osu-markasread.php") +async def osuMarkAsRead( + player: Player = Depends(authenticate_player_session(Query, "u", "h")), + channel: str = Query(..., min_length=0, max_length=32), +) -> Response: + target_name = unquote(channel) # TODO: unquote needed? + if not target_name: + log( + f"User {player} attempted to mark a channel as read without a target.", + Ansi.LYELLOW, + ) + return Response(b"") # no channel specified + + target = await app.state.sessions.players.from_cache_or_sql(name=target_name) + if target: + # mark any unread mail from this user as read. + await mail_repo.mark_conversation_as_read( + to_id=player.id, + from_id=target.id, + ) + + return Response(b"") diff --git a/app/api/web/comments.py b/app/api/web/comments.py new file mode 100644 index 000000000..020550c4e --- /dev/null +++ b/app/api/web/comments.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Literal + +from fastapi.param_functions import Depends +from fastapi.param_functions import Form +from fastapi.responses import Response +from fastapi.routing import APIRouter + +from app.api.web.authentication import authenticate_player_session +from app.constants.privileges import Privileges +from app.logging import log +from app.objects.player import Player +from app.repositories import comments as comments_repo + +router = APIRouter() + + +@router.post("/osu-comment.php") +async def osuComment( + player: Player = Depends(authenticate_player_session(Form, "u", "p")), + map_id: int = Form(..., alias="b"), + map_set_id: int = Form(..., alias="s"), + score_id: int = Form(..., alias="r", ge=0, le=9_223_372_036_854_775_807), + mode_vn: int = Form(..., alias="m", ge=0, le=3), + action: Literal["get", "post"] = Form(..., alias="a"), + # only sent for post + target: Literal["song", "map", "replay"] | None = Form(None), + colour: str | None = Form(None, alias="f", min_length=6, max_length=6), + start_time: int | None = Form(None, alias="starttime"), + comment: str | None = Form(None, min_length=1, max_length=80), +) -> Response: + if action == "get": + # client is requesting all comments + comments = await comments_repo.fetch_all_relevant_to_replay( + score_id=score_id, + map_set_id=map_set_id, + map_id=map_id, + ) + + ret: list[str] = [] + + for cmt in comments: + # note: this implementation does not support + # "player" or "creator" comment colours + if cmt["priv"] & Privileges.NOMINATOR: + fmt = "bat" + elif cmt["priv"] & Privileges.DONATOR: + fmt = "supporter" + else: + fmt = "" + + if cmt["colour"]: + fmt += f'|{cmt["colour"]}' + + ret.append( + "{time}\t{target_type}\t{fmt}\t{comment}".format(fmt=fmt, **cmt), + ) + + player.update_latest_activity_soon() + return Response("\n".join(ret).encode()) + + elif action == "post": + # client is submitting a new comment + + # validate all required params are provided + assert target is not None + assert start_time is not None + assert comment is not None + + # get the corresponding id from the request + if target == "song": + target_id = map_set_id + elif target == "map": + target_id = map_id + else: # target == "replay" + target_id = score_id + + if colour and not player.priv & Privileges.DONATOR: + # only supporters can use colours. + colour = None + + log( + f"User {player} attempted to use a coloured comment without " + "supporter status. Submitting comment without a colour.", + ) + + # insert into sql + await comments_repo.create( + target_id=target_id, + target_type=comments_repo.TargetType(target), + userid=player.id, + time=start_time, + comment=comment, + colour=colour, + ) + + player.update_latest_activity_soon() + + return Response(b"") # empty resp is fine diff --git a/app/api/web/connect.py b/app/api/web/connect.py new file mode 100644 index 000000000..36a026cd5 --- /dev/null +++ b/app/api/web/connect.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +router = APIRouter() + + +@router.get("/bancho_connect.php") +async def banchoConnect( + # NOTE: this is disabled as this endpoint can be called + # before a player has been granted a session + # player: Player = Depends(authenticate_player_session(Query, "u", "h")), + osu_ver: str = Query(..., alias="v"), + active_endpoint: str | None = Query(None, alias="fail"), + net_framework_vers: str | None = Query(None, alias="fx"), # delimited by | + client_hash: str | None = Query(None, alias="ch"), + retrying: bool | None = Query(None, alias="retry"), # '0' or '1' +) -> Response: + return Response(b"") diff --git a/app/api/web/direct.py b/app/api/web/direct.py new file mode 100644 index 000000000..6f4e5599d --- /dev/null +++ b/app/api/web/direct.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import status +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.settings +import app.state +from app.api.web.authentication import authenticate_player_session +from app.objects.beatmap import RankedStatus +from app.objects.player import Player + +DIRECT_SET_INFO_FMTSTR = ( + "{SetID}.osz|{Artist}|{Title}|{Creator}|" + "{RankedStatus}|10.0|{LastUpdate}|{SetID}|" + "0|{HasVideo}|0|0|0|{diffs}" # 0s are threadid, has_story, + # filesize, filesize_novid. +) + +DIRECT_MAP_INFO_FMTSTR = ( + "[{DifficultyRating:.2f}⭐] {DiffName} " + "{{cs: {CS} / od: {OD} / ar: {AR} / hp: {HP}}}@{Mode}" +) + + +router = APIRouter() + + +@router.get("/osu-search.php") +async def osuSearchHandler( + player: Player = Depends(authenticate_player_session(Query, "u", "h")), + ranked_status: int = Query(..., alias="r", ge=0, le=8), + query: str = Query(..., alias="q"), + mode: int = Query(..., alias="m", ge=-1, le=3), # -1 for all + page_num: int = Query(..., alias="p"), +) -> Response: + params: dict[str, Any] = {"amount": 100, "offset": page_num * 100} + + # eventually we could try supporting these, + # but it mostly depends on the mirror. + if query not in ("Newest", "Top+Rated", "Most+Played"): + params["query"] = query + + if mode != -1: # -1 for all + params["mode"] = mode + + if ranked_status != 4: # 4 for all + # convert to osu!api status + params["status"] = RankedStatus.from_osudirect(ranked_status).osu_api + + response = await app.state.services.http_client.get( + app.settings.MIRROR_SEARCH_ENDPOINT, + params=params, + ) + if response.status_code != status.HTTP_200_OK: + return Response(b"-1\nFailed to retrieve data from the beatmap mirror.") + + result = response.json() + + lresult = len(result) # send over 100 if we receive + # 100 matches, so the client + # knows there are more to get + ret = [f"{'101' if lresult == 100 else lresult}"] + for bmapset in result: + if bmapset["ChildrenBeatmaps"] is None: + continue + + # some mirrors use a true/false instead of 0 or 1 + bmapset["HasVideo"] = int(bmapset["HasVideo"]) + + diff_sorted_maps = sorted( + bmapset["ChildrenBeatmaps"], + key=lambda m: m["DifficultyRating"], + ) + + def handle_invalid_characters(s: str) -> str: + # XXX: this is a bug that exists on official servers (lmao) + # | is used to delimit the set data, so the difficulty name + # cannot contain this or it will be ignored. we fix it here + # by using a different character. + return s.replace("|", "I") + + diffs_str = ",".join( + [ + DIRECT_MAP_INFO_FMTSTR.format( + DifficultyRating=row["DifficultyRating"], + DiffName=handle_invalid_characters(row["DiffName"]), + CS=row["CS"], + OD=row["OD"], + AR=row["AR"], + HP=row["HP"], + Mode=row["Mode"], + ) + for row in diff_sorted_maps + ], + ) + + ret.append( + DIRECT_SET_INFO_FMTSTR.format( + Artist=handle_invalid_characters(bmapset["Artist"]), + Title=handle_invalid_characters(bmapset["Title"]), + Creator=bmapset["Creator"], + RankedStatus=bmapset["RankedStatus"], + LastUpdate=bmapset["LastUpdate"], + SetID=bmapset["SetID"], + HasVideo=bmapset["HasVideo"], + diffs=diffs_str, + ), + ) + + return Response("\n".join(ret).encode()) + + +# TODO: video support (needs db change) +@router.get("/osu-search-set.php") +async def osuSearchSetHandler( + player: Player = Depends(authenticate_player_session(Query, "u", "h")), + map_set_id: int | None = Query(None, alias="s"), + map_id: int | None = Query(None, alias="b"), + checksum: str | None = Query(None, alias="c"), +) -> Response: + # Since we only need set-specific data, we can basically + # just do same query with either bid or bsid. + + v: int | str + if map_set_id is not None: + # this is just a normal request + k, v = ("set_id", map_set_id) + elif map_id is not None: + k, v = ("id", map_id) + elif checksum is not None: + k, v = ("md5", checksum) + else: + return Response(b"") # invalid args + + # Get all set data. + bmapset = await app.state.services.database.fetch_one( + "SELECT DISTINCT set_id, artist, " + "title, status, creator, last_update " + f"FROM maps WHERE {k} = :v", + {"v": v}, + ) + if bmapset is None: + # TODO: get from osu! + return Response(b"") + + rating = 10.0 # TODO: real data + + return Response( + ( + "{set_id}.osz|{artist}|{title}|{creator}|" + "{status}|{rating:.1f}|{last_update}|{set_id}|" + "0|0|0|0|0" + ) + .format(**bmapset, rating=rating) + .encode(), + ) + # 0s are threadid, has_vid, has_story, filesize, filesize_novid diff --git a/app/api/web/favourites.py b/app/api/web/favourites.py new file mode 100644 index 000000000..138d8f782 --- /dev/null +++ b/app/api/web/favourites.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +from app.api.web.authentication import authenticate_player_session +from app.objects.player import Player +from app.repositories import favourites as favourites_repo + +router = APIRouter() + + +@router.get("/osu-getfavourites.php") +async def osuGetFavourites( + player: Player = Depends(authenticate_player_session(Query, "u", "h")), +) -> Response: + favourites = await favourites_repo.fetch_all(userid=player.id) + + return Response( + "\n".join([str(favourite["setid"]) for favourite in favourites]).encode(), + ) + + +@router.get("/osu-addfavourite.php") +async def osuAddFavourite( + player: Player = Depends(authenticate_player_session(Query, "u", "h")), + map_set_id: int = Query(..., alias="a"), +) -> Response: + # check if they already have this favourited. + if await favourites_repo.fetch_one(player.id, map_set_id): + return Response(b"You've already favourited this beatmap!") + + # add favourite + await favourites_repo.create( + userid=player.id, + setid=map_set_id, + ) + + return Response(b"Added favourite!") diff --git a/app/api/web/friends.py b/app/api/web/friends.py new file mode 100644 index 000000000..aae9ecd2c --- /dev/null +++ b/app/api/web/friends.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +from app.api.web.authentication import authenticate_player_session +from app.objects.player import Player + +router = APIRouter() + + +@router.get("/osu-getfriends.php") +async def osuGetFriends( + player: Player = Depends(authenticate_player_session(Query, "u", "h")), +) -> Response: + return Response("\n".join(map(str, player.friends)).encode()) diff --git a/app/api/web/lastfm.py b/app/api/web/lastfm.py new file mode 100644 index 000000000..ddd1a6913 --- /dev/null +++ b/app/api/web/lastfm.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +import random +from typing import Literal + +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.packets +import app.state +from app.api.web.authentication import authenticate_player_session +from app.constants.clientflags import LastFMFlags +from app.objects.player import Player + +router = APIRouter() + + +@router.get("/lastfm.php") +async def lastFM( + action: Literal["scrobble", "np"], + beatmap_id_or_hidden_flag: str = Query( + ..., + description=( + "This flag is normally a beatmap ID, but is also " + "used as a hidden anticheat flag within osu!" + ), + alias="b", + ), + player: Player = Depends(authenticate_player_session(Query, "us", "ha")), +) -> Response: + if beatmap_id_or_hidden_flag[0] != "a": + # not anticheat related, tell the + # client not to send any more for now. + return Response(b"-3") + + flags = LastFMFlags(int(beatmap_id_or_hidden_flag[1:])) + + if flags & (LastFMFlags.HQ_ASSEMBLY | LastFMFlags.HQ_FILE): + # Player is currently running hq!osu; could possibly + # be a separate client, buuuut prooobably not lol. + + await player.restrict( + admin=app.state.sessions.bot, + reason=f"hq!osu running ({flags})", + ) + + # refresh their client state + if player.is_online: + player.logout() + + return Response(b"-3") + + if flags & LastFMFlags.REGISTRY_EDITS: + # Player has registry edits left from + # hq!osu's multiaccounting tool. This + # does not necessarily mean they are + # using it now, but they have in the past. + + if random.randrange(32) == 0: + # Random chance (1/32) for a ban. + await player.restrict( + admin=app.state.sessions.bot, + reason="hq!osu relife 1/32", + ) + + # refresh their client state + if player.is_online: + player.logout() + + return Response(b"-3") + + player.enqueue( + app.packets.notification( + "\n".join( + [ + "Hey!", + "It appears you have hq!osu's multiaccounting tool (relife) enabled.", + "This tool leaves a change in your registry that the osu! client can detect.", + "Please re-install relife and disable the program to avoid any restrictions.", + ], + ), + ), + ) + + player.logout() + + return Response(b"-3") + + """ These checks only worked for ~5 hours from release. rumoi's quick! + if flags & ( + LastFMFlags.SDL2_LIBRARY + | LastFMFlags.OPENSSL_LIBRARY + | LastFMFlags.AQN_MENU_SAMPLE + ): + # AQN has been detected in the client, either + # through the 'libeay32.dll' library being found + # onboard, or from the menu sound being played in + # the AQN menu while being in an inappropriate menu + # for the context of the sound effect. + pass + """ + + return Response(b"") diff --git a/app/api/web/leaderboards.py b/app/api/web/leaderboards.py new file mode 100644 index 000000000..c46ed13f8 --- /dev/null +++ b/app/api/web/leaderboards.py @@ -0,0 +1,314 @@ +from __future__ import annotations + +from enum import IntEnum +from enum import unique +from typing import Any +from typing import Literal +from urllib.parse import unquote_plus + +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.packets +import app.state +import app.utils +from app.api.web.authentication import authenticate_player_session +from app.constants.gamemodes import GameMode +from app.constants.mods import Mods +from app.objects.beatmap import Beatmap +from app.objects.beatmap import RankedStatus +from app.objects.player import Player +from app.repositories import clans as clans_repo +from app.repositories import maps as maps_repo +from app.repositories import ratings as ratings_repo +from app.utils import escape_enum +from app.utils import pymysql_encode + +SCORE_LISTING_FMTSTR = ( + "{id}|{name}|{score}|{max_combo}|" + "{n50}|{n100}|{n300}|{nmiss}|{nkatu}|{ngeki}|" + "{perfect}|{mods}|{userid}|{rank}|{time}|{has_replay}" +) + + +@unique +@pymysql_encode(escape_enum) +class LeaderboardType(IntEnum): + Local = 0 + Top = 1 + Mods = 2 + Friends = 3 + Country = 4 + + +router = APIRouter() + + +async def get_leaderboard_scores( + leaderboard_type: LeaderboardType | int, + map_md5: str, + mode: int, + mods: Mods, + player: Player, + scoring_metric: Literal["pp", "score"], +) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + query = [ + f"SELECT s.id, s.{scoring_metric} AS _score, " + "s.max_combo, s.n50, s.n100, s.n300, " + "s.nmiss, s.nkatu, s.ngeki, s.perfect, s.mods, " + "UNIX_TIMESTAMP(s.play_time) time, u.id userid, " + "COALESCE(CONCAT('[', c.tag, '] ', u.name), u.name) AS name " + "FROM scores s " + "INNER JOIN users u ON u.id = s.userid " + "LEFT JOIN clans c ON c.id = u.clan_id " + "WHERE s.map_md5 = :map_md5 AND s.status = 2 " # 2: =best score + "AND (u.priv & 1 OR u.id = :user_id) AND mode = :mode", + ] + + params: dict[str, Any] = { + "map_md5": map_md5, + "user_id": player.id, + "mode": mode, + } + + if leaderboard_type == LeaderboardType.Mods: + query.append("AND s.mods = :mods") + params["mods"] = mods + elif leaderboard_type == LeaderboardType.Friends: + query.append("AND s.userid IN :friends") + params["friends"] = player.friends | {player.id} + elif leaderboard_type == LeaderboardType.Country: + query.append("AND u.country = :country") + params["country"] = player.geoloc["country"]["acronym"] + + # TODO: customizability of the number of scores + query.append("ORDER BY _score DESC LIMIT 50") + + score_rows = await app.state.services.database.fetch_all( + " ".join(query), + params, + ) + + if score_rows: # None or [] + # fetch player's personal best score + personal_best_score_row = await app.state.services.database.fetch_one( + f"SELECT id, {scoring_metric} AS _score, " + "max_combo, n50, n100, n300, " + "nmiss, nkatu, ngeki, perfect, mods, " + "UNIX_TIMESTAMP(play_time) time " + "FROM scores " + "WHERE map_md5 = :map_md5 AND mode = :mode " + "AND userid = :user_id AND status = 2 " + "ORDER BY _score DESC LIMIT 1", + {"map_md5": map_md5, "mode": mode, "user_id": player.id}, + ) + + if personal_best_score_row is not None: + # calculate the rank of the score. + p_best_rank = 1 + await app.state.services.database.fetch_val( + "SELECT COUNT(*) FROM scores s " + "INNER JOIN users u ON u.id = s.userid " + "WHERE s.map_md5 = :map_md5 AND s.mode = :mode " + "AND s.status = 2 AND u.priv & 1 " + f"AND s.{scoring_metric} > :score", + { + "map_md5": map_md5, + "mode": mode, + "score": personal_best_score_row["_score"], + }, + column=0, # COUNT(*) + ) + + # attach rank to personal best row + personal_best_score_row["rank"] = p_best_rank + else: + score_rows = [] + personal_best_score_row = None + + return score_rows, personal_best_score_row + + +@router.get("/osu-osz2-getscores.php") +async def getScores( + player: Player = Depends(authenticate_player_session(Query, "us", "ha")), + requesting_from_editor_song_select: bool = Query(..., alias="s"), + leaderboard_version: int = Query(..., alias="vv"), + leaderboard_type: int = Query(..., alias="v", ge=0, le=4), + map_md5: str = Query(..., alias="c", min_length=32, max_length=32), + map_filename: str = Query(..., alias="f"), + mode_arg: int = Query(..., alias="m", ge=0, le=3), + map_set_id: int = Query(..., alias="i", ge=-1, le=2_147_483_647), + mods_arg: int = Query(..., alias="mods", ge=0, le=2_147_483_647), + map_package_hash: str = Query(..., alias="h"), # TODO: further validation + aqn_files_found: bool = Query(..., alias="a"), +) -> Response: + if aqn_files_found: + stacktrace = app.utils.get_appropriate_stacktrace() + await app.state.services.log_strange_occurrence(stacktrace) + + # check if this md5 has already been cached as + # unsubmitted/needs update to reduce osu!api spam + if map_md5 in app.state.cache.unsubmitted: + return Response(b"-1|false") + if map_md5 in app.state.cache.needs_update: + return Response(b"1|false") + + if mods_arg & Mods.RELAX: + if mode_arg == 3: # rx!mania doesn't exist + mods_arg &= ~Mods.RELAX + else: + mode_arg += 4 + elif mods_arg & Mods.AUTOPILOT: + if mode_arg in (1, 2, 3): # ap!catch, taiko and mania don't exist + mods_arg &= ~Mods.AUTOPILOT + else: + mode_arg += 8 + + mods = Mods(mods_arg) + mode = GameMode(mode_arg) + + # attempt to update their stats if their + # gm/gm-affecting-mods change at all. + if mode != player.status.mode: + player.status.mods = mods + player.status.mode = mode + + if not player.restricted: + app.state.sessions.players.enqueue(app.packets.user_stats(player)) + + scoring_metric: Literal["pp", "score"] = ( + "pp" if mode >= GameMode.RELAX_OSU else "score" + ) + + bmap = await Beatmap.from_md5(map_md5, set_id=map_set_id) + has_set_id = map_set_id > 0 + + if not bmap: + # map not found, figure out whether it needs an + # update or isn't submitted using its filename. + + if has_set_id and map_set_id not in app.state.cache.beatmapset: + # set not cached, it doesn't exist + app.state.cache.unsubmitted.add(map_md5) + return Response(b"-1|false") + + map_filename = unquote_plus(map_filename) # TODO: is unquote needed? + + map_exists = False + if has_set_id: + # we can look it up in the specific set from cache + for bmap in app.state.cache.beatmapset[map_set_id].maps: + if map_filename == bmap.filename: + map_exists = True + break + else: + map_exists = False + else: + # we can't find it on the osu!api by md5, + # and we don't have the set id, so we must + # look it up in sql from the filename. + map_exists = ( + await maps_repo.fetch_one( + filename=map_filename, + ) + is not None + ) + + if map_exists: + # map can be updated. + app.state.cache.needs_update.add(map_md5) + return Response(b"1|false") + else: + # map is unsubmitted. + # add this map to the unsubmitted cache, so + # that we don't have to make this request again. + app.state.cache.unsubmitted.add(map_md5) + return Response(b"-1|false") + + # we've found a beatmap for the request. + + if app.state.services.datadog: + app.state.services.datadog.increment("bancho.leaderboards_served") # type: ignore[no-untyped-call] + + if bmap.status < RankedStatus.Ranked: + # only show leaderboards for ranked, + # approved, qualified, or loved maps. + return Response(f"{int(bmap.status)}|false".encode()) + + # fetch scores & personal best + # TODO: create a leaderboard cache + if not requesting_from_editor_song_select: + score_rows, personal_best_score_row = await get_leaderboard_scores( + leaderboard_type, + bmap.md5, + mode, + mods, + player, + scoring_metric, + ) + else: + score_rows = [] + personal_best_score_row = None + + # fetch beatmap rating + map_ratings = await ratings_repo.fetch_many( + map_md5=bmap.md5, + page=None, + page_size=None, + ) + ratings = [row["rating"] for row in map_ratings] + map_avg_rating = sum(ratings) / len(ratings) if ratings else 0.0 + + ## construct response for osu! client + + response_lines: list[str] = [ + # NOTE: fa stands for featured artist (for the ones that may not know) + # {ranked_status}|{serv_has_osz2}|{bid}|{bsid}|{len(scores)}|{fa_track_id}|{fa_license_text} + f"{int(bmap.status)}|false|{bmap.id}|{bmap.set_id}|{len(score_rows)}|0|", + # {offset}\n{beatmap_name}\n{rating} + # TODO: server side beatmap offsets + f"0\n{bmap.full_name}\n{map_avg_rating}", + ] + + if not score_rows: + response_lines.extend(("", "")) # no scores, no personal best + return Response("\n".join(response_lines).encode()) + + if personal_best_score_row is not None: + user_clan = ( + await clans_repo.fetch_one(id=player.clan_id) + if player.clan_id is not None + else None + ) + display_name = ( + f"[{user_clan['tag']}] {player.name}" + if user_clan is not None + else player.name + ) + response_lines.append( + SCORE_LISTING_FMTSTR.format( + **personal_best_score_row, + name=display_name, + userid=player.id, + score=int(round(personal_best_score_row["_score"])), + has_replay="1", + ), + ) + else: + response_lines.append("") + + response_lines.extend( + [ + SCORE_LISTING_FMTSTR.format( + **s, + score=int(round(s["_score"])), + has_replay="1", + rank=idx + 1, + ) + for idx, s in enumerate(score_rows) + ], + ) + + return Response("\n".join(response_lines).encode()) diff --git a/app/api/web/peppy.py b/app/api/web/peppy.py new file mode 100644 index 000000000..6daac3faa --- /dev/null +++ b/app/api/web/peppy.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from fastapi.responses import Response +from fastapi.routing import APIRouter + +router = APIRouter() + + +@router.get("/p/doyoureallywanttoaskpeppy") +async def peppyDMHandler() -> Response: + return Response( + content=( + b"This user's ID is usually peppy's (when on bancho), " + b"and is blocked from being messaged by the osu! client." + ), + ) diff --git a/app/api/web/ratings.py b/app/api/web/ratings.py new file mode 100644 index 000000000..816e20680 --- /dev/null +++ b/app/api/web/ratings.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.state +from app.api.web.authentication import authenticate_player_session +from app.objects.beatmap import RankedStatus +from app.objects.player import Player +from app.repositories import ratings as ratings_repo + +router = APIRouter() + + +@router.get("/osu-rate.php") +async def osuRate( + player: Player = Depends( + authenticate_player_session(Query, "u", "p", err=b"auth fail"), + ), + map_md5: str = Query(..., alias="c", min_length=32, max_length=32), + rating: int | None = Query(None, alias="v", ge=1, le=10), +) -> Response: + if rating is None: + # check if we have the map in our cache; + # if not, the map probably doesn't exist. + if map_md5 not in app.state.cache.beatmap: + return Response(b"no exist") + + cached = app.state.cache.beatmap[map_md5] + + # only allow rating on maps with a leaderboard. + if cached.status < RankedStatus.Ranked: + return Response(b"not ranked") + + # osu! client is checking whether we can rate the map or not. + # the client hasn't rated the map, so simply + # tell them that they can submit a rating. + if not await ratings_repo.fetch_one(map_md5=map_md5, userid=player.id): + return Response(b"ok") + else: + # the client is submitting a rating for the map. + await ratings_repo.create(userid=player.id, map_md5=map_md5, rating=rating) + + map_ratings = await ratings_repo.fetch_many(map_md5=map_md5) + ratings = [row["rating"] for row in map_ratings] + + # send back the average rating + avg = sum(ratings) / len(ratings) + return Response(f"alreadyvoted\n{avg}".encode()) diff --git a/app/api/web/replays.py b/app/api/web/replays.py new file mode 100644 index 000000000..c3939f307 --- /dev/null +++ b/app/api/web/replays.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pathlib import Path as SystemPath + +from fastapi.param_functions import Depends +from fastapi.param_functions import Query +from fastapi.responses import FileResponse +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.state +from app.api.web.authentication import authenticate_player_session +from app.objects.player import Player +from app.objects.score import Score + +REPLAYS_PATH = SystemPath.cwd() / ".data/osr" + + +router = APIRouter() + + +@router.get("/osu-getreplay.php") +async def getReplay( + player: Player = Depends(authenticate_player_session(Query, "u", "h")), + mode: int = Query(..., alias="m", ge=0, le=3), + score_id: int = Query(..., alias="c", min=0, max=9_223_372_036_854_775_807), +) -> Response: + score = await Score.from_sql(score_id) + if not score: + return Response(b"", status_code=404) + + file = REPLAYS_PATH / f"{score_id}.osr" + if not file.exists(): + return Response(b"", status_code=404) + + # increment replay views for this score + if score.player is not None and player.id != score.player.id: + app.state.loop.create_task(score.increment_replay_views()) # type: ignore[unused-awaitable] + + return FileResponse(file) diff --git a/app/api/web/scoring.py b/app/api/web/scoring.py new file mode 100644 index 000000000..5df7a52ad --- /dev/null +++ b/app/api/web/scoring.py @@ -0,0 +1,622 @@ +from __future__ import annotations + +import copy +import hashlib +from pathlib import Path as SystemPath +from typing import Any + +from fastapi.datastructures import FormData +from fastapi.param_functions import File +from fastapi.param_functions import Form +from fastapi.param_functions import Header +from fastapi.requests import Request +from fastapi.responses import Response +from fastapi.routing import APIRouter +from starlette.datastructures import UploadFile as StarletteUploadFile + +import app.packets +import app.settings +import app.state +import app.utils +from app import encryption +from app._typing import UNSET +from app.constants.gamemodes import GameMode +from app.logging import Ansi +from app.logging import log +from app.objects.beatmap import Beatmap +from app.objects.beatmap import RankedStatus +from app.objects.beatmap import ensure_osu_file_is_available +from app.objects.score import Grade +from app.objects.score import Score +from app.objects.score import SubmissionStatus +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 user_achievements as user_achievements_usecases + +REPLAYS_PATH = SystemPath.cwd() / ".data/osr" + + +router = APIRouter() + + +def parse_form_data_score_params( + score_data: FormData, +) -> tuple[bytes, StarletteUploadFile] | None: + """Parse the score data, and replay file + from the form data's 'score' parameters.""" + try: + score_parts = score_data.getlist("score") + assert len(score_parts) == 2, "Invalid score data" + + score_data_b64 = score_data.getlist("score")[0] + assert isinstance(score_data_b64, str), "Invalid score data" + replay_file = score_data.getlist("score")[1] + assert isinstance(replay_file, StarletteUploadFile), "Invalid replay data" + except AssertionError as exc: + log(f"Failed to validate score multipart data: ({exc.args[0]})", Ansi.LRED) + return None + else: + return ( + score_data_b64.encode(), + replay_file, + ) + + +def chart_entry(name: str, before: float | None, after: float | None) -> str: + return f"{name}Before:{before or ''}|{name}After:{after or ''}" + + +def format_achievement_string(file: str, name: str, description: str) -> str: + return f"{file}+{name}+{description}" + + +@router.post("/osu-submit-modular-selector.php") +async def osuSubmitModularSelector( + request: Request, + # TODO: should token be allowed + # through but ac'd if not found? + # TODO: validate token format + # TODO: save token in the database + token: str = Header(...), + # TODO: do ft & st contain pauses? + exited_out: bool = Form(..., alias="x"), + fail_time: int = Form(..., alias="ft"), + visual_settings_b64: bytes = Form(..., alias="fs"), + updated_beatmap_hash: str = Form(..., alias="bmk"), + storyboard_md5: str | None = Form(None, alias="sbk"), + iv_b64: bytes = Form(..., alias="iv"), + unique_ids: str = Form(..., alias="c1"), + score_time: int = Form(..., alias="st"), + pw_md5: str = Form(..., alias="pass"), + osu_version: str = Form(..., alias="osuver"), + client_hash_b64: bytes = Form(..., alias="s"), + fl_cheat_screenshot: bytes | None = File(None, alias="i"), +) -> Response: + """Handle a score submission from an osu! client with an active session.""" + + if fl_cheat_screenshot: + stacktrace = app.utils.get_appropriate_stacktrace() + await app.state.services.log_strange_occurrence(stacktrace) + + # NOTE: the bancho protocol uses the "score" parameter name for both + # the base64'ed score data, and the replay file in the multipart + # starlette/fastapi do not support this, so we've moved it out + score_parameters = parse_form_data_score_params(await request.form()) + if score_parameters is None: + return Response(b"") + + # extract the score data and replay file from the score data + score_data_b64, replay_file = score_parameters + + # decrypt the score data (aes) + score_data, client_hash_decoded = encryption.decrypt_score_aes_data( + score_data_b64, + client_hash_b64, + iv_b64, + osu_version, + ) + + # fetch map & player + + bmap_md5 = score_data[0] + bmap = await Beatmap.from_md5(bmap_md5) + if not bmap: + # Map does not exist, most likely unsubmitted. + return Response(b"error: beatmap") + + # if the client has supporter, a space is appended + # but usernames may also end with a space, which must be preserved + username = score_data[1] + if username[-1] == " ": + username = username[:-1] + + player = await app.state.sessions.players.from_login(username, pw_md5) + if not player: + # Player is not online, return nothing so that their + # client will retry submission when they log in. + return Response(b"") + + # parse the score from the remaining data + score = Score.from_submission(score_data[2:]) + + # attach bmap & player + score.bmap = bmap + score.player = player + + ## perform checksum validation + + unique_id1, unique_id2 = unique_ids.split("|", maxsplit=1) + unique_id1_md5 = hashlib.md5(unique_id1.encode()).hexdigest() + unique_id2_md5 = hashlib.md5(unique_id2.encode()).hexdigest() + + try: + assert player.client_details is not None + + if osu_version != f"{player.client_details.osu_version.date:%Y%m%d}": + raise ValueError("osu! version mismatch") + + if client_hash_decoded != player.client_details.client_hash: + raise ValueError("client hash mismatch") + # assert unique ids (c1) are correct and match login params + if unique_id1_md5 != player.client_details.uninstall_md5: + raise ValueError( + f"unique_id1 mismatch ({unique_id1_md5} != {player.client_details.uninstall_md5})", + ) + + if unique_id2_md5 != player.client_details.disk_signature_md5: + raise ValueError( + f"unique_id2 mismatch ({unique_id2_md5} != {player.client_details.disk_signature_md5})", + ) + + # assert online checksums match + server_score_checksum = score.compute_online_checksum( + osu_version=osu_version, + osu_client_hash=client_hash_decoded, + storyboard_checksum=storyboard_md5 or "", + ) + if score.client_checksum != server_score_checksum: + raise ValueError( + f"online score checksum mismatch ({server_score_checksum} != {score.client_checksum})", + ) + + # assert beatmap hashes match + if bmap_md5 != updated_beatmap_hash: + raise ValueError( + f"beatmap hash mismatch ({bmap_md5} != {updated_beatmap_hash})", + ) + + except (ValueError, AssertionError): + # NOTE: this is undergoing a temporary trial period, + # after which, it will be enabled & perform restrictions. + stacktrace = app.utils.get_appropriate_stacktrace() + await app.state.services.log_strange_occurrence(stacktrace) + + # await player.restrict( + # admin=app.state.sessions.bot, + # reason="mismatching hashes on score submission", + # ) + + # refresh their client state + # if player.online: + # player.logout() + + # return b"error: ban" + + # we should update their activity no matter + # what the result of the score submission is. + score.player.update_latest_activity_soon() + + # make sure the player's client displays the correct mode's stats + if score.mode != score.player.status.mode: + score.player.status.mods = score.mods + score.player.status.mode = score.mode + + if not score.player.restricted: + app.state.sessions.players.enqueue(app.packets.user_stats(score.player)) + + # hold a lock around (check if submitted, submission) to ensure no duplicates + # are submitted to the database, and potentially award duplicate score/pp/etc. + async with app.state.score_submission_locks[score.client_checksum]: + # stop here if this is a duplicate score + if await app.state.services.database.fetch_one( + "SELECT 1 FROM scores WHERE online_checksum = :checksum", + {"checksum": score.client_checksum}, + ): + log(f"{score.player} submitted a duplicate score.", Ansi.LYELLOW) + return Response(b"error: no") + + # all data read from submission. + # now we can calculate things based on our data. + score.acc = score.calculate_accuracy() + + osu_file_available = await ensure_osu_file_is_available( + bmap.id, + expected_md5=bmap.md5, + ) + if osu_file_available: + score.pp, score.sr = score.calculate_performance(bmap.id) + + if score.passed: + await score.calculate_status() + + if score.bmap.status != RankedStatus.Pending: + score.rank = await score.calculate_placement() + else: + score.status = SubmissionStatus.FAILED + + score.time_elapsed = score_time if score.passed else fail_time + + # TODO: re-implement pp caps for non-whitelisted players? + + """ Score submission checks completed; submit the score. """ + + if app.state.services.datadog: + app.state.services.datadog.increment("bancho.submitted_scores") # type: ignore[no-untyped-call] + + if score.status == SubmissionStatus.BEST: + if app.state.services.datadog: + app.state.services.datadog.increment("bancho.submitted_scores_best") # type: ignore[no-untyped-call] + + if score.bmap.has_leaderboard: + if score.bmap.status == RankedStatus.Loved and score.mode in ( + GameMode.VANILLA_OSU, + GameMode.VANILLA_TAIKO, + GameMode.VANILLA_CATCH, + GameMode.VANILLA_MANIA, + ): + performance = f"{score.score:,} score" + else: + performance = f"{score.pp:,.2f}pp" + + score.player.enqueue( + app.packets.notification( + f"You achieved #{score.rank}! ({performance})", + ), + ) + + if score.rank == 1 and not score.player.restricted: + announce_chan = app.state.sessions.channels.get_by_name("#announce") + + ann = [ + f"\x01ACTION achieved #1 on {score.bmap.embed}", + f"with {score.acc:.2f}% for {performance}.", + ] + + if score.mods: + ann.insert(1, f"+{score.mods!r}") + + scoring_metric = ( + "pp" if score.mode >= GameMode.RELAX_OSU else "score" + ) + + # If there was previously a score on the map, add old #1. + prev_n1 = await app.state.services.database.fetch_one( + "SELECT u.id, name FROM users u " + "INNER JOIN scores s ON u.id = s.userid " + "WHERE s.map_md5 = :map_md5 AND s.mode = :mode " + "AND s.status = 2 AND u.priv & 1 " + f"ORDER BY s.{scoring_metric} DESC LIMIT 1", + {"map_md5": score.bmap.md5, "mode": score.mode}, + ) + + if prev_n1: + if score.player.id != prev_n1["id"]: + ann.append( + f"(Previous #1: [https://{app.settings.DOMAIN}/u/" + "{id} {name}])".format( + id=prev_n1["id"], + name=prev_n1["name"], + ), + ) + + assert announce_chan is not None + announce_chan.send(" ".join(ann), sender=score.player, to_self=True) + + # this score is our best score. + # update any preexisting personal best + # records with SubmissionStatus.SUBMITTED. + await app.state.services.database.execute( + "UPDATE scores SET status = 1 " + "WHERE status = 2 AND map_md5 = :map_md5 " + "AND userid = :user_id AND mode = :mode", + { + "map_md5": score.bmap.md5, + "user_id": score.player.id, + "mode": score.mode, + }, + ) + + score.id = await app.state.services.database.execute( + "INSERT INTO scores " + "VALUES (NULL, " + ":map_md5, :score, :pp, :acc, " + ":max_combo, :mods, :n300, :n100, " + ":n50, :nmiss, :ngeki, :nkatu, " + ":grade, :status, :mode, :play_time, " + ":time_elapsed, :client_flags, :user_id, :perfect, " + ":checksum)", + { + "map_md5": score.bmap.md5, + "score": score.score, + "pp": score.pp, + "acc": score.acc, + "max_combo": score.max_combo, + "mods": score.mods, + "n300": score.n300, + "n100": score.n100, + "n50": score.n50, + "nmiss": score.nmiss, + "ngeki": score.ngeki, + "nkatu": score.nkatu, + "grade": score.grade.name, + "status": score.status, + "mode": score.mode, + "play_time": score.server_time, + "time_elapsed": score.time_elapsed, + "client_flags": score.client_flags, + "user_id": score.player.id, + "perfect": score.perfect, + "checksum": score.client_checksum, + }, + ) + + if score.passed: + replay_data = await replay_file.read() + + MIN_REPLAY_SIZE = 24 + + if len(replay_data) >= MIN_REPLAY_SIZE: + replay_disk_file = REPLAYS_PATH / f"{score.id}.osr" + replay_disk_file.write_bytes(replay_data) + else: + log(f"{score.player} submitted a score without a replay!", Ansi.LRED) + + if not score.player.restricted: + await score.player.restrict( + admin=app.state.sessions.bot, + reason="submitted score with no replay", + ) + if score.player.is_online: + score.player.logout() + + """ Update the user's & beatmap's stats """ + + # get the current stats, and take a + # shallow copy for the response charts. + stats = score.player.stats[score.mode] + prev_stats = copy.copy(stats) + + # stuff update for all submitted scores + stats.playtime += score.time_elapsed // 1000 + stats.plays += 1 + stats.tscore += score.score + stats.total_hits += score.n300 + score.n100 + score.n50 + + if score.mode.as_vanilla in (1, 3): + # taiko uses geki & katu for hitting big notes with 2 keys + # mania uses geki & katu for rainbow 300 & 200 + stats.total_hits += score.ngeki + score.nkatu + + stats_updates: dict[str, Any] = { + "plays": stats.plays, + "playtime": stats.playtime, + "tscore": stats.tscore, + "total_hits": stats.total_hits, + } + + if score.passed and score.bmap.has_leaderboard: + # player passed & map is ranked, approved, or loved. + + if score.max_combo > stats.max_combo: + stats.max_combo = score.max_combo + stats_updates["max_combo"] = stats.max_combo + + if score.bmap.awards_ranked_pp and score.status == SubmissionStatus.BEST: + # map is ranked or approved, and it's our (new) + # best score on the map. update the player's + # ranked score, grades, pp, acc and global rank. + + additional_rscore = score.score + if score.prev_best: + # we previously had a score, so remove + # it's score from our ranked score. + additional_rscore -= score.prev_best.score + + if score.grade != score.prev_best.grade: + if score.grade >= Grade.A: + stats.grades[score.grade] += 1 + grade_col = format(score.grade, "stats_column") + stats_updates[grade_col] = stats.grades[score.grade] + + if score.prev_best.grade >= Grade.A: + stats.grades[score.prev_best.grade] -= 1 + grade_col = format(score.prev_best.grade, "stats_column") + stats_updates[grade_col] = stats.grades[score.prev_best.grade] + else: + # this is our first submitted score on the map + if score.grade >= Grade.A: + stats.grades[score.grade] += 1 + grade_col = format(score.grade, "stats_column") + stats_updates[grade_col] = stats.grades[score.grade] + + stats.rscore += additional_rscore + stats_updates["rscore"] = stats.rscore + + # fetch scores sorted by pp for total acc/pp calc + # NOTE: we select all plays (and not just top100) + # because bonus pp counts the total amount of ranked + # scores. I'm aware this scales horribly, and it'll + # likely be split into two queries in the future. + best_scores = await app.state.services.database.fetch_all( + "SELECT s.pp, s.acc FROM scores s " + "INNER JOIN maps m ON s.map_md5 = m.md5 " + "WHERE s.userid = :user_id AND s.mode = :mode " + "AND s.status = 2 AND m.status IN (2, 3) " # ranked, approved + "ORDER BY s.pp DESC", + {"user_id": score.player.id, "mode": score.mode}, + ) + + # calculate new total weighted accuracy + weighted_acc = sum( + row["acc"] * 0.95**i for i, row in enumerate(best_scores) + ) + bonus_acc = 100.0 / (20 * (1 - 0.95 ** len(best_scores))) + stats.acc = (weighted_acc * bonus_acc) / 100 + stats_updates["acc"] = stats.acc + + # calculate new total weighted pp + weighted_pp = sum(row["pp"] * 0.95**i for i, row in enumerate(best_scores)) + bonus_pp = 416.6667 * (1 - 0.9994 ** len(best_scores)) + stats.pp = round(weighted_pp + bonus_pp) + stats_updates["pp"] = stats.pp + + # update global & country ranking + stats.rank = await score.player.update_rank(score.mode) + + await stats_repo.partial_update( + score.player.id, + score.mode.value, + plays=stats_updates.get("plays", UNSET), + playtime=stats_updates.get("playtime", UNSET), + tscore=stats_updates.get("tscore", UNSET), + total_hits=stats_updates.get("total_hits", UNSET), + max_combo=stats_updates.get("max_combo", UNSET), + xh_count=stats_updates.get("xh_count", UNSET), + x_count=stats_updates.get("x_count", UNSET), + sh_count=stats_updates.get("sh_count", UNSET), + s_count=stats_updates.get("s_count", UNSET), + a_count=stats_updates.get("a_count", UNSET), + rscore=stats_updates.get("rscore", UNSET), + acc=stats_updates.get("acc", UNSET), + pp=stats_updates.get("pp", UNSET), + ) + + if not score.player.restricted: + # enqueue new stats info to all other users + app.state.sessions.players.enqueue(app.packets.user_stats(score.player)) + + # update beatmap with new stats + score.bmap.plays += 1 + if score.passed: + score.bmap.passes += 1 + + await app.state.services.database.execute( + "UPDATE maps SET plays = :plays, passes = :passes WHERE md5 = :map_md5", + { + "plays": score.bmap.plays, + "passes": score.bmap.passes, + "map_md5": score.bmap.md5, + }, + ) + + # update their recent score + score.player.recent_scores[score.mode] = score + + """ score submission charts """ + + # charts are only displayed for passes vanilla gamemodes. + if not score.passed: # TODO: check if this is correct + response = b"error: no" + else: + # construct and send achievements & ranking charts to the client + if score.bmap.awards_ranked_pp and not score.player.restricted: + unlocked_achievements: list[Achievement] = [] + + server_achievements = await achievements_usecases.fetch_many() + player_achievements = await user_achievements_usecases.fetch_many( + user_id=score.player.id, + ) + + for server_achievement in server_achievements: + player_unlocked_achievement = any( + player_achievement + for player_achievement in player_achievements + if player_achievement["achid"] == server_achievement["id"] + ) + if player_unlocked_achievement: + # player already has this achievement. + continue + + achievement_condition = server_achievement["cond"] + if achievement_condition(score, score.mode.as_vanilla): + await user_achievements_usecases.create( + score.player.id, + server_achievement["id"], + ) + unlocked_achievements.append(server_achievement) + + achievements_str = "/".join( + format_achievement_string(a["file"], a["name"], a["desc"]) + for a in unlocked_achievements + ) + else: + achievements_str = "" + + # create score submission charts for osu! client to display + + if score.prev_best: + beatmap_ranking_chart_entries = ( + chart_entry("rank", score.prev_best.rank, score.rank), + chart_entry("rankedScore", score.prev_best.score, score.score), + chart_entry("totalScore", score.prev_best.score, score.score), + chart_entry("maxCombo", score.prev_best.max_combo, score.max_combo), + chart_entry( + "accuracy", + round(score.prev_best.acc, 2), + round(score.acc, 2), + ), + chart_entry("pp", score.prev_best.pp, score.pp), + ) + else: + # no previous best score + beatmap_ranking_chart_entries = ( + chart_entry("rank", None, score.rank), + chart_entry("rankedScore", None, score.score), + chart_entry("totalScore", None, score.score), + chart_entry("maxCombo", None, score.max_combo), + chart_entry("accuracy", None, round(score.acc, 2)), + chart_entry("pp", None, score.pp), + ) + + overall_ranking_chart_entries = ( + chart_entry("rank", prev_stats.rank, stats.rank), + chart_entry("rankedScore", prev_stats.rscore, stats.rscore), + chart_entry("totalScore", prev_stats.tscore, stats.tscore), + chart_entry("maxCombo", prev_stats.max_combo, stats.max_combo), + chart_entry("accuracy", round(prev_stats.acc, 2), round(stats.acc, 2)), + chart_entry("pp", prev_stats.pp, stats.pp), + ) + + submission_charts = [ + # beatmap info chart + f"beatmapId:{score.bmap.id}", + f"beatmapSetId:{score.bmap.set_id}", + f"beatmapPlaycount:{score.bmap.plays}", + f"beatmapPasscount:{score.bmap.passes}", + f"approvedDate:{score.bmap.last_update}", + "\n", + # beatmap ranking chart + "chartId:beatmap", + f"chartUrl:{score.bmap.set.url}", + "chartName:Beatmap Ranking", + *beatmap_ranking_chart_entries, + f"onlineScoreId:{score.id}", + "\n", + # overall ranking chart + "chartId:overall", + f"chartUrl:https://{app.settings.DOMAIN}/u/{score.player.id}", + "chartName:Overall Ranking", + *overall_ranking_chart_entries, + f"achievements-new:{achievements_str}", + ] + + response = "|".join(submission_charts).encode() + + log( + f"[{score.mode!r}] {score.player} submitted a score! " + f"({score.status!r}, {score.pp:,.2f}pp / {stats.pp:,}pp)", + Ansi.LGREEN, + ) + + return Response(response) diff --git a/app/api/web/screenshots.py b/app/api/web/screenshots.py new file mode 100644 index 000000000..cc76358a2 --- /dev/null +++ b/app/api/web/screenshots.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +import secrets +from pathlib import Path as SystemPath + +from fastapi import status +from fastapi.datastructures import UploadFile +from fastapi.param_functions import Depends +from fastapi.param_functions import File +from fastapi.param_functions import Form +from fastapi.responses import Response +from fastapi.routing import APIRouter + +import app.state +import app.utils +from app.api.web.authentication import authenticate_player_session +from app.logging import log +from app.objects.player import Player + +SCREENSHOTS_PATH = SystemPath.cwd() / ".data/ss" + +router = APIRouter() + + +@router.post("/osu-screenshot.php") +async def osuScreenshot( + player: Player = Depends(authenticate_player_session(Form, "u", "p")), + endpoint_version: int = Form(..., alias="v"), + screenshot_file: UploadFile = File(..., alias="ss"), +) -> Response: + with memoryview(await screenshot_file.read()) as screenshot_view: + # png sizes: 1080p: ~300-800kB | 4k: ~1-2mB + if len(screenshot_view) > (4 * 1024 * 1024): + return Response( + content=b"Screenshot file too large.", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + if endpoint_version != 1: + await app.state.services.log_strange_occurrence( + f"Incorrect endpoint version (/web/osu-screenshot.php v{endpoint_version})", + ) + + if app.utils.has_jpeg_headers_and_trailers(screenshot_view): + extension = "jpeg" + elif app.utils.has_png_headers_and_trailers(screenshot_view): + extension = "png" + else: + return Response( + content=b"Invalid file type", + status_code=status.HTTP_400_BAD_REQUEST, + ) + + while True: + filename = f"{secrets.token_urlsafe(6)}.{extension}" + ss_file = SCREENSHOTS_PATH / filename + if not ss_file.exists(): + break + + with ss_file.open("wb") as f: + f.write(screenshot_view) + + log(f"{player} uploaded {filename}.") + return Response(filename.encode()) diff --git a/app/api/web/updates.py b/app/api/web/updates.py new file mode 100644 index 000000000..272870a2e --- /dev/null +++ b/app/api/web/updates.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from typing import Literal + +from fastapi.requests import Request +from fastapi.responses import Response +from fastapi.routing import APIRouter + +router = APIRouter() + + +@router.get("/check-updates.php") +async def checkUpdates( + request: Request, + action: Literal["check", "path", "error"], + stream: Literal["cuttingedge", "stable40", "beta40", "stable"], +) -> Response: + return Response(b"")