"""API server implementation. Not to be confused with the HTTP requests implementation. HTTP Request handler can be found at gd/utils/http_request.py. """ import asyncio import functools import json import platform import re import secrets import time from pathlib import Path from aiohttp import web import aiohttp import multidict from gd.typing import ( Any, Callable, Dict, Generator, Iterable, List, Optional, Sequence, Tuple, Type, Union, ref, ) import gd AUTH_RE = re.compile(r"(?:Token )?(?P<token>[A-Fa-z0-9]+)") CHUNK_SIZE = 64 * 1024 CLIENT = gd.Client(debug=True) JSON_PREFIX = "json_" ROOT_PATH = Path(".gd") Function = Callable[[Any], Any] Error = ref("gd.server.Error") Cooldown = ref("gd.server.Cooldown") CooldownMapping = ref("gd.server.CooldownMapping") routes = web.RouteTableDef() class ErrorType(gd.Enum): DEFAULT = 13000 INVALID_TYPE = 13001 MISSING_PARAMETER = 13002 ACCESS_RESTRICTED = 13003 NOT_FOUND = 13004 FAILED = 13005 LOGIN_FAILED = 13006 RATE_LIMIT_EXCEEDED = 13007 AUTH_INVALID = 13101 AUTH_MISSING = 13102 AUTH_NOT_SET = 13103 class Error: def __init__( self, resp_code: int, message: str, error_type: Union[int, str, ErrorType] = ErrorType.DEFAULT, **additional, ) -> None: error_type = ErrorType.from_value(error_type) self.payload = { "status": resp_code, "data": { "message": message, "code": error_type.value, "code_name": error_type.desc, "error": None, "error_message": None, **additional, }, } def set_error(self, error: BaseException) -> Error: to_add = { # format message with error object "message": self.payload["data"]["message"].format(error=error), "error": type(error).__name__, "error_message": str(error), } self.payload["data"].update(to_add) return self def into_resp(self, **kwargs) -> web.Response: return json_resp(**self.payload, **kwargs) def create_retry_after(retry_after: float) -> Error: return Error( 429, f"Retry after {retry_after:.2f} seconds.", ErrorType.RATE_LIMIT_EXCEEDED, retry_after=retry_after, ) DEFAULT_ERROR = Error( 500, ( "Unexcepted error has occured. " "If you think this is a bug, please report it. " "Link: [https://github.com/NeKitDS/gd.py/issues]" ), ErrorType.DEFAULT, ) AUTH_NOT_SET = Error(401, "Authorization header is not set.", ErrorType.AUTH_NOT_SET) AUTH_INVALID = Error(401, "Authorization token is incorrect.", ErrorType.AUTH_INVALID) AUTH_MISSING = Error(401, "Authorization token is not found in database.", ErrorType.AUTH_MISSING) class TokenInfo: def __init__(self, name: str, password: str, account_id: int, id: int) -> None: self.name = name self.password = password self.account_id = account_id self.id = id # generate 256-bit token (32 bytes -> 256 bits) self.token = secrets.token_hex(32) def __repr__(self) -> str: info = { "token": repr(self.token), "name": repr(self.name), "account_id": self.account_id, "id": self.id, } return gd.utils.make_repr(self, info) def as_dict( self, include: Sequence[str] = ("token", "account_id", "id", "name", "password") ) -> Dict[str, Union[int, str]]: return {name: getattr(self, name) for name in include} def apply_to_client(self, client: gd.Client) -> None: # apply state of self to a given client client.edit(name=self.name, password=self.password, account_id=self.account_id, id=self.id) class LoginManager: def __init__(self, client: gd.Client, info: TokenInfo) -> None: self.client = client self.info = info def __enter__(self) -> None: self.info.apply_to_client(self.client) def __exit__(self, *exc) -> None: self.client.close() class Forward: def __init__(self, client: gd.Client, request: web.Request) -> None: self.http = client.http self.forwarded_for = request.remote self.backup = self.http.forwarded_for def __enter__(self) -> None: self.http.forwarded_for = self.forwarded_for def __exit__(self, *exc) -> None: self.http.forwarded_for = self.backup @web.middleware async def forward_middleware(request: web.Request, handler: Function) -> web.Response: with Forward(client=request.app.client, request=request): return await handler(request) def get_original_handler(handler: Function) -> Function: while hasattr(handler, "keywords"): handler = handler.keywords.get("handler") return handler def get_token(request: web.Request, required: bool) -> Optional[Union[TokenInfo, Error]]: # try to get token from Authorization header auth = request.headers.get("Authorization") if auth is None: # try to get token from query auth = request.query.get("token") if auth is None: if required: return AUTH_NOT_SET return # see if token matches pattern match = AUTH_RE.match(auth) if match is None: if required: return AUTH_INVALID return token = match.group("token") # check if token is in app.tokens token_info = gd.utils.get(request.app.tokens, token=token) if token_info is None: if required: return AUTH_MISSING return return token_info @web.middleware async def auth_middleware(request: web.Request, handler: Function) -> web.Response: original = get_original_handler(handler) required = getattr(original, "required", False) result = get_token(request, required=required) if isinstance(result, Error): return result.into_resp() request.token_info = result # if token is supplied, wrap handling into context manager if request.token_info: with LoginManager(client=request.app.client, info=request.token_info): return await handler(request) return await handler(request) class Cooldown: def __init__(self, rate: int, per: float) -> None: self.rate = int(rate) self.per = float(per) self.last = 0.0 self.tokens = self.rate self.window = 0.0 def copy(self) -> Cooldown: return self.__class__(self.rate, self.per) def update_tokens(self, current: Optional[float] = None) -> None: if not current: current = time.time() if current > self.window + self.per: self.tokens = self.rate # reset token state def update_rate_limit(self, current: Optional[float] = None) -> Optional[float]: if not current: current = time.time() self.last = current # may be used externally self.update_tokens() if self.tokens == self.rate: # first iteration after reset self.window = current # update window to current if not self.tokens: # rate limited -> return retry_after return self.per - (current - self.window) self.tokens -= 1 # not rate limited -> decrement tokens # if we got rate limited due to this token change, # update the window to point to our current time frame if not self.tokens: self.window = current class CooldownMapping: def __init__(self, original: Cooldown) -> None: self.cache: Dict[str, Cooldown] = {} self.original = original def copy(self) -> CooldownMapping: self_copy = self.__class__(self.original) self_copy.cache = self.cache.copy() return self_copy def clear_unused_cache(self, current: Optional[float] = None) -> None: if not current: current = time.time() self.cache = { key: value for key, value in self.cache.items() if current < value.last + value.per } def construct_key(self, request: web.Request) -> str: return "#".join(map(str, (request.remote, request.path))) def get_bucket(self, request: web.Request, current: Optional[float] = None) -> Cooldown: self.clear_unused_cache() key = self.construct_key(request) if key in self.cache: bucket = self.cache[key] else: bucket = self.original.copy() self.cache[key] = bucket return bucket def update_rate_limit( self, request: web.Request, current: Optional[float] = None ) -> Optional[float]: bucket = self.get_bucket(request, current) return bucket.update_rate_limit(current) @classmethod def from_cooldown(cls, rate: int, per: float) -> CooldownMapping: return cls(Cooldown(rate, per)) def cooldown(rate: int, per: float) -> Function: def decorator(func: Function) -> Function: func.cooldown = CooldownMapping.from_cooldown(rate, per) return func return decorator @web.middleware async def error_middleware(request: web.Request, handler: Function) -> web.Response: try: return await handler(request) except web.HTTPError as error: return Error(error.status, "{error.status}: {error.reason}").set_error(error).into_resp() @web.middleware async def rate_limit_middleware(request: web.Request, handler: Function) -> web.Response: cooldown = getattr(get_original_handler(handler), "cooldown", None) if cooldown: retry_after = cooldown.update_rate_limit(request) if retry_after: retry_after = round(retry_after, 5) return create_retry_after(retry_after).into_resp( headers={"Retry-After": str(retry_after)} ) return await handler(request) DEFAULT_MIDDLEWARES = [ rate_limit_middleware, auth_middleware, forward_middleware, error_middleware, web.normalize_path_middleware(append_slash=False, remove_slash=True), ] def parse_string(string: Optional[str]) -> Dict[str, Union[Dict[Union[str, int], str], str]]: if string is None: return {} current_section = "" current_content = None result = {} lines = [line for line in string.strip().splitlines() if line] + [""] result["method"], result["path"] = lines.pop(0).strip().split(maxsplit=1) for line in lines: line = line.strip(". ;") if line.endswith(":") or not line: if current_section: if isinstance(current_content, list): current_content = "\n".join(current_content) result[current_section] = current_content current_content = None current_section = line.lower().rstrip(":").replace(" ", "_") else: key, sep, value = line.partition(": ") if sep: if current_content is None: current_content = {} current_content[key] = value else: if current_content is None: current_content = [] current_content.append(line) return result def json_resp(*args, **kwargs) -> web.Response: actual_kwargs, json_kwargs = {}, {} for key, value in kwargs.items(): if key.startswith(JSON_PREFIX): key = key[len(JSON_PREFIX) :] json_kwargs[key] = value else: actual_kwargs[key] = value json_kwargs.setdefault("indent", 4) dumps = functools.partial(gd.utils.dumps, **json_kwargs) actual_kwargs.setdefault("dumps", dumps) return web.json_response(*args, **actual_kwargs) def create_app(**kwargs) -> web.Application: kwargs.update(middlewares=(kwargs.get("middlewares", []) + DEFAULT_MIDDLEWARES)) if not ROOT_PATH.exists(): ROOT_PATH.mkdir() app = web.Application(**kwargs) app.client = kwargs.get("client", CLIENT) app.tokens: List[TokenInfo] = [] app.add_routes(routes) return app def run(app: web.Application, **kwargs) -> None: web.run_app(app, **kwargs) def start(**kwargs) -> None: run(create_app(), **kwargs) def handle_errors(error_dict: Optional[Dict[Type[BaseException], Error]] = None) -> Function: if error_dict is None: error_dict = {} def decorator(func: Function) -> Function: @functools.wraps(func) async def wrapper(*args, **kwargs) -> web.Response: try: return await func(*args, **kwargs) except BaseException as error: return error_dict.get(type(error), DEFAULT_ERROR).set_error(error).into_resp() return wrapper return decorator def auth_setup(required: bool = True) -> Function: def decorator(func: Function) -> Function: func.required = required return func return decorator def str_to_bool( string: str, true: Iterable[str] = {"yes", "y", "true", "t", "1"}, false: Iterable[str] = {"no", "n", "false", "f", "0"}, ) -> bool: string = string.casefold() if string in true: return True elif string in false: return False else: raise ValueError(f"Invalid string given: {string!r}.") def string_to_enum(string: str, enum: gd.Enum) -> gd.Enum: if string.isdigit(): return enum.from_value(int(string)) return enum.from_value(string) def get_value(parameter: str, function: Function, default: Any, request: web.Request) -> Any: value = request.query.get(parameter) if value is None: value = default else: value = function(value) return value def get_id_and_special( item_type: str, item_id: int = 0, level_id: int = 0 ) -> Optional[Tuple[int, int]]: mapping = { # string_type: (int_type, special) "level": (1, 0), "level_comment": (2, level_id), "comment": (3, item_id), } return mapping.get(item_type.casefold().replace(" ", "_"), None) def parse_route_docs() -> Generator[Dict[str, Union[Dict[Union[str, int], str], str]], None, None]: for route in routes: info = dict(name=route.handler.__name__) info.update(parse_string(route.handler.__doc__)) yield info async def delete_after(seconds: float, path: Path) -> None: await asyncio.sleep(seconds) try: path.unlink() except Exception: # noqa pass @routes.get("/api") @handle_errors() @auth_setup(required=False) async def main_page(request: web.Request) -> web.Response: """GET /api Description: Return simple JSON with useful info. Example: link: /api Returns: 200: JSON with API info. Return Type: application/json """ payload = { "aiohttp": aiohttp.__version__, "gd.py": gd.__version__, "python": platform.python_version(), "routes": list(parse_route_docs()), } return json_resp(payload) @routes.post("/api/logout") @handle_errors() @auth_setup(required=True) async def logout_handle(request: web.Request) -> web.Response: """POST /api/logout Description: Log out of the system, deleting the token from the database. Example: link: /api/logout?token=01a2345678b9012345cd6e7fa8bc9cfab01234c56def7a89bc0de1fab234c56d token: 01a2345678b9012345cd6e7fa8bc9cfab01234c56def7a89bc0de1fab234c56d Returns: 200: Empty JSON. Return Type: application/json """ try: request.app.tokens.remove(request.token_info) except ValueError: pass # not in tokens, ignore return json_resp({}) @routes.get("/api/auth") @handle_errors( { KeyError: Error(400, "Parameter is missing.", ErrorType.MISSING_PARAMETER), gd.LoginFailure: Error(401, "Failed to login.", ErrorType.LOGIN_FAILED), } ) @auth_setup(required=False) async def auth_handle(request: web.Request) -> web.Response: """GET /api/auth Description: Login into API system and get the token for further operation. Example: link: /api/auth?name=User&password=Password name: User password: Password Returns: Token for further requests, like {"token": "..."}. Return Type: application/json """ name = request.query["name"] password = request.query["password"] # Do NOT use gd.Client.login(...) as it changes state and we do not want this account_id, id = await request.app.client.session.login(name, password) # Check if name and password are in the app.tokens token_info = gd.utils.get(request.app.tokens, name=name, password=password) # Create token info object to store current state if not existing if token_info is None: tokens = set(set_token.token for set_token in request.app.tokens) while not token_info or token_info.token in tokens: token_info = TokenInfo(name=name, password=password, account_id=account_id, id=id) request.app.tokens.append(token_info) new = True else: new = False return json_resp({"token": token_info.token, "new": new}) @routes.get("/api/user/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Requested user not found.", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) async def user_get(request: web.Request) -> web.Response: """GET /api/user/{id} Description: Fetch a user by their Account ID. Example: link: /api/user/71 Returns: 200: JSON with user info; 400: Invalid type; 404: User was not found. Return Type: application/json """ query = int(request.match_info["id"]) small = str_to_bool(request.query.get("small", "false")) if small: user = await request.app.client.fetch_user(query) else: user = await request.app.client.get_user(query) return json_resp(user) @routes.get("/api/song/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Requested song not found.", ErrorType.NOT_FOUND), gd.SongRestrictedForUsage: Error( 403, "Song is not allowed for use.", ErrorType.ACCESS_RESTRICTED ), } ) @auth_setup(required=False) async def song_search(request: web.Request) -> web.Response: """GET /api/song/{id} Description: Fetch a song by its ID. Example: link: /api/song/1 Returns: 200: JSON with song info; 400: Invalid type in payload; 403: Song is not allowed to use; 404: Song was not found. Return Type: application/json """ query = int(request.match_info["id"]) song = await request.app.client.get_song(query) return json_resp(song) @routes.get("/api/song/{id}/info") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Requested artist info was not found", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) async def get_artist_info(request: web.Request) -> web.Response: """GET /api/song/{id}/info Description: Get information about the song and its artist. Example: link: /api/song/467339/info Returns: 200: JSON with song and artist info; 400: Invalid type in payload; 404: Requested info was not found. Return Type: application/json """ query = int(request.match_info["id"]) artist_info = await request.app.client.get_artist_info(query) return json_resp(artist_info) @routes.get("/api/search/user/{query}") @handle_errors({gd.MissingAccess: Error(404, "Requested user was not found.", ErrorType.NOT_FOUND)}) @auth_setup(required=False) async def user_search(request: web.Request) -> web.Response: """GET /api/search/user/{query} Description: Fetch a user by their name or player ID. Example: link: /api/search/user/RobTop Returns: 200: JSON with user info; 404: User was not found. Return Type: application/json """ query = request.match_info["query"] small = str_to_bool(request.query.get("small", "false")) if small: user = await request.app.client.find_user(query) else: user = await request.app.client.search_user(query) return json_resp(user) @routes.get("/api/level/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Requested level was not found", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) async def get_level(request: web.Request) -> web.Response: """GET /api/level/{id} Description: Fetch a level by given ID. Example: link: /api/level/30029017 Returns: 200: JSON with level info; 400: Invalid type; 404: Level was not found. Return Type: application/json """ level_id = int(request.match_info["id"]) level = await request.app.client.get_level(level_id) return json_resp(level) @routes.delete("/api/level/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to delete a level.", ErrorType.FAILED), } ) @auth_setup(required=True) async def delete_level(request: web.Request) -> web.Response: """DELETE /api/level/{id} Description: Delete a level by given ID. Example: link: /api/level/1234567890 Returns: 200: Empty JSON dictionary; 400: Invalid type; 404: Failed to delete level. Return Type: application/json """ level_id = int(request.match_info["id"]) await gd.Level(id=level_id, client=request.app.client).delete() return json_resp({}) @routes.get("/api/install/song/{song_id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Requested song not found.", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) async def download_song(request: web.Request) -> web.FileResponse: """GET /api/install/song/{song_id} Description: Download a song by its ID. Example: link: /api/install/song/905110 Returns: 200: Found song; 400: Invalid type; 404: Failed to find the song. Return Type: audio/mpeg """ song_id = int(request.match_info["song_id"]) path = ROOT_PATH / f"song-{song_id}.mp3" if path.exists(): return web.FileResponse(path) song = await request.app.client.get_ng_song(song_id) await song.download(file=path) request.loop.create_task(delete_after(60, path)) return web.FileResponse(path) @routes.get("/api/install/level/{level_id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Requested level was not found.", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) @cooldown(rate=10, per=50) async def download_level(request: web.Request) -> web.Response: """GET /api/install/level/{level_id} Description: Download a level by its ID, optionally parsing it. Example: link: /api/install/level/30029017?state=parsed state: parsed Parameters: state: State of level to return. Either "raw", "parsed" (default) or "editor". Returns: 200: JSON with data; 400: Invalid type; 404: Level was not found. Return Type: application/json """ level_id = int(request.match_info["level_id"]) # "raw", "parsed", "editor" state = request.query.get("state", "parsed").casefold() level = await request.app.client.get_level(level_id) if state == "raw": data = gd.Coder.zip(level.data) separators = None elif state == "editor": data = level.open_editor() separators = (",", ":") else: data = level.data separators = None return json_resp({"data": data}, json_indent=None, json_separators=separators) @routes.get("/api/daily") @handle_errors( {gd.MissingAccess: Error(404, "Daily is likely being refreshed.", ErrorType.NOT_FOUND)} ) @auth_setup(required=False) async def get_daily(request: web.Request) -> web.Response: """GET /api/daily Description: Fetch current daily level. Example: link: /api/daily Returns: 200: JSON with daily info; 404: Daily is being refreshed. Return Type: application/json """ daily = await request.app.client.get_daily() return json_resp(daily) @routes.get("/api/weekly") @handle_errors( {gd.MissingAccess: Error(404, "Weekly is likely being refreshed.", ErrorType.NOT_FOUND)} ) @auth_setup(required=False) async def get_weekly(request: web.Request) -> web.Response: """GET /api/weekly Description: Fetch current weekly level. Example: link: /api/weekly Returns: 200: JSON with weekly info; 404: Weekly is being refreshed. Return Type: application/json """ weekly = await request.app.client.get_weekly() return json_resp(weekly) @routes.get("/api/gauntlets") @handle_errors({gd.MissingAccess: Error(404, "Failed to load gauntlets.", ErrorType.FAILED)}) @auth_setup(required=False) async def get_gauntlets(request: web.Request) -> web.Response: """GET /api/gauntlets Description: Get all gauntlets and optionally load their levels. Example: link: /api/gauntlets?load=true load: true Parameters: load: Whether to load gauntlet levels, "true" (default) or "false". Returns: 200: JSON with gauntlets; 404: Failed to load gauntlets. Return Type: application/json """ gauntlets = await request.app.client.get_gauntlets() load = str_to_bool(request.query.get("load", "true")) if load: await gd.utils.gather(gauntlet.get_levels() for gauntlet in gauntlets) return json_resp(gauntlets) @routes.get("/api/map_packs") @handle_errors({gd.MissingAccess: Error(404, "Failed to load map packs.", ErrorType.FAILED)}) @auth_setup(required=False) async def get_map_packs(request: web.Request) -> web.Response: """GET /api/map_packs Description: Get all map packs and optionally load their levels. Example: link: /api/map_packs?load=false load: false Parameters: load: Whether to load map pack levels, "true" (default) or "false". Returns: 200: JSON with map packs; 404: Failed to load map packs. Return Type: application/json """ pages = map(int, request.query.get("pages", "0").split(",")) load = str_to_bool(request.query.get("load", "true")) map_packs = await request.app.client.get_map_packs(pages=pages) if load: await gd.utils.gather(map_pack.get_levels() for map_pack in map_packs) return json_resp(map_packs) UPLOAD_QUERY: Dict[str, Tuple[Union[Callable[[str], Any], Any]]] = { "name": (str, "Unnamed"), "id": (int, 0), "version": (int, 1), "length": (functools.partial(string_to_enum, enum=gd.LevelLength), gd.LevelLength.TINY), "track": (int, 0), "song_id": (int, 0), "is_auto": (str_to_bool, False), "original": (int, 0), "two_player": (str_to_bool, False), "objects": (int, None), "coins": (int, 0), "star_amount": (int, 0), "unlist": (str_to_bool, False), "ldm": (str_to_bool, False), "password": (int, None), "copyable": (str_to_bool, False), "data": (str, ""), "description": (str, ""), "load": (str_to_bool, True), } @routes.post("/api/level") @handle_errors({gd.MissingAccess: Error(404, "Failed to upload a level.", ErrorType.FAILED)}) @auth_setup(required=True) @cooldown(rate=10, per=50) async def upload_level(request: web.Request) -> web.Response: """POST /api/level Description: Upload a level with given arguments. Example: link: /api/level?name=Test&track=35&password=123456 name: Test track: 35 password: 123456 Parameters: name: Name of the level; id: ID of the level. 0 if uploading a new level, non-zero to update; version: Version of the level; length: Length of the level; track: Normal track to set, starting from 0 - Stereo Madness; song_id: ID of the custom song to set; is_auto: Indicates if the level is auto; original: ID of the original level; two_player: Indicates whether the level has enabled Two Player mode; objects: The amount of objects in the level; coins: Amount of coins the level has; star_amount: The amount of stars to request; unlist: Indicates whether the level should be unlisted; ldm: Indicates if the level has LDM mode; password: The password to apply; copyable: Indicates whether the level should be copyable; data: The data of the level, as a string; description: The description of the level. Returns: 200: JSON with uploaded level; 404: Failed to upload level. Return Type: application/json """ upload_arguments = { parameter: get_value(parameter, function, default, request) for (parameter, (function, default)) in UPLOAD_QUERY.items() } level = await request.app.client.upload_level(**upload_arguments) return json_resp(level) @routes.get("/api/chests") @handle_errors({gd.MissingAccess: Error(404, "Failed to get chests.", ErrorType.FAILED)}) @auth_setup(required=True) async def get_chests(request: web.Request) -> web.Response: """GET /api/chests Description: Load chests of the connected client. Example: link: /api/chests Returns: 200: JSON with chests; 404: Failed to get chests. Return Type: application/json """ chests = await request.app.client.get_chests() return json_resp(chests) @routes.get("/api/quests") @handle_errors({gd.MissingAccess: Error(404, "Failed to get quests.", ErrorType.FAILED)}) @auth_setup(required=True) async def get_quests(request: web.Request) -> web.Response: """GET /api/quests Description: Load quests of the connected client. Example: link: /api/quests Returns: 200: JSON with quests; 404: Failed to get quests. Return Type: application/json """ quests = await request.app.client.get_quests() return json_resp(quests) @routes.get("/api/levels") @handle_errors({gd.MissingAccess: Error(404, "Failed to get levels.", ErrorType.FAILED)}) @auth_setup(required=True) async def get_levels(request: web.Request) -> web.Response: """GET /api/levels Description: Load levels of the connected client. Example: link: /api/levels?pages=0,1,2,3 pages: 0,1,2,3 Parameters: pages: Pages of levels to load. Returns: 200: JSON with levels; 404: Failed to get levels. Return Type: application/json """ pages = map(int, request.query.get("pages", "0").split(",")) levels = await request.app.client.get_levels(pages=pages) return json_resp(levels) @routes.get("/api/messages") @handle_errors({gd.MissingAccess: Error(404, "Failed to load messages.", ErrorType.FAILED)}) @auth_setup(required=True) async def get_messages(request: web.Request) -> web.Response: """GET api/messages Description: Load messages, optionally reading them. Example: link: /api/messages?pages=0,1,2,3&sent=false&read=true pages: 0,1,2,3 sent: false read: true Parameters: pages: Pages of messages to load; sent: Whether to load sent messages, "true" or "false" (default); read: Whether to read messages, "true" (default) or "false". Returns: 200: JSON with messages; 404: Failed to load messages. Return Type: application/json """ pages = map(int, request.query.get("pages", "0").split(",")) sent = str_to_bool(request.query.get("sent", "false")) read = str_to_bool(request.query.get("read", "true")) sent_or_inbox = "sent" if sent else "inbox" messages = await request.app.client.get_messages(sent_or_inbox, pages=pages) if read: await gd.utils.gather(message.read() for message in messages) return json_resp(messages) @routes.get("/api/message/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to read a message.", ErrorType.FAILED), } ) @auth_setup(required=True) async def read_message(request: web.Request) -> web.Response: """GET /api/message/{id} Description: Read a message by its ID. Example: link: /api/message/123456789 Parameters: type: Type of the message, either "sent" or "normal" (default). Returns: 200: JSON with message body; 404: Failed to read a message. Return Type: application/json """ message_id = int(request.match_info["id"]) message_type = string_to_enum(request.query.get("type", "normal"), gd.MessageOrRequestType) body = await gd.Message(id=message_id, type=message_type, client=request.app.client).read() return json_resp({"body": body}) @routes.delete("/api/message/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to delete a message.", ErrorType.FAILED), } ) @auth_setup(required=True) async def delete_message(request: web.Request) -> web.Response: """DELETE /api/message/{id} Description: Delete a message by its ID. Example: link: /api/message/123456789 Parameters: type: Type of the message, either "sent" or "normal" (default). Returns: 200: Empty JSON; 404: Failed to delete a message. Return Type: application/json """ message_id = int(request.match_info["id"]) message_type = string_to_enum(request.query.get("type", "normal"), gd.MessageOrRequestType) await gd.Message(id=message_id, type=message_type, client=request.app.client).delete() return json_resp({}) @routes.get("/api/friend_requests") @handle_errors({gd.MissingAccess: Error(404, "Failed to get friend requests.", ErrorType.FAILED)}) @auth_setup(required=True) async def get_friend_requests(request: web.Request) -> web.Response: """GET api/friend_requests Description: Load friend requests, optionally reading them. Example: link: /api/friend_requests?pages=0,1,2,3&sent=false&read=false pages: 0,1,2,3 sent: false read: false Parameters: pages: Pages of friend requests to load; sent: Whether to load sent friend requests, "true" or "false" (default); read: Whether to read friend requests, "true" (default) or "false". Returns: 200: JSON with friend requests; 404: Failed to load friend requests. Return Type: application/json """ pages = map(int, request.query.get("pages", "0").split(",")) sent = str_to_bool(request.query.get("sent", "false")) read = str_to_bool(request.query.get("read", "false")) sent_or_inbox = "sent" if sent else "inbox" friend_requests = await request.app.client.get_friend_requests(sent_or_inbox, pages=pages) if read: await gd.utils.gather(friend_request.read() for friend_request in friend_requests) return json_resp(friend_requests) @routes.get("/api/friend_request/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to get a request.", ErrorType.FAILED), } ) @auth_setup(required=True) async def read_friend_request(request: web.Request) -> web.Response: """GET /api/friend_request/{id} Description: Read friend request by its ID. Example: link: /api/friend_request/123456789 Returns: 200: Empty JSON; 404: Failed to get a request. Return Type: application/json """ request_id = int(request.match_info["id"]) await gd.FriendRequest(id=request_id, client=request.app.client).read() return json_resp({}) @routes.delete("/api/friend_request/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to delete a request.", ErrorType.FAILED), } ) @auth_setup(required=True) async def delete_friend_request(request: web.Request) -> web.Response: """DELETE /api/friend_request/{id} Description: Delete friend request by its ID. Example: link: /api/friend_request/123456789?type=normal&author_id=987654321 type: normal author_id: 987654321 Parameters: type: Type of friend request, "sent" or "normal" (default); author_id: AccountID of the author of friend request. Returns: 200: Empty JSON; 404: Failed to delete a request. Return Type: application/json """ request_id = int(request.match_info["id"]) request_type = string_to_enum(request.query.get("type", "normal"), gd.MessageOrRequestType) account_id = int(request.query["author_id"]) await gd.FriendRequest( author=gd.AbstractUser(account_id=account_id, client=request.app.client), id=request_id, type=request_type, client=request.app.client, ).delete() return json_resp({}) @routes.patch("/api/friend_request/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to accept a request.", ErrorType.FAILED), } ) @auth_setup(required=True) async def accept_friend_request(request: web.Request) -> web.Response: """PATCH /api/friend_request/{id} Description: Accept friend request by its ID. Example: link: /api/friend_request/123456789?author_id=987654321 author_id: 987654321 Parameters: type: Type of friend request, "normal" (always); author_id: AccountID of the author of friend request. Returns: 200: Empty JSON; 404: Failed to accept a request. Return Type: application/json """ request_id = int(request.match_info["id"]) request_type = string_to_enum(request.query.get("type", "normal"), gd.MessageOrRequestType) account_id = int(request.query["author_id"]) await gd.FriendRequest( author=gd.AbstractUser(account_id=account_id, client=request.app.client), id=request_id, type=request_type, client=request.app.client, ).accept() return json_resp({}) @routes.get("/api/friends") @handle_errors({gd.MissingAccess: Error(404, "Failed to get friends.", ErrorType.FAILED)}) @auth_setup(required=True) async def get_friends(request: web.Request) -> web.Response: """GET /api/friends Description: Get friend of the connected client. Example: link: /api/friends Returns: 200: JSON list of friends; 404: Failed to get friends. Return Type: application/json """ friends = await request.app.client.get_friends() return json_resp(friends) @routes.patch("/api/{action:(unblock|block)}/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to (un)block user.", ErrorType.FAILED), } ) @auth_setup(required=True) async def un_block_user(request: web.Request) -> web.Response: """PATCH /api/{action:(block|unblock)}/{id} Description: Block or unblock user by their AccountID. Example: link: api/unblock/123456789 Returns: 200: Empty JSON; 404: Failed to block or unblock given user. Return Type: application/json """ account_id = int(request.match_info["id"]) unblock = request.match_info["action"].startswith("un") user = await gd.AbstractUser(account_id=account_id, client=request.app.client) if unblock: await user.unblock() else: await user.block() return json_resp({}) @routes.patch("/api/{action:(unfriend|friend)}/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to (un)friend user.", ErrorType.FAILED), } ) @auth_setup(required=True) async def un_friend_user(request: web.Request) -> web.Response: """PATCH /api/{action:(friend|unfriend)}/{id} Description: Unfriend user or send a friend request to them by their AccountID. Example: link: api/friend/123456789?message=Hello! message: Hello! Parameters: message: Message to send with friend request. (Ignored if /unfriend/). Returns: 200: Empty JSON or friend request data; 404: Failed to send a friend request or unfriend given user. Return Type: application/json """ account_id = int(request.match_info["id"]) unfriend = request.match_info["action"].startswith("un") message = request.query.get("message", "") user = gd.AbstractUser(account_id=account_id, client=request.app.client) if unfriend: await user.unfriend() return json_resp({}) else: friend_request = await user.send_friend_request(message=message) return json_resp(friend_request) return json_resp({}) @routes.get("/api/blocked") @handle_errors({gd.MissingAccess: Error(404, "Failed to get blocked users.", ErrorType.FAILED)}) @auth_setup(required=True) async def get_blocked(request: web.Request) -> web.Response: """GET /api/blocked Description: Get users blocked by the connected client. Example: link: /api/blocked Returns: 200: JSON list of blocked users; 404: Failed to get blocked users. Return Type: application/json """ blocked = await request.app.client.get_blocked_users() return json_resp(blocked) @routes.post("/api/send/{query}") @handle_errors( { KeyError: Error(400, "Parameter is missing.", ErrorType.MISSING_PARAMETER), ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "{error}", ErrorType.FAILED), } ) @auth_setup(required=True) @cooldown(rate=5, per=5) async def send_message(request: web.Request) -> web.Response: """POST /api/send/{query} Description: Send a message to the user given by the query. Example: link: /api/send/5509312?id=true&subject=Server&body=Test id: true subject: Server body: Test Parameters: id: Whether given query is AccountID, "true" or "false" (default); subject: Subject of the message to send, "No Subject" by default; body: Body of the message to send, required. Returns: 200: JSON with the message; 404: Failed to send a message. Return Type: application/json """ query = request.match_info["query"] is_id = str_to_bool(request.query.get("id", "false")) subject = request.query.get("subject", "No Subject") body = request.query["body"] if is_id: user = await request.app.client.fetch_user(int(query)) else: user = await request.app.client.find_user(query) message = await user.send(subject, body) return json_resp(message) @routes.patch("/api/{action:(dislike|like)}/{id}") @handle_errors( { KeyError: Error(400, "Parameter is missing.", ErrorType.MISSING_PARAMETER), ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to like an entity.", ErrorType.FAILED), } ) @auth_setup(required=True) @cooldown(rate=5, per=5) async def like_item(request: web.Request) -> web.Response: """PATCH /api/{action:(dislike|like)}/{id} Description: Like or dislike item given by ID. Example: link: /api/like/16625059?type=comment type: comment Parameters: type: Type of the item, "comment", "level" or "level_comment". Required; level_id: ID of the Level, needed if type is "level_comment". Returns: 200: Empty JSON; 400: Parameter is missing; 404: Failed to like an entity. Return Type: application/json """ dislike = request.match_info["action"].startswith("dis") item_id = int(request.match_info["id"]) item_type = request.query["type"] level_id = int(request.query.get("level_id", 0)) type_id, special = get_id_and_special(item_type, item_id, level_id) await request.app.client.session.like( item_id, type_id, special, dislike=dislike, client=request.app.client ) return json_resp({}) @routes.patch("/api/level/{id}/rate") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to rate a level.", ErrorType.FAILED), } ) @auth_setup(required=True) @cooldown(rate=5, per=5) async def rate_level(request: web.Request) -> web.Response: """PATCH /api/level/{id}/rate Description: Rate the level given by its ID. Example: link: /api/level/44622744/rate?stars=10 stars: 10 Parameters: stars: Stars to rate the level with, required. Returns: 200: Empty JSON; 404: Failed to rate a level. Return Type: application/json """ level_id = int(request.match_info["id"]) stars = int(request.query["stars"]) await gd.Level(id=level_id, client=request.app.client).rate(stars) return json_resp({}) @routes.patch("/api/level/{id}/description") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to update level description.", ErrorType.FAILED), } ) @auth_setup(required=True) async def update_level_description(request: web.Request) -> web.Response: """PATCH /api/level/{id}/description Description: Update level description. Example: link: /api/level/123456789/description?new=Test new: Test Parameters: new: New description to set. If not given, clears current description. Returns: 200: Empty JSON; 404: Failed to update level description. Return Type: application/json """ level_id = int(request.match_info["id"]) new = request.query.get("new", "") await gd.Level(id=level_id, client=request.app.client).update_description(new) return json_resp({}) @routes.patch("/api/level/{id}/rate_demon") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to demon-rate a level.", ErrorType.FAILED), } ) @auth_setup(required=True) async def rate_level_demon(request: web.Request) -> web.Response: """PATCH /api/level/{id}/rate_demon Description: Demon-Rate the level given by its ID. Example: link: /api/level/42584142/rate_demon?difficulty=extreme_demon&mod=false difficulty: extreme_demon mod: false Parameters: difficulty: Difficulty to demon-rate the level with, required; mod: Whether to attempt to demon-rate the level as mod, "true" or "false" (default). Returns: 200: Empty JSON; 404: Failed to demon-rate a level. Return Type: application/json """ level_id = int(request.match_info["id"]) demon_difficulty = string_to_enum(request.query["difficulty"], gd.DemonDifficulty) as_mod = str_to_bool(request.query.get("mod", "false")) await gd.Level(id=level_id, client=request.app.client).rate_demon( demon_difficulty, as_mod=as_mod ) return json_resp({}) @routes.patch("/api/level/{id}/send") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to send a level.", ErrorType.FAILED), } ) @auth_setup(required=True) async def send_level(request: web.Request) -> web.Response: """PATCH /api/level/{id}/send Description: Send the level given by its ID for rating. Requires Moderator privileges. Example: link: /api/level/123456789/send?stars=10&feature=true stars: 10 feature: true Parameters: stars: Stars to send the level with, required; mod: Whether to send the level for feature, "true" or "false", required. Returns: 200: Empty JSON; 404: Failed to send a level. Return Type: application/json """ level_id = int(request.match_info["id"]) stars = int(request.query["stars"]) featured = str_to_bool(request.query["featured"]) await gd.Level(id=level_id, client=request.app.client).send(stars, featured=featured) return json_resp({}) @routes.get("/api/level/{id}/comments") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to get level comments.", ErrorType.FAILED), } ) @auth_setup(required=False) async def get_level_comments(request: web.Request) -> web.Response: """GET /api/level/{id}/comments Description: Get comments of the level given by its ID. Example: link: /api/level/30029017/comments?amount=100&strategy=most_liked amount: 100 strategy: most_liked Parameters: amount: Amount of comments to fetch, 20 by default; strategy: Strategy to apply, "recent" (default) or "most_liked". Returns: 200: JSON with level comments; 404: Failed to get level comments. Return Type: application/json """ level_id = int(request.match_info["id"]) amount = int(request.query.get("amount", 20)) strategy = string_to_enum(request.query.get("strategy", "recent"), gd.CommentStrategy) comments = await gd.Level(id=level_id, client=request.app.client).get_comments( amount=amount, strategy=strategy ) return json_resp(comments) @routes.get("/api/level/{id}/leaderboard") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to get the leaderboard.", ErrorType.FAILED), } ) @auth_setup(required=True) async def get_level_leaderboard(request: web.Request) -> web.Response: """GET /api/level/{id}/leaderboard Description: Get leaderboard of the level given by its ID. Example: link: /api/level/30029017/leaderboard?strategy=all strategy: all Parameters: strategy: Strategy to apply, "all" (default), "weekly" or "friends". Returns: 200: JSON with level records; 404: Failed to get the leaderboard. Return Type: application/json """ level_id = int(request.match_info["id"]) strategy = string_to_enum( request.match_info.get("strategy", "all"), gd.LevelLeaderboardStrategy ) records = await gd.Level(id=level_id, client=request.app.client).get_leaderboard( strategy=strategy ) return json_resp(records) @routes.get("/api/user/{id}/comments") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to get user comments.", ErrorType.FAILED), } ) @auth_setup(required=False) async def get_user_comments(request: web.Request) -> web.Response: """GET /api/user/{id}/comments Description: Get comments of the user given by AccountID. Example: link: /api/user/5509312/comments?pages=0,1,2,3&type=profile&strategy=recent pages: 0,1,2,3 type: profile strategy: recent Parameters: pages: Pages of comments to load, e.g. "0,1,2,3"; type: Type of comments to fetch, either "profile" (default) or "level"; strategy: Strategy to use for fetching, "recent" (default) or "most_liked". Returns: 200: JSON with found comments; 404: Failed to get comments. Return Type: application/json """ account_id = int(request.match_info["id"]) pages = map(int, request.query.get("pages", "0").split(",")) type_str = request.query.get("type", "profile") strategy = string_to_enum(request.query.get("strategy", "recent"), gd.CommentStrategy) user = await request.app.client.fetch_user(account_id) comments = await user.retrieve_comments(type=type_str, pages=pages, strategy=strategy) return json_resp(comments) @routes.post("/api/level/{level_id}/comment") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to comment a level.", ErrorType.FAILED), } ) @auth_setup(required=True) async def comment_level(request: web.Request) -> web.Response: """POST /api/level/{level_id}/comment Description: Post a comment on the level given by Level ID. Example: link: /api/level/1/comment?body=Test&percentage=42 body: Test percentage: 42 Parameters: body: Body of the comment, required; percentage: Percentage to put, 0 by default. Returns: 200: JSON with comment data; 404: Failed to comment a level. Return Type: application/json """ level_id = int(request.match_info["level_id"]) body = request.query["body"] percentage = int(request.query.get("percentage", 0)) comment = await gd.Level(id=level_id, client=request.app.client).comment(body, percentage) return json_resp(comment) @routes.post("/api/comment") @handle_errors({gd.MissingAccess: Error(404, "Failed to post a comment.", ErrorType.FAILED)}) @auth_setup(required=True) async def post_comment(request: web.Request) -> web.Response: """POST /api/comment Description: Post a profile comment. Example: link: /api/comment?body=Test body: Test Parameters: body: Body of the comment to post. Returns: 200: JSON with the comment; 404: Failed to post a comment. Return Type: application/json """ body = request.query["body"] comment = await request.app.client.post_comment(body) return json_resp(comment) SETTINGS_QUERY: Dict[str, Tuple[Union[Callable[[str], Any], Any]]] = { "message_policy": (functools.partial(string_to_enum, enum=gd.MessagePolicyType), None), "friend_request_policy": ( functools.partial(string_to_enum, enum=gd.FriendRequestPolicyType), None, ), "comment_policy": (functools.partial(string_to_enum, enum=gd.CommentPolicyType), None), "youtube": (str, None), "twitter": (str, None), "twitch": (str, None), } @routes.patch("/api/settings") @handle_errors({gd.MissingAccess: Error(404, "Failed to edit settings.", ErrorType.FAILED)}) @auth_setup(required=True) async def update_settings(request: web.Request) -> web.Response: """PATCH /api/settings Description: Update profile settings, policies and social media links. Example: link: /api/settings?comment_policy=opened_to_all comment_policy: opened_to_all Parameters: message_policy: Message policy of the account; friend_request_policy: Friend Request policy of the account; comment_policy: Comment policy of the account; youtube: YouTube ID of the channel; twitter: Twitter name of the account; twitch: Twitch name of the account. Returns: 200: Empty JSON; 404: Failed to edit settings. Return Type: application/json """ update_arguments = { parameter: get_value(parameter, function, default, request) for (parameter, (function, default)) in SETTINGS_QUERY.items() } await request.app.client.update_settings(**update_arguments) return json_resp({}) PROFILE_QUERY: Dict[str, Tuple[Union[Callable[[str], Any], Any]]] = { "stars": (int, None), "demons": (int, None), "diamonds": (int, None), "has_glow": (bool, None), "icon_type": (functools.partial(string_to_enum, enum=gd.IconType), None), "icon": (int, None), "color_1": (int, None), "color_2": (int, None), "coins": (int, None), "user_coins": (int, None), "cube": (int, None), "ship": (int, None), "ball": (int, None), "ufo": (int, None), "wave": (int, None), "robot": (int, None), "spider": (int, None), "explosion": (int, None), "special": (int, 0), "set_as_user": (str, None), } @routes.patch("/api/profile") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to update profile.", ErrorType.FAILED), } ) @auth_setup(required=True) async def update_profile(request: web.Request) -> web.Response: """PATCH /api/profile Description: Update profile of the connected client. Example: link: /api/profile?has_glow=true&icon_type=cube&icon=3 has_glow: true icon_type: cube icon: 3 Parameters: stars: An amount of stars to set; demons: An amount of completed demons to set; diamonds: An amount of diamonds to set; has_glow: Indicates whether a user should have the glow outline; icon_type: Icon type that should be used; icon: Icon ID that should be used; color_1: Index of a color to use as the main color; color_2: Index of a color to use as the secodary color; coins: An amount of secret coins to set; user_coins: An amount of user coins to set; cube: An index of a cube icon to apply; ship: An index of a ship icon to apply; ball: An index of a ball icon to apply; ufo: An index of a ufo icon to apply; wave: An index of a wave icon to apply; robot: An index of a robot icon to apply; spider: An index of a spider icon to apply; explosion: An index of an explosion to apply; special: The purpose of this parameter is unknown; id: Whether to interpret "set_as_user" as AccountID or Name/PlayerID; set_as_user: Passing this parameter allows to copy user's profile. Returns: 200: Empty JSON; 404: Failed to update profile. Return Type: application/json """ is_id = str_to_bool(request.query.get("id", "false")) update_arguments = { parameter: get_value(parameter, function, default, request) for (parameter, (function, default)) in PROFILE_QUERY.items() } query = update_arguments.get("set_as_user") if query is None: user = None elif is_id: user = await request.app.client.get_user(int(query)) else: user = await request.app.client.search_user(query) update_arguments.update(set_as_user=user) await request.app.client.update_profile(**update_arguments) return json_resp({}) def color_from_hex(string: str) -> gd.Color: return gd.Color(int(string.replace("#", "0x"), 16)) @routes.get("/api/icon_factory") @handle_errors( { AttributeError: Error( 500, "Can not generate icons due to factory missing.", ErrorType.NOT_FOUND ), LookupError: Error(404, "Icon was not found.", ErrorType.NOT_FOUND), ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), Warning: Error(404, "No types to generate are given.", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) async def generate_icons(request: web.Request) -> web.Response: query = multidict.CIMultiDict(request.query) color_1 = color_from_hex(query.pop("color_1", "0x00ff00")) color_2 = color_from_hex(query.pop("color_2", "0x00ffff")) glow_outline = str_to_bool(query.pop("glow_outline", "false")) error_on_not_found = str_to_bool(query.pop("error_on_not_found", "false")) settings = f"color_1={color_1}$color_2={color_2}$glow_outline={glow_outline}".lower() types = "$".join(f"{key}={value}".lower() for key, value in query.items()) name = f"[{settings}]({types}).png" path = ROOT_PATH / name if path.exists(): return web.FileResponse(path) images = [ await gd.utils.run_blocking_io( gd.factory.generate, icon_type=gd.IconType.from_value(icon_type), icon_id=int(icon_id), color_1=color_1, color_2=color_2, glow_outline=glow_outline, error_on_not_found=error_on_not_found, ) for icon_type, icon_id in query.items() ] if not images: raise Warning("No types were generated.") image = await gd.utils.run_blocking_io(gd.icon_factory.connect_images, images) image.save(path) request.loop.create_task(delete_after(5, path)) return web.FileResponse(path) @routes.get("/api/icons/{type:(all|main|cube|ship|ball|ufo|wave|robot|spider)}/{query}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Could not find requested user.", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) async def get_icons(request: web.Request) -> web.Response: """GET /api/icons/{type:(all|main|cube|ship|ball|ufo|wave|robot|spider)}/{query} Description: Generate icon of the user given by query. Example: link: /api/icons/all/5509312?id=true id: true Parameters: id: Whether to interpret "query" as AccountID or Name/PlayerID; Returns: 200: Image with generated icons; 404: Could not find requested user. Return Type: image/png """ icon_type = request.match_info["type"] query = request.match_info["query"] is_id = str_to_bool(request.query.get("id", "false")) if is_id: user = await request.app.client.get_user(int(query)) else: user = await request.app.client.search_user(query) path = ROOT_PATH / f"icons-{icon_type}-{user.account_id}.png" if path.exists(): return web.FileResponse(path) if icon_type == "main": icon_type = user.icon_set.main_type.name.lower() if icon_type == "all": image = await user.icon_set.generate_full(as_image=True) else: image = await user.icon_set.generate(icon_type, as_image=True) image.save(path) request.loop.create_task(delete_after(5, path)) return web.FileResponse(path) @routes.get("/api/search/levels") @handle_errors({ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE)}) @auth_setup(required=False) async def search_levels(request: web.Request) -> web.Response: """GET /api/search/levels Description: Search levels with provided options. Example: link: /api/search/levels?query=Bloodlust&demon_difficulty=extreme_demon&pages=0 query: Bloodlust demon_difficulty: extreme_demon Parameters: query: Query to search for, by default empty string; pages: Pages to look levels on; strategy: Strategy to apply when searching, "regular" by default; difficulty: Difficulties to filter levels, optional; demon_difficulty: Demon Difficulty to filter levels, optional; length: Lengths to filter levels, optional; uncompleted: Whether to fetch only uncompleted levels, requires "completed_levels"; only_completed: Whether to fetch only completed levels, requires "completed_levels"; completed_levels: Comma-separated list of IDs of completed levels, e.g. "1,2,3,4"; require_coins: Whether levels should have coins, "false" by default; featured: Whether levels should be featured, "false" by default; epic: Whether levels should be epic, "false" by default; require_two_player: Whether levels should have Two Player mode, "false" by default; rated: If not omitted, forces levels to be either rated or unrated strictly; song_id: Song ID of the Song/Track to use; use_custom_song: Whether Song by given "song_id" is custom or not, "false" by default; require_original: Whether to force levels to be original, "false" by default; followed: Comma-separated list of IDs of followed users, e.g. "71,5509312"; gauntlet: ID or Name of the gauntlet to fetch levels of; id: Indicates whether to interpret "user" as AccountID or Name/PlayerID; user: User to fetch levels from. If not given, logged in account might be required. Returns: 200: JSON with levels; 404: No levels were found. Return Type: application/json """ is_id = str_to_bool(request.query.get("id", "false")) query = request.query.get("query", "") pages = map(int, request.query.get("pages", "0").split(",")) user_query = request.query.get("user") gauntlet = request.query.get("gauntlet") if gauntlet is not None: if not gauntlet.isdigit(): gauntlet = gd.Converter.get_gauntlet_id(gauntlet) # assume name else: gauntlet = int(gauntlet) strategy = string_to_enum(request.query.get("strategy", "0"), gd.SearchStrategy) difficulty = request.query.get("difficulty") if difficulty is not None: difficulty = (string_to_enum(part, gd.LevelDifficulty) for part in difficulty.split(",")) demon_difficulty = request.query.get("demon_difficulty") if demon_difficulty is not None: demon_difficulty = string_to_enum(demon_difficulty, gd.DemonDifficulty) length = request.query.get("length") if length is not None: length = (string_to_enum(part, gd.LevelLength) for part in length.split(",")) uncompleted = str_to_bool(request.query.get("uncompleted", "false")) only_completed = str_to_bool(request.query.get("only_completed", "false")) completed_levels = request.query.get("completed_levels") if completed_levels is not None: completed_levels = map(int, completed_levels.split(",")) require_coins = str_to_bool(request.query.get("require_coins", "false")) featured = str_to_bool(request.query.get("featured", "false")) epic = str_to_bool(request.query.get("epic", "false")) require_two_player = str_to_bool(request.query.get("require_two_player", "false")) rated = request.query.get("rated") if rated is not None: rated = str_to_bool(rated) song_id = request.query.get("song_id") if song_id is not None: song_id = int(song_id) use_custom_song = str_to_bool(request.query.get("use_custom_song", "false")) require_original = str_to_bool(request.query.get("require_original", "false")) followed = request.query.get("followed") if followed is not None: followed = map(int, followed.split(",")) if user_query is None: user = None elif is_id: user = await request.app.client.fetch_user(int(user_query)) else: user = await request.app.client.find_user(user_query) filters = gd.Filters( strategy=strategy, difficulty=difficulty, demon_difficulty=demon_difficulty, length=length, uncompleted=uncompleted, only_completed=only_completed, completed_levels=completed_levels, require_coins=require_coins, featured=featured, epic=epic, rated=rated, require_two_player=require_two_player, song_id=song_id, use_custom_song=use_custom_song, require_original=require_original, followed=followed, ) levels = await request.app.client.search_levels( query=query, filters=filters, user=user, gauntlet=gauntlet, pages=pages ) return json_resp(levels) @routes.get("/api/load") @handle_errors({gd.MissingAccess: Error(404, "Failed to load the save.", ErrorType.FAILED)}) @auth_setup(required=True) @cooldown(rate=10, per=100) async def load_save(request: web.Request) -> web.Response: """GET /api/load Description: Load save and return it as JSON. Example: link: /api/load Returns: 200: JSON with the save and the database; 404: Failed to load the save. Return Type: application/json """ await request.app.client.load() return json_resp( {"database": request.app.client.db, "save": request.app.client.save}, json_indent=None, json_separators=(",", ":"), ) def convert_to_encoded(string: str) -> str: try: data = gd.api.Part(json.loads(string)).dump() except json.JSONDecodeError: if "?xml" in string: # xml data = gd.Coder.encode_save(string) else: # assume base64 data = string return data @routes.patch("/api/save") @handle_errors({gd.MissingAccess: Error(404, "Failed to save.", ErrorType.FAILED)}) @auth_setup(required=True) @cooldown(rate=10, per=100) async def backup_save(request: web.Request) -> web.Response: """PATCH /api/save Description: Request save backup with given main and levels parts. Example: link: /api/save?main=MAIN&levels=LEVELS main: MAIN levels: LEVELS Parameters: main: Main part of the save (CCGameManager.dat). JSON, XML or Base64 format; levels: Levels part of the save (CCLocalLevels.dat). JSON, XML or Base64 format. Returns: 200: Empty JSON; 404: Failed to do a backup. Return Type: application/json """ main = convert_to_encoded(request.query["main"]) levels = convert_to_encoded(request.query["levels"]) await request.app.client.backup(save_data=[main, levels]) @routes.delete("/api/comment/{id}") @handle_errors( { KeyError: Error(400, "Parameter is missing.", ErrorType.MISSING_PARAMETER), ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to delete a comment.", ErrorType.FAILED), } ) @auth_setup(required=True) async def delete_comment(request: web.Request) -> web.Response: """DELETE /api/comment/{id} Description: Delete the profile comment, given by ID. Example: link: /api/comment/123456789 Returns: 200: Empty JSON; 400: Parameter is missing; 404: Failed to delete a comment. Return Type: application/json """ comment_id = int(request.match_info["id"]) await gd.Comment(type=gd.CommentType.PROFILE, id=comment_id, client=request.app.client).delete() return json_resp({}) @routes.delete("/api/level/{level_id}/comment/{comment_id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Failed to delete a comment.", ErrorType.FAILED), } ) @auth_setup(required=True) async def delete_level_comment(request: web.Request) -> web.Response: """DELETE /api/level/{level_id}/comment/{comment_id} Description: Delete the level (given by ID) comment, given by ID. Example: link: /api/level/1/comment/123456789 Returns: 200: Empty JSON; 404: Failed to delete a comment. Return Type: application/json """ level_id = int(request.match_info["level_id"]) comment_id = int(request.match_info["comment_id"]) await gd.Comment( type=gd.CommentType.LEVEL, id=comment_id, level_id=level_id, client=request.app.client ).delete() return json_resp({}) @routes.get("/api/ng/song/{id}") @handle_errors( { ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE), gd.MissingAccess: Error(404, "Requested song not found.", ErrorType.NOT_FOUND), } ) @auth_setup(required=False) async def ng_song_search(request: web.Request) -> web.Response: """GET /api/ng/song/{id} Description: Fetch a song on Newgrounds by its ID. Example: link: /api/ng/song/1 Returns: 200: JSON with song info; 400: Invalid type in payload; 404: Song was not found. Return Type: application/json """ query = int(request.match_info["id"]) song = await request.app.client.get_ng_song(query) return json_resp(song) @routes.get("/api/ng/users/{query}") @handle_errors({ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE)}) @auth_setup(required=False) async def ng_user_search(request: web.Request) -> web.Response: """GET /api/ng/users/{query} Description: Search for users on Newgrounds by given query. Example: link: /api/ng/users/Xtrullor?pages=0,1,2,3 pages: 0,1,2,3 Parameters: pages: Pages to load. Returns: 200: JSON with user info; 400: Invalid type in payload. Return Type: application/json """ query = request.match_info["query"] pages = map(int, request.query.get("pages", "0").split(",")) users = await request.app.client.search_users(query, pages=pages) return json_resp(users) @routes.get("/api/ng/songs/{query}") @handle_errors({ValueError: Error(400, "Invalid type in payload.", ErrorType.INVALID_TYPE)}) @auth_setup(required=False) async def ng_songs_search(request: web.Request) -> web.Response: """GET /api/ng/songs/{query} Description: Find songs on Newgrounds by given query. Example: link: /api/ng/songs/Panda Eyes?pages=0,1,2,3 pages: 0,1,2,3 Parameters: pages: Pages to load. Returns: 200: JSON with user info; 400: Invalid type in payload. Return Type: application/json """ query = request.match_info["query"] pages = map(int, request.query.get("pages", "0").split(",")) songs = await request.app.client.search_songs(query, pages=pages) return json_resp(songs) @routes.get("/api/ng/user_songs/{user}") @handle_errors() @auth_setup(required=False) async def search_songs_by_user(request: web.Request) -> web.Response: """GET /api/ng/user_songs/{user} Description: Find songs by given artist on Newgrounds. Example: link: /api/ng/user_songs/CreoMusic?pages=0,1,2,3 pages: 0,1,2,3 Parameters: pages: Pages to load. Returns: 200: JSON with song info. Return Type: application/json """ query = request.match_info["user"] pages = map(int, request.query.get("pages", "0").split(",")) user_songs = await request.app.client.get_user_songs(query, pages=pages) return json_resp(user_songs)