diff --git a/customiwmserver/__main__.py b/customiwmserver/__main__.py index 471086d..0daaea9 100644 --- a/customiwmserver/__main__.py +++ b/customiwmserver/__main__.py @@ -1,3 +1,4 @@ import uvicorn + if __name__ == "__main__": uvicorn.run("customiwmserver.main:app", host="0.0.0.0", port=8001, reload=True) diff --git a/customiwmserver/data_types.py b/customiwmserver/data_types.py index c290bd4..65c6905 100644 --- a/customiwmserver/data_types.py +++ b/customiwmserver/data_types.py @@ -133,11 +133,13 @@ class Notification(BaseModel): } Type: int = 0 + class Map(BaseModel): """Pydantic model for maps. It looks like a lot of variables are also included in the user data. """ + ID: int = 0 CreatorId: int = 0 @@ -156,7 +158,7 @@ class Map(BaseModel): DragonCoins: bool = False # Might be duplicates from the User data - + ShoesColor: int = 0 PantsColor: int = 0 ShirtColor: int = 10897172 @@ -189,17 +191,15 @@ class Map(BaseModel): NumThumbsUp: int = 0 NumThumbsDown: int = 0 - TagIDs: str = "" TagNames: str = "" TagFreqs: str = "" - PlayCount: int = 0 ClearCount: int = 0 - + # I'm not sure how ClearRate is handled - # in the official server, but I assume it's + # in the official server, but I assume it's # calculated as ClearCount / PlayCount. ClearRate: float = 0.0 FavoriteCount: int = 0 @@ -216,9 +216,11 @@ class Map(BaseModel): # Propably not needed, because you can simply read the # FirstClearUserID, and get the name from there # It's still useful for requests though. - FirstClearUsername: Union[str, None] = "uwu" + FirstClearUsername: Union[str, None] = "uwu" MapData: str = "" - MapReplay: str = "" # Might be also stored in DB as a dict of user IDs and the replays + MapReplay: str = ( + "" # Might be also stored in DB as a dict of user IDs and the replays + ) Played: bool = False Clear: bool = False FullClear: bool = False @@ -229,6 +231,7 @@ class Map(BaseModel): class MapLeaderboard(BaseModel): """Pydantic model for Map record leaderboards.""" + BestPlaytime: int BestPlaytimeTime: str BestReplay: str @@ -256,7 +259,7 @@ class MapLeaderboard(BaseModel): FollowerColor: int = 0 SaveEffect: int = 0 TextSnd: int = 0 - + ids_to_names = { 0: "Adventure/Variety", @@ -271,11 +274,11 @@ ids_to_names = { 4: "Auto", } + def convertTagsToNames(tags): """Converts tag IDs to names""" global ids_to_names tagNames = [] - for i in tags.split(','): + for i in tags.split(","): tagNames.append(ids_to_names[int(i)]) return tagNames - diff --git a/customiwmserver/database.py b/customiwmserver/database.py index a0c5b8e..fb66fdd 100644 --- a/customiwmserver/database.py +++ b/customiwmserver/database.py @@ -14,13 +14,19 @@ reports_collection = db.reports general_collection = db.general admin_log_collection = db.admin_log -def LogAdminAction(action_type: str, action_data: dict, UserID: int = None, success: bool = True): + +def LogAdminAction( + action_type: str, action_data: dict, UserID: int = None, success: bool = True +): """Log administrator action.""" - admin_log_collection.insert_one({ - "date": datetime.utcnow(), - "action_type": action_type, - "action_data": action_data - }) + admin_log_collection.insert_one( + { + "date": datetime.utcnow(), + "action_type": action_type, + "action_data": action_data, + } + ) + def auth_check(Authorization): """Checks credentials. @@ -33,11 +39,11 @@ def auth_check(Authorization): """ username, password = Authorization.split(":") - query = user_collection.find_one({"Username": username}) + query = user_collection.find_one({"Username": username}) if not query: return False, "nouser" - if query['Password'] != password: + if query["Password"] != password: return False, "wrongpass" return True, query diff --git a/customiwmserver/hook_system.py b/customiwmserver/hook_system.py new file mode 100644 index 0000000..db88b15 --- /dev/null +++ b/customiwmserver/hook_system.py @@ -0,0 +1,46 @@ +from functools import reduce + + +class HookException(Exception): + pass + + +class HookMan: + hooks = {} + + def __new__(cls): + return reduce(lambda x, y: y(x), cls.hooks, super(HookMan, cls).__new__(cls)) + + def execute_hooks(self, hook_type, *args, **kwargs): + if hook_type not in self.hooks: + return + + for hook in self.hooks[hook_type]: + print(f"Executing hook {hook.__name__}") + try: + hook()(*args, **kwargs) + except Exception as exc: + raise HookException(exc) + + @classmethod + def add_hook(cls, func, *args, **kwargs): + """Adds a hook.""" + hook_type = func.hook_type + if hook_type in cls.hooks: + cls.hooks[hook_type] = cls.hooks[hook_type] + [func] + else: + cls.hooks[hook_type] = [func] + + +hook = HookMan() + +# @hook.add_hook +# class example_hook: +# """Example hook.""" +# hook_type = "player_login" +# +# def __call__(self, *args, **kwargs): +# print("Hook player_login,", kwargs) +# +# +hook.execute_hooks("player_login", hello="john") diff --git a/customiwmserver/hooks/__init__.py b/customiwmserver/hooks/__init__.py new file mode 100644 index 0000000..0710db8 --- /dev/null +++ b/customiwmserver/hooks/__init__.py @@ -0,0 +1,9 @@ +# from . import * + +import pkgutil + +__path__ = pkgutil.extend_path(__path__, __name__) +for imp, module, ispackage in pkgutil.walk_packages( + path=__path__, prefix=__name__ + "." +): + __import__(module) diff --git a/customiwmserver/hooks/webhook.py b/customiwmserver/hooks/webhook.py new file mode 100644 index 0000000..4f2680c --- /dev/null +++ b/customiwmserver/hooks/webhook.py @@ -0,0 +1,18 @@ +# +# Example module. +# + +from .. import hook_system + +hook = hook_system.hook + + +@hook.add_hook +class Login: + # Different hook types are executed at different places. + # In this case, the hook is executed when a user logs in. + # Check the documentation for more hook types. + hook_type = "player_login" + + def __call__(self, *args, **kwargs): + print("LOGIN HOOK:", args, kwargs) diff --git a/customiwmserver/main.py b/customiwmserver/main.py index 2c1beef..e1fd344 100644 --- a/customiwmserver/main.py +++ b/customiwmserver/main.py @@ -7,136 +7,160 @@ from typing import Union, Optional, Any import uvicorn from . import data_types as types from . import database as db +from . import hook_system +from . import hooks + import pymongo app = FastAPI() +hook = hook_system.hook + @app.exception_handler(RequestValidationError) async def http_exception_handler(request, exc): return PlainTextResponse(f"{str(exc)}", status_code=422) + @app.exception_handler(StarletteHTTPException) async def http_exception_handler(request, exc): - return PlainTextResponse(f"[{exc.status_code}] {str(exc.detail)}", status_code=exc.status_code) + return PlainTextResponse( + f"[{exc.status_code}] {str(exc.detail)}", status_code=exc.status_code + ) + ## Users + @app.post("/api/v1/login") async def login(username: str = Form(), password: str = Form(), version: str = Form()): """User login""" - #FIXME Add login + # FIXME Add login + hook.execute_hooks("player_login", username=username) return {"token": username + ":" + password, "userId": 1} @app.put("/api/v1/user") -async def create_user(username: str = Form(), password: str = Form(), email: str = Form(), version: str = Form()): +async def create_user( + username: str = Form(), + password: str = Form(), + email: str = Form(), + version: str = Form(), +): """Create a user with specified credentials""" token = username + ":" + password - + UserID = len(list(db.user_collection.find({}))) - if UserID == 0: UserID = 1 + if UserID == 0: + UserID = 1 insert_data = { - **types.User( - Username=username, - Email=email, - ID=UserID - ).dict(), + **types.User(Username=username, Email=email, ID=UserID).dict(), "Password": password, } db.user_collection.insert_one(insert_data) return {"token": token, "userId": UserID} + @app.get("/api/v1/notifunread") async def notifunread(): """Returns the number of unread notifications.""" - #FIXME Add notifications + # FIXME Add notifications return 0 + @app.get("/api/v1/refresh") async def refresh_login(Authorization: Union[str, None] = Header(default=None)): """Intended for refreshing user token.""" return {"token": Authorization} + @app.get("/api/v1/user/{user_id}") async def get_user(user_id: int): """Returns specified user's profile.""" - #FIXME use database + # FIXME use database query = db.user_collection.find_one({"ID": user_id}) del query["Password"] return types.User(**query) + ## Maps + @app.get("/api/v1/mapcount") async def mapcount(): return len(list(db.maps_collection.find({}))) + + @app.get("/api/v1/useruploadcooldown") async def useruploadcooldown(): """Limits the amount of levels that user can upload.""" return {"success": True} + @app.get("/api/v1/map") async def search_for_maps( - start: int = 0, - limit: int = 5, - min_diff: float = 0.0, - max_diff: float = 5.0, - order: str = '[{ "Dir": "desc", "Name": "created_at" }]', - name: str = "", - author: str = "", - author_id: Optional[int] = None, - last_x_hours: Optional[int] = None - ): + start: int = 0, + limit: int = 5, + min_diff: float = 0.0, + max_diff: float = 5.0, + order: str = '[{ "Dir": "desc", "Name": "created_at" }]', + name: str = "", + author: str = "", + author_id: Optional[int] = None, + last_x_hours: Optional[int] = None, +): """Search for maps.""" # query = db.maps_collection.find({ "CreatorId": author_id }).limit(limit) query = db.maps_collection.find({}).limit(limit) entries = [] for i in query: - del i['_id'] - del i['MapData'] + del i["_id"] + del i["MapData"] entries.append(i) return entries + @app.get("/api/v1/map/{mapID}") async def getMap(mapID: int): - query = db.maps_collection.find_one({ "ID": mapID }) - del query['_id'] + query = db.maps_collection.find_one({"ID": mapID}) + del query["_id"] return query + @app.post("/api/v1/map/{mapID}/start") async def getMap(mapID: int): - query = db.maps_collection.find_one({ "ID": mapID }) - del query['_id'] + query = db.maps_collection.find_one({"ID": mapID}) + del query["_id"] returned_resp = { - "BestDeaths": 0, - "BestPlaytime": 0, - "Clear": False, - "CurMap": query, - "Difficulty": 0, - "Followed": False, - "Played": True, - "Rating": 5, - "TagIDs": "1,8,9", - "TagNames": "Boss/Avoidance,Music,Art" - } + "BestDeaths": 0, + "BestPlaytime": 0, + "Clear": False, + "CurMap": query, + "Difficulty": 0, + "Followed": False, + "Played": True, + "Rating": 5, + "TagIDs": "1,8,9", + "TagNames": "Boss/Avoidance,Music,Art", + } return returned_resp + @app.post("/api/v1/map/{mapID}/stop") async def stopMapPlay( - mapID: int, - clear: int = Form(), - deaths: int = Form(), - playtime: int = Form(), - totalDeaths: int = Form(), - totalTime: int = Form(), - replayData: str = Form(), - Authorization: Union[str, None] = Header(default=None), - ): + mapID: int, + clear: int = Form(), + deaths: int = Form(), + playtime: int = Form(), + totalDeaths: int = Form(), + totalTime: int = Form(), + replayData: str = Form(), + Authorization: Union[str, None] = Header(default=None), +): """Saves the map replay, and informs the user if their play is a record""" authcheck = db.auth_check(Authorization) if not authcheck[0] and authcheck[1] == "nouser": @@ -146,32 +170,35 @@ async def stopMapPlay( elif authcheck[0]: NewMapRecord = False FirstClear = False - + userData = types.User(**authcheck[1]) - query = db.maps_collection.find_one({"ID": mapID, "Leaderboard.UserID": userData.ID}) - if query is not None: del query['_id'] - print(__import__("json").dumps(query,indent=4)) + query = db.maps_collection.find_one( + {"ID": mapID, "Leaderboard.UserID": userData.ID} + ) + if query is not None: + del query["_id"] + print(__import__("json").dumps(query, indent=4)) BestUserTime = None BestTime = None - if query is not None and 'Leaderboard' in query: - print("-"*3) - for i in query['Leaderboard']: - if i['UserID'] == userData.ID: - if BestUserTime is None or BestUserTime > i['BestPlaytime'] : - BestUserTime = i['BestPlaytime'] - if BestTime is None or BestTime > i['BestPlaytime']: + if query is not None and "Leaderboard" in query: + print("-" * 3) + for i in query["Leaderboard"]: + if i["UserID"] == userData.ID: + if BestUserTime is None or BestUserTime > i["BestPlaytime"]: + BestUserTime = i["BestPlaytime"] + if BestTime is None or BestTime > i["BestPlaytime"]: BestTime = i["BestPlaytime"] - if len(query['Leaderboard']) <= 1 and i['UserID'] != userData.ID: + if len(query["Leaderboard"]) <= 1 and i["UserID"] != userData.ID: FirstClear = True print(BestUserTime, BestTime) if BestUserTime is None or playtime < BestUserTime: print(BestUserTime) - updateQuery = db.maps_collection.update_one({"ID": mapID}, + updateQuery = db.maps_collection.update_one( + {"ID": mapID}, { - "$push": { "Leaderboard": types.MapLeaderboard( ShoesColor=userData.ShoesColor, @@ -195,41 +222,53 @@ async def stopMapPlay( FollowerColor=userData.FollowerColor, SaveEffect=userData.SaveEffect, TextSnd=userData.TextSnd, - BestPlaytime=playtime, BestPlaytimeTime="2020-02-13T15:19:33Z", BestReplay=replayData, CreatorName=userData.Username, - UserID=userData.ID - ).dict() - - }, - }) + UserID=userData.ID, + ).dict() + }, + }, + ) if BestTime is None or playtime < BestTime: print(BestTime, playtime) NewMapRecord = True - + + hook.execute_hooks( + "map_finished", + user=userData, + mapID=mapID, + clear=clear, + deaths=deaths, + playtime=playtime, + FirstClear=FirstClear, + NewMapRecord=NewMapRecord, + ) + return {"FirstClear": FirstClear, "NewMapRecord": NewMapRecord} + + @app.put("/api/v1/map") async def upload_map( - Authorization: Union[str, None] = Header(default=None), - mapName: str = Form(), - mapDescription: str = Form(default=''), - mapVersion: int = Form(), - mapData: str = Form(), - file: Optional[UploadFile] = None, - mapReplay: str = Form(), - deaths: int = Form(), - playtime: int = Form(), - totalDeaths: int = Form(), - totalTime: int = Form(), - listed: int = Form(), - requiresCancels: int = Form(), - hideInChallenges: int = Form(), - tags: str = Form(), - rng: int = Form(), - clientVersion: float = Form(), - ): + Authorization: Union[str, None] = Header(default=None), + mapName: str = Form(), + mapDescription: str = Form(default=""), + mapVersion: int = Form(), + mapData: str = Form(), + file: Optional[UploadFile] = None, + mapReplay: str = Form(), + deaths: int = Form(), + playtime: int = Form(), + totalDeaths: int = Form(), + totalTime: int = Form(), + listed: int = Form(), + requiresCancels: int = Form(), + hideInChallenges: int = Form(), + tags: str = Form(), + rng: int = Form(), + clientVersion: float = Form(), +): authcheck = db.auth_check(Authorization) if not authcheck[0] and authcheck[1] == "nouser": raise HTTPException(404, detail="User not found") @@ -238,78 +277,79 @@ async def upload_map( elif authcheck[0]: print(authcheck) userData = types.User(**authcheck[1]) - MapCode = "SUSYBAKA" #FIXME Add mapcodes correctly - db.maps_collection.insert_one({ - **types.Map( - CreatorName=userData.Username, - CreatorId=userData.ID, - - ID=len(list(db.maps_collection.find({}))) + 1, - Name=mapName, - Description=mapDescription, - Version=mapVersion, - MapCode=MapCode, - MapData=mapData, - Listed=bool(listed), - HiddenInChallenges=hideInChallenges, - TagIDs=tags, - TagNames=",".join(types.convertTagsToNames(tags)), - - ShoesColor=userData.ShoesColor, - PantsColor=userData.PantsColor, - ShirtColor=userData.ShirtColor, - CapeColor=userData.CapeColor, - SkinColor=userData.SkinColor, - HairColor=userData.HairColor, - HatSpr=userData.HatSpr, - Country=userData.Country, - HairSpr=userData.HairSpr, - HatColor=userData.HatColor, - HatColorInv=userData.HatColorInv, - FacialExpression=userData.FacialExpression, - DeathEffect=userData.DeathEffect, - GunSpr=userData.GunSpr, - BulletSpr=userData.BulletSpr, - SwordSpr=userData.SwordSpr, - Costume=userData.Costume, - FollowerSpr=userData.FollowerSpr, - FollowerColor=userData.FollowerColor, - SaveEffect=userData.SaveEffect, - TextSnd=userData.TextSnd, - - Leaderboard=[ - types.MapLeaderboard( - BestPlaytime=playtime, - BestPlaytimeTime="2020-02-13T15:19:33Z", - BestReplay=mapReplay, - CreatorName=userData.Username, - UserID=userData.ID - + MapCode = "SUSYBAKA" # FIXME Add mapcodes correctly + db.maps_collection.insert_one( + { + **types.Map( + CreatorName=userData.Username, + CreatorId=userData.ID, + ID=len(list(db.maps_collection.find({}))) + 1, + Name=mapName, + Description=mapDescription, + Version=mapVersion, + MapCode=MapCode, + MapData=mapData, + Listed=bool(listed), + HiddenInChallenges=hideInChallenges, + TagIDs=tags, + TagNames=",".join(types.convertTagsToNames(tags)), + ShoesColor=userData.ShoesColor, + PantsColor=userData.PantsColor, + ShirtColor=userData.ShirtColor, + CapeColor=userData.CapeColor, + SkinColor=userData.SkinColor, + HairColor=userData.HairColor, + HatSpr=userData.HatSpr, + Country=userData.Country, + HairSpr=userData.HairSpr, + HatColor=userData.HatColor, + HatColorInv=userData.HatColorInv, + FacialExpression=userData.FacialExpression, + DeathEffect=userData.DeathEffect, + GunSpr=userData.GunSpr, + BulletSpr=userData.BulletSpr, + SwordSpr=userData.SwordSpr, + Costume=userData.Costume, + FollowerSpr=userData.FollowerSpr, + FollowerColor=userData.FollowerColor, + SaveEffect=userData.SaveEffect, + TextSnd=userData.TextSnd, + Leaderboard=[ + types.MapLeaderboard( + BestPlaytime=playtime, + BestPlaytimeTime="2020-02-13T15:19:33Z", + BestReplay=mapReplay, + CreatorName=userData.Username, + UserID=userData.ID, ).dict() - ] - ).dict() - }) + ], + ).dict() + } + ) return {"MapCode": MapCode} - #raise HTTPException(501) + # raise HTTPException(501) + @app.get("/api/v1/map/{mapID}/besttimes/{maxEntries}") async def getMapLeaderboard(mapID, maxEntries): """Returns maxEntries records for the specified level""" - query = db.maps_collection.find_one({ "ID": int(mapID) }) + query = db.maps_collection.find_one({"ID": int(mapID)}) if not query: - raise HTTPException(404, detail="Map not found") - del query['_id'] - leaderboard = query['Leaderboard'] - + raise HTTPException(404, detail="Map not found") + del query["_id"] + leaderboard = query["Leaderboard"] + return leaderboard + + @app.get("/api/v1/map/{mapID}/userbesttime/{userID}") async def getPlayerRecord(mapID, userID): """Returns specific replay""" - query = db.maps_collection.find_one({ "ID": int(mapID) }) + query = db.maps_collection.find_one({"ID": int(mapID)}) if not query: - raise HTTPException(404, detail="Map not found") - del query['_id'] - leaderboard = query['Leaderboard'] + raise HTTPException(404, detail="Map not found") + del query["_id"] + leaderboard = query["Leaderboard"] # Find user replayIndex = None @@ -320,28 +360,32 @@ async def getPlayerRecord(mapID, userID): break print(leaderboard[replayIndex]) - if replayIndex is not None: return {"BestMapTime": leaderboard[replayIndex], "Exists": True} - else: return {"BestMapTime": None, "Exists": False} + if replayIndex is not None: + return {"BestMapTime": leaderboard[replayIndex], "Exists": True} + else: + return {"BestMapTime": None, "Exists": False} + @app.get("/api/v1/map/{mapID}/besttime") async def getBestRecord(mapID: int): - query = db.maps_collection.find_one({ "ID": int(mapID) }) + query = db.maps_collection.find_one({"ID": int(mapID)}) BestTime = None BestPlay = None - for i in query['Leaderboard']: - if BestTime is None or BestTime > i['BestPlaytime']: + for i in query["Leaderboard"]: + if BestTime is None or BestTime > i["BestPlaytime"]: BestTime = i["BestPlaytime"] BestPlay = i return {"BestMapTime": BestPlay, "Exists": True} + @app.post("/api/v1/map/{mapID}/invalidatealltimes") async def invalidateAllTimes( - mapID: int, - Reason: int = Body(), - CustomReason: str = Body(default=""), - Authorization: Union[str, None] = Header(default=None), - ): + mapID: int, + Reason: int = Body(), + CustomReason: str = Body(default=""), + Authorization: Union[str, None] = Header(default=None), +): """Removes ALL records from a specific map""" authcheck = db.auth_check(Authorization) @@ -352,39 +396,46 @@ async def invalidateAllTimes( elif authcheck[0]: userData = types.User(**authcheck[1]) if userData.Admin: - query = db.maps_collection.update_one({"ID": mapID}, {"$push": { - "Leaderboard": { - "$each": [], - "$slice": 0 - } - } - }) - db.LogAdminAction(action_type="invalidateAllTimes", - action_data={ - "Reason": Reason, - "CustomReason": CustomReason, - "mapID": mapID - }, - UserID=userData.ID - ) + query = db.maps_collection.update_one( + {"ID": mapID}, {"$push": {"Leaderboard": {"$each": [], "$slice": 0}}} + ) + db.LogAdminAction( + action_type="invalidateAllTimes", + action_data={ + "Reason": Reason, + "CustomReason": CustomReason, + "mapID": mapID, + }, + UserID=userData.ID, + ) else: - db.LogAdminAction(action_type="invalidateAllTimes", action_data={ - "Reason": Reason, - "CustomReason": CustomReason, - "mapID": mapID, - "unauthorized": True - }, success=False) - raise HTTPException(403, detail="Attempted to perform an administrator action without permission. This will be reported.") + db.LogAdminAction( + action_type="invalidateAllTimes", + action_data={ + "Reason": Reason, + "CustomReason": CustomReason, + "mapID": mapID, + "unauthorized": True, + }, + success=False, + ) + raise HTTPException( + 403, + detail="Attempted to perform an administrator action without permission. This will be reported.", + ) + + ## General + @app.post("/api/v1/reports") async def reportContent( - Authorization: Union[str, None] = Header(default=None), - user_id: int = Form(), - map_id: Optional[int] = Form(default=None), - report_type: int = Form(), - content: str = Form() - ): + Authorization: Union[str, None] = Header(default=None), + user_id: int = Form(), + map_id: Optional[int] = Form(default=None), + report_type: int = Form(), + content: str = Form(), +): authcheck = db.auth_check(Authorization) if not authcheck[0] and authcheck[1] == "nouser": @@ -392,20 +443,30 @@ async def reportContent( elif not authcheck[0] and authcheck[1] == "wrongpass": raise HTTPException(403, detail="Wrong password") elif authcheck[0]: - db.reports_collection.insert_one({"user_id": user_id, "map_id": map_id, "report_type": report_type, "content": content}) + db.reports_collection.insert_one( + { + "user_id": user_id, + "map_id": map_id, + "report_type": report_type, + "content": content, + } + ) return True + @app.get("/api/v1/featuredlist") async def featuredlist(): """Returns the list id of the weekly levels list.""" - #FIXME Add playlists + # FIXME Add playlists return + @app.get("/api/v1/followcheck") async def followcheck(): - #FIXME Find the purpouse of this endpoint + # FIXME Find the purpouse of this endpoint return 1 + def start(): """Launched with `poetry run start` at root level""" uvicorn.run("customiwmserver.main:app", host="0.0.0.0", port=8001, reload=True)