diff --git a/app/api/v2/__init__.py b/app/api/v2/__init__.py index 13faca62..418217d7 100644 --- a/app/api/v2/__init__.py +++ b/app/api/v2/__init__.py @@ -1,9 +1,43 @@ # isort: dont-add-imports +from typing import Any + from fastapi import APIRouter +from fastapi import Depends +from fastapi import HTTPException +from fastapi import status + +from app.api.v2.common.oauth import OAuth2Scheme +from app.repositories import access_tokens as access_tokens_repo + +oauth2_scheme = OAuth2Scheme( + authorizationUrl="/v2/oauth/authorize", + tokenUrl="/v2/oauth/token", + refreshUrl="/v2/oauth/refresh", + scheme_name="OAuth2 for third-party clients.", + scopes={ + "public": "Access endpoints with public data.", + "identify": "Access endpoints with user's data.", + "admin": "Access admin endpoints.", + }, +) + + +async def get_current_client(token: str = Depends(oauth2_scheme)) -> dict[str, Any]: + """Look up the token in the Redis-based token store""" + access_token = await access_tokens_repo.fetch_one(token) + if not access_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return access_token + from . import clans from . import maps +from . import oauth from . import players from . import scores @@ -13,3 +47,4 @@ apiv2_router.include_router(maps.router) apiv2_router.include_router(players.router) apiv2_router.include_router(scores.router) +apiv2_router.include_router(oauth.router) diff --git a/app/api/v2/common/json.py b/app/api/v2/common/json.py index e5679918..f5c1ed8c 100644 --- a/app/api/v2/common/json.py +++ b/app/api/v2/common/json.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any +from uuid import UUID import orjson from fastapi.responses import JSONResponse @@ -14,6 +15,8 @@ def _default_processor(data: Any) -> Any: return {k: _default_processor(v) for k, v in data.items()} elif isinstance(data, list): return [_default_processor(v) for v in data] + elif isinstance(data, UUID): + return str(data) else: return data @@ -22,8 +25,12 @@ def dumps(data: Any) -> bytes: return orjson.dumps(data, default=_default_processor) +def loads(data: str) -> Any: + return orjson.loads(data) + + class ORJSONResponse(JSONResponse): - media_type = "application/json" + media_type = "application/json;charset=UTF-8" def render(self, content: Any) -> bytes: return dumps(content) diff --git a/app/api/v2/common/oauth.py b/app/api/v2/common/oauth.py new file mode 100644 index 00000000..0e445544 --- /dev/null +++ b/app/api/v2/common/oauth.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import base64 + +from fastapi import Request +from fastapi import status +from fastapi.exceptions import HTTPException +from fastapi.openapi.models import OAuthFlowAuthorizationCode +from fastapi.openapi.models import OAuthFlowClientCredentials +from fastapi.openapi.models import OAuthFlows +from fastapi.security import OAuth2 +from fastapi.security.utils import get_authorization_scheme_param + + +class OAuth2Scheme(OAuth2): + def __init__( + self, + authorizationUrl: str, + tokenUrl: str, + refreshUrl: str | None = None, + scheme_name: str | None = None, + scopes: dict[str, str] | None = None, + description: str | None = None, + auto_error: bool = True, + ): + if not scopes: + scopes = {} + flows = OAuthFlows( + authorizationCode=OAuthFlowAuthorizationCode( + authorizationUrl=authorizationUrl, + tokenUrl=tokenUrl, + scopes=scopes, + refreshUrl=refreshUrl, + ), + clientCredentials=OAuthFlowClientCredentials( + tokenUrl=tokenUrl, + scopes=scopes, + refreshUrl=refreshUrl, + ), + ) + super().__init__( + flows=flows, + scheme_name=scheme_name, + description=description, + auto_error=auto_error, + ) + + async def __call__(self, request: Request) -> str | None: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "bearer": + if self.auto_error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + else: + return None + return param + + +# https://developer.zendesk.com/api-reference/sales-crm/authentication/requests/#client-authentication +def get_credentials_from_basic_auth( + request: Request, +) -> dict[str, str | int] | None: + authorization = request.headers.get("Authorization") + scheme, param = get_authorization_scheme_param(authorization) + if not authorization or scheme.lower() != "basic": + return None + + data = base64.b64decode(param).decode("utf-8") + if ":" not in data: + return None + + split = data.split(":") + if len(split) != 2: + return None + if not split[0].isdecimal(): + return None + + return { + "client_id": int(split[0]), + "client_secret": split[1], + } diff --git a/app/api/v2/models/oauth.py b/app/api/v2/models/oauth.py new file mode 100644 index 00000000..381525c8 --- /dev/null +++ b/app/api/v2/models/oauth.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime +from enum import StrEnum +from typing import Literal + +from . import BaseModel + +# input models + + +# output models + + +class GrantType(StrEnum): + AUTHORIZATION_CODE = "authorization_code" + CLIENT_CREDENTIALS = "client_credentials" + + # TODO: Add support for other grant types + + +class Token(BaseModel): + access_token: str + refresh_token: str | None + token_type: Literal["Bearer"] + expires_in: int + expires_at: datetime + scope: str diff --git a/app/api/v2/oauth.py b/app/api/v2/oauth.py new file mode 100644 index 00000000..3e582175 --- /dev/null +++ b/app/api/v2/oauth.py @@ -0,0 +1,213 @@ +""" bancho.py's v2 apis for interacting with clans """ + +from __future__ import annotations + +import uuid +from typing import Any + +from fastapi import APIRouter +from fastapi import Depends +from fastapi import Response +from fastapi import status +from fastapi.param_functions import Form +from fastapi.param_functions import Query + +from app.api.v2 import get_current_client +from app.api.v2.common.oauth import get_credentials_from_basic_auth +from app.api.v2.models.oauth import GrantType +from app.api.v2.models.oauth import Token +from app.repositories import access_tokens as access_tokens_repo +from app.repositories import authorization_codes as authorization_codes_repo +from app.repositories import ouath_clients as clients_repo +from app.repositories import refresh_tokens as refresh_tokens_repo + +router = APIRouter() + + +def oauth_failure_response(reason: str) -> dict[str, Any]: + return {"error": reason} + + +@router.get("/oauth/authorize", status_code=status.HTTP_302_FOUND) +async def authorize( + client_id: int = Query(), + redirect_uri: str = Query(), + response_type: str = Query(regex="code"), + player_id: int = Query(), + scope: str = Query(default="", regex=r"\b\w+\b(?:,\s*\b\w+\b)*"), + state: str | None = Query(default=None), +): + """Authorize a client to access the API on behalf of a user.""" + # NOTE: We should have to implement the frontend part to request the user to authorize the client + # and then redirect to the redirect_uri with the code. + # For now, we just return the code and the state if it's provided. + client = await clients_repo.fetch_one(client_id) + if client is None: + return oauth_failure_response("invalid_client") + + if client["redirect_uri"] != redirect_uri: + return oauth_failure_response("invalid_client") + + if response_type != "code": + return oauth_failure_response("unsupported_response_type") + + code = uuid.uuid4() + await authorization_codes_repo.create(code, client_id, scope, player_id) + + if state is None: + redirect_uri = f"{redirect_uri}?code={code}" + else: + redirect_uri = f"{redirect_uri}?code={code}&state={state}" + + # return RedirectResponse(redirect_uri, status_code=status.HTTP_302_FOUND) + return redirect_uri + + +@router.post("/oauth/token", status_code=status.HTTP_200_OK) +async def token( + response: Response, + grant_type: GrantType = Form(), + client_id: int | None = Form(default=None), + client_secret: str | None = Form(default=None), + auth_credentials: dict[str, Any] | None = Depends( + get_credentials_from_basic_auth, + ), + code: str | None = Form(default=None), + scope: str = Form(default="", regex=r"\b\w+\b(?:,\s*\b\w+\b)*"), +): + """Get an access token for the API.""" + # https://www.rfc-editor.org/rfc/rfc6749#section-5.1 + response.headers["Content-Type"] = "application/json; charset=utf-8" + response.headers["Cache-Control"] = "no-store, private" + response.headers["Pragma"] = "no-cache" + + if (client_id is None or client_secret is None) and auth_credentials is None: + return oauth_failure_response("invalid_request") + + if client_id is None and client_secret is None: + if auth_credentials is None: + return oauth_failure_response("invalid_request") + else: + client_id = auth_credentials["client_id"] + client_secret = auth_credentials["client_secret"] + + client = await clients_repo.fetch_one(client_id) + if client is None: + return oauth_failure_response("invalid_client") + + if client["secret"] != client_secret: + return oauth_failure_response("invalid_client") + + if grant_type is GrantType.AUTHORIZATION_CODE: + if code is None: + return oauth_failure_response("invalid_request") + + authorization_code = await authorization_codes_repo.fetch_one(code) + if not authorization_code: + return oauth_failure_response("invalid_grant") + + if client_id is None or authorization_code["client_id"] != client_id: + return oauth_failure_response("invalid_client") + + if authorization_code["scopes"] != scope: + return oauth_failure_response("invalid_scope") + await authorization_codes_repo.delete(code) + + refresh_token = uuid.uuid4() + raw_access_token = uuid.uuid4() + + access_token = await access_tokens_repo.create( + raw_access_token, + client_id, + grant_type, + scope, + refresh_token, + authorization_code["player_id"], + ) + await refresh_tokens_repo.create( + refresh_token, + raw_access_token, + client_id, + scope, + ) + + return Token( + access_token=str(raw_access_token), + refresh_token=str(refresh_token), + token_type="Bearer", + expires_in=3600, + expires_at=access_token["expires_at"], + scope=scope, + ) + elif grant_type is GrantType.CLIENT_CREDENTIALS: + if client_id is None: + return oauth_failure_response("invalid_client") + + client = await clients_repo.fetch_one(client_id) + if client is None: + return oauth_failure_response("invalid_client") + + if client["secret"] != client_secret: + return oauth_failure_response("invalid_client") + + raw_access_token = uuid.uuid4() + access_token = await access_tokens_repo.create( + raw_access_token, + client_id, + grant_type, + scope, + expires_in=86400, + ) + + return Token( + access_token=str(raw_access_token), + refresh_token=None, + token_type="Bearer", + expires_in=86400, + expires_at=access_token["expires_at"], + scope=scope, + ) + else: + return oauth_failure_response("unsupported_grant_type") + + +@router.post("/oauth/refresh", status_code=status.HTTP_200_OK) +async def refresh( + response: Response, + client: dict[str, Any] = Depends(get_current_client), + grant_type: str = Form(), + refresh_token: str = Form(), +): + """Refresh an access token.""" + # https://www.rfc-editor.org/rfc/rfc6749#section-5.1 + response.headers["Content-Type"] = "application/json; charset=utf-8" + response.headers["Cache-Control"] = "no-store, private" + response.headers["Pragma"] = "no-cache" + + if grant_type != "refresh_token": + return oauth_failure_response("unsupported_grant_type") + + if client["grant_type"] != "authorization_code": + return oauth_failure_response("invalid_grant") + + if client["refresh_token"] != refresh_token: + return oauth_failure_response("invalid_grant") + + raw_access_token = uuid.uuid4() + access_token = await access_tokens_repo.create( + raw_access_token, + client["client_id"], + client["grant_type"], + client["scope"], + refresh_token, + client["player_id"], + ) + + return Token( + access_token=str(raw_access_token), + refresh_token=refresh_token, + token_type="Bearer", + expires_in=3600, + expires_at=access_token["expires_at"], + scope=access_token["scope"], + ) diff --git a/app/repositories/access_tokens.py b/app/repositories/access_tokens.py new file mode 100644 index 00000000..47a23491 --- /dev/null +++ b/app/repositories/access_tokens.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from datetime import datetime +from datetime import timedelta +from typing import Any +from typing import Literal +from typing import TypedDict +from uuid import UUID + +import app.state.services +from app.api.v2.common import json + +ACCESS_TOKEN_TTL = timedelta(hours=1) + + +class AccessToken(TypedDict): + refresh_token: UUID | None + client_id: int + grant_type: str + scope: str + player_id: int | None + created_at: datetime + expires_at: datetime + + +def create_access_token_key(code: UUID | Literal["*"]) -> str: + return f"bancho:access_tokens:{code}" + + +async def create( + access_token_id: UUID, + client_id: int, + grant_type: str, + scope: str, + refresh_token: UUID | None = None, + player_id: int | None = None, +) -> AccessToken: + now = datetime.now() + expires_at = now + ACCESS_TOKEN_TTL + access_token: AccessToken = { + "refresh_token": refresh_token, + "client_id": client_id, + "grant_type": grant_type, + "scope": scope, + "player_id": player_id, + "created_at": now, + "expires_at": expires_at, + } + await app.state.services.redis.set( + create_access_token_key(access_token_id), + json.dumps(access_token), + exat=expires_at, + ) + return access_token + + +async def fetch_one(access_token_id: UUID) -> AccessToken | None: + raw_access_token = await app.state.services.redis.get( + create_access_token_key(access_token_id), + ) + if raw_access_token is None: + return None + return json.loads(raw_access_token) + + +async def fetch_all( + client_id: int | None = None, + scope: str | None = None, + grant_type: str | None = None, + player_id: int | None = None, + page: int = 1, + page_size: int = 10, +) -> list[AccessToken]: + access_token_key = create_access_token_key("*") + + if page > 1: + cursor, keys = await app.state.services.redis.scan( + cursor=0, + match=access_token_key, + count=(page - 1) * page_size, + ) + else: + cursor = None + + access_tokens = [] + while cursor != 0: + cursor, keys = await app.state.services.redis.scan( + cursor=cursor or 0, + match=access_token_key, + count=page_size, + ) + + raw_access_token = await app.state.services.redis.mget(keys) + for raw_access_token in raw_access_token: + access_token = json.loads(raw_access_token) + + if client_id is not None and access_token["client_id"] != client_id: + continue + + if scope is not None and access_token["scopes"] != scope: + continue + + if grant_type is not None and access_token["grant_type"] != grant_type: + continue + + if player_id is not None and access_token["player_id"] != player_id: + continue + + access_tokens.append(access_token) + + return access_tokens + + +async def delete(access_token_id: UUID) -> AccessToken | None: + access_token_key = create_access_token_key(access_token_id) + + raw_access_token = await app.state.services.redis.get(access_token_key) + if raw_access_token is None: + return None + + await app.state.services.redis.delete(access_token_key) + + return json.loads(raw_access_token) diff --git a/app/repositories/authorization_codes.py b/app/repositories/authorization_codes.py new file mode 100644 index 00000000..f8fdc629 --- /dev/null +++ b/app/repositories/authorization_codes.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from datetime import datetime +from datetime import timedelta +from typing import Literal +from typing import TypedDict +from uuid import UUID + +import app.state.services +from app.api.v2.common import json + +AUTHORIZATION_CODE_TTL = timedelta(minutes=3) + + +class AuthorizationCode(TypedDict): + client_id: int + scope: str + player_id: int + created_at: datetime + expires_at: datetime + + +def create_authorization_code_key(code: UUID | Literal["*"]) -> str: + return f"bancho:authorization_codes:{code}" + + +async def create( + code: UUID, + client_id: int, + scope: str, + player_id: int, +) -> AuthorizationCode: + now = datetime.now() + expires_at = now + AUTHORIZATION_CODE_TTL + authorization_code: AuthorizationCode = { + "client_id": client_id, + "scope": scope, + "player_id": player_id, + "created_at": now, + "expires_at": expires_at, + } + await app.state.services.redis.set( + create_authorization_code_key(code), + json.dumps(authorization_code), + exat=expires_at, + ) + return authorization_code + + +async def fetch_one(code: UUID) -> AuthorizationCode | None: + raw_authorization_code = await app.state.services.redis.get( + create_authorization_code_key(code), + ) + if raw_authorization_code is None: + return None + + return json.loads(raw_authorization_code) + + +async def fetch_all( + client_id: int | None = None, + scope: str | None = None, + page: int = 1, + page_size: int = 10, +) -> list[AuthorizationCode]: + authorization_code_key = create_authorization_code_key("*") + + if page > 1: + cursor, keys = await app.state.services.redis.scan( + cursor=0, + match=authorization_code_key, + count=(page - 1) * page_size, + ) + else: + cursor = None + + authorization_codes = [] + while cursor != 0: + cursor, keys = await app.state.services.redis.scan( + cursor=cursor or 0, + match=authorization_code_key, + count=page_size, + ) + + raw_authorization_code = await app.state.services.redis.mget(keys) + for raw_authorization_code in raw_authorization_code: + authorization_code = json.loads(raw_authorization_code) + + if client_id is not None and authorization_code["client_id"] != client_id: + continue + + if scope is not None and authorization_code["scope"] != scope: + continue + + authorization_codes.append(authorization_code) + + return authorization_codes + + +async def delete(code: UUID) -> AuthorizationCode | None: + authorization_code_key = create_authorization_code_key(code) + + raw_authorization_code = await app.state.services.redis.get(authorization_code_key) + if raw_authorization_code is None: + return None + + await app.state.services.redis.delete(authorization_code_key) + + return json.loads(raw_authorization_code) diff --git a/app/repositories/ouath_clients.py b/app/repositories/ouath_clients.py new file mode 100644 index 00000000..d4626c73 --- /dev/null +++ b/app/repositories/ouath_clients.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import textwrap +from typing import Any +from typing import Optional + +import app.state.services + +# +--------------+-------------+------+-----+---------+----------------+ +# | Field | Type | Null | Key | Default | Extra | +# +--------------+-------------+------+-----+---------+----------------+ +# | id | int | NO | PRI | NULL | auto_increment | +# | name | varchar(16) | YES | | NULL | | +# | secret | varchar(32) | NO | | NULL | | +# | owner | int | NO | | NULL | | +# | redirect_uri | text | YES | | NULL | | +# +--------------+-------------+------+-----+---------+----------------+ + +READ_PARAMS = textwrap.dedent( + """\ + id, name, secret, owner, redirect_uri + """, +) + + +async def create( + secret: str, + owner: int, + name: str | None = None, + redirect_uri: str | None = None, +) -> dict[str, Any]: + """Create a new client in the database.""" + query = """\ + INSERT INTO oauth_clients (secret, owner, name, redirect_uri) + VALUES (:secret, :owner, :name, :redirect_uri) + """ + params = { + "secret": secret, + "owner": owner, + "name": name, + "redirect_uri": redirect_uri, + } + rec_id = await app.state.services.database.execute(query, params) + + query = f"""\ + SELECT {READ_PARAMS} + FROM oauth_clients + WHERE id = :id + """ + params = { + "id": rec_id, + } + + rec = await app.state.services.database.fetch_one(query, params) + assert rec is not None + return dict(rec) + + +async def fetch_one( + id: int | None = None, + owner: int | None = None, + secret: str | None = None, + name: str | None = None, +) -> dict[str, Any] | None: + """Fetch a signle client from the database.""" + if id is None and owner is None and secret is None: + raise ValueError("Must provide at least one parameter.") + + query = f"""\ + SELECT {READ_PARAMS} + FROM oauth_clients + WHERE id = COALESCE(:id, id) + AND owner = COALESCE(:owner, owner) + AND secret = COALESCE(:secret, secret) + AND name = COALESCE(:name, name) + """ + params = { + "id": id, + "owner": owner, + "secret": secret, + "name": name, + } + rec = await app.state.services.database.fetch_one(query, params) + return dict(rec) if rec is not None else None + + +async def fetch_many( + id: int | None = None, + owner: int | None = None, + secret: str | None = None, + page: int | None = None, + page_size: int | None = None, +) -> list[dict[str, Any]] | None: + """Fetch all clients from the database.""" + query = f"""\ + SELECT {READ_PARAMS} + FROM oauth_clients + WHERE id = COALESCE(:id, id) + AND owner = COALESCE(:owner, owner) + AND secret = COALESCE(:secret, secret) + """ + params = { + "id": id, + "owner": owner, + "secret": secret, + } + + if page is not None and page_size is not None: + query += """\ + LIMIT :limit + OFFSET :offset + """ + params["limit"] = page_size + params["offset"] = (page - 1) * page_size + + rec = await app.state.services.database.fetch_one(query, params) + return dict(rec) if rec is not None else None + + +async def update( + id: int, + secret: str | None = None, + owner: int | None = None, + name: str | None = None, + redirect_uri: str | None = None, +) -> dict[str, Any] | None: + """Update an existing client in the database.""" + query = """\ + UPDATE oauth_clients + SET secret = COALESCE(:secret, secret), + owner = COALESCE(:owner, owner), + redirect_uri = COALESCE(:redirect_uri, redirect_uri) + name = COALESCE(:name, name) + WHERE id = :id + """ + params = { + "id": id, + "secret": secret, + "owner": owner, + "name": name, + "redirect_uri": redirect_uri, + } + await app.state.services.database.execute(query, params) + + query = f"""\ + SELECT {READ_PARAMS} + FROM oauth_clients + WHERE id = :id + """ + params = { + "id": id, + } + rec = await app.state.services.database.fetch_one(query, params) + return dict(rec) if rec is not None else None diff --git a/app/repositories/refresh_tokens.py b/app/repositories/refresh_tokens.py new file mode 100644 index 00000000..b1856a15 --- /dev/null +++ b/app/repositories/refresh_tokens.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from datetime import datetime +from datetime import timedelta +from typing import Literal +from typing import TypedDict +from uuid import UUID + +import app.state.services +from app.api.v2.common import json + + +class RefreshToken(TypedDict): + client_id: int + scope: str + refresh_token_id: UUID + access_token_id: UUID + created_at: datetime + expires_at: datetime + + +def create_refresh_token_key(code: UUID | Literal["*"]) -> str: + return f"bancho:refresh_tokens:{code}" + + +async def create( + refresh_token_id: UUID, + access_token_id: UUID, + client_id: int, + scope: str, +) -> RefreshToken: + now = datetime.now() + expires_at = now + timedelta(days=30) + refresh_token: RefreshToken = { + "client_id": client_id, + "scope": scope, + "refresh_token_id": refresh_token_id, + "access_token_id": access_token_id, + "created_at": now, + "expires_at": expires_at, + } + await app.state.services.redis.set( + create_refresh_token_key(refresh_token_id), + json.dumps(refresh_token), + exat=expires_at, + ) + return refresh_token + + +async def fetch_one(refresh_token_id: UUID) -> RefreshToken | None: + raw_refresh_token = await app.state.services.redis.get( + create_refresh_token_key(refresh_token_id), + ) + if raw_refresh_token is None: + return None + + return json.loads(raw_refresh_token) + + +async def fetch_all( + client_id: int | None = None, + scope: str | None = None, + page: int = 1, + page_size: int = 10, +) -> list[RefreshToken]: + refresh_token_key = create_refresh_token_key("*") + + if page > 1: + cursor, keys = await app.state.services.redis.scan( + cursor=0, + match=refresh_token_key, + count=(page - 1) * page_size, + ) + else: + cursor = None + + refresh_tokens = [] + while cursor != 0: + cursor, keys = await app.state.services.redis.scan( + cursor=cursor or 0, + match=refresh_token_key, + count=page_size, + ) + + raw_refresh_token = await app.state.services.redis.mget(keys) + for raw_refresh_token in raw_refresh_token: + refresh_token = json.loads(raw_refresh_token) + + if client_id is not None and refresh_token["client_id"] != client_id: + continue + + if scope is not None and refresh_token["scope"] != scope: + continue + + refresh_tokens.append(refresh_token) + + return refresh_tokens + + +async def delete(refresh_token_id: UUID) -> RefreshToken | None: + refresh_token_key = create_refresh_token_key(refresh_token_id) + + raw_refresh_token = await app.state.services.redis.get(refresh_token_key) + if raw_refresh_token is None: + return None + + await app.state.services.redis.delete(refresh_token_key) + + return json.loads(raw_refresh_token) diff --git a/app/state/services.py b/app/state/services.py index 0365a552..3c729c8b 100644 --- a/app/state/services.py +++ b/app/state/services.py @@ -40,7 +40,7 @@ http_client = httpx.AsyncClient() database = databases.Database(app.settings.DB_DSN) -redis: aioredis.Redis = aioredis.from_url(app.settings.REDIS_DSN) +redis: aioredis.Redis = aioredis.from_url(app.settings.REDIS_DSN, decode_responses=True) datadog: datadog_client.ThreadStats | None = None if str(app.settings.DATADOG_API_KEY) and str(app.settings.DATADOG_APP_KEY): diff --git a/migrations/migrations.sql b/migrations/migrations.sql index e524aace..4f57865a 100644 --- a/migrations/migrations.sql +++ b/migrations/migrations.sql @@ -409,3 +409,13 @@ alter table maps drop primary key; alter table maps add primary key (id); alter table maps modify column server enum('osu!', 'private') not null default 'osu!' after id; unlock tables; + +# v4.7.3 +CREATE TABLE oauth_clients ( + id INT(10) NOT NULL AUTO_INCREMENT, + name VARCHAR(16) NULL DEFAULT NULL, + secret VARCHAR(32) NOT NULL, + owner INT(10) NOT NULL, + redirect_uri TEXT NULL DEFAULT NULL, + PRIMARY KEY (`id`) USING BTREE +)