Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions app/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions app/api/bancho/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
198 changes: 198 additions & 0 deletions app/api/bancho/users.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where were the contents of cho.py migrated to? that's the part i'd expect to have under bancho/

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what I migrated was osu.py, cho.py is still pending, in bancho/users.py currently there is only the register_account method that was in osu.py, should it be in web/instead of bancho/?

Original file line number Diff line number Diff line change
@@ -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
1 change: 0 additions & 1 deletion app/api/domains/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,3 @@

from . import cho
from . import map
from . import osu
Loading