From 4cb02c55eaa4adb12977084c275ccc01162d5745 Mon Sep 17 00:00:00 2001 From: Alexandre Teles Date: Thu, 13 Oct 2022 01:06:50 -0300 Subject: [PATCH] feat: implement cdn mirrors endpoints, closes #15 (#17) * feat: add cdn mirror endpoints * refactor: change API version in docs * docs: fix titles on API docs page --- app/controllers/Mirrors.py | 111 ++++++++++++++++++++++++++++ app/main.py | 2 + app/models/GeneralErrors.py | 22 +++++- app/models/MirrorModels.py | 52 ++++++++++++++ app/routers/mirrors.py | 139 ++++++++++++++++++++++++++++++++++++ app/utils/Logger.py | 30 ++++---- config.toml | 13 ++-- 7 files changed, 345 insertions(+), 24 deletions(-) create mode 100644 app/controllers/Mirrors.py create mode 100644 app/models/MirrorModels.py create mode 100644 app/routers/mirrors.py diff --git a/app/controllers/Mirrors.py b/app/controllers/Mirrors.py new file mode 100644 index 0000000..de4a8f4 --- /dev/null +++ b/app/controllers/Mirrors.py @@ -0,0 +1,111 @@ +import toml +from redis import asyncio as aioredis +import app.utils.Logger as Logger +from app.models.MirrorModels import MirrorModel, MirrorStoreModel +from app.utils.RedisConnector import RedisConnector + +config: dict = toml.load("config.toml") + +class Mirrors: + """Implements the Mirror class for the ReVanced API""" + + redis = RedisConnector.connect(config['mirrors']['database']) + + MirrorsLogger = Logger.MirrorsLogger() + + async def assemble_key(self, org: str, repo: str, version: str) -> str: + """Assemble the Redis key for the cdn + + Returns: + str: The Redis key + """ + + return f"{org}/{repo}/{version}" + + async def store(self, org: str, repo: str, version: str, mirror: MirrorStoreModel) -> bool: + """Store mirrors in the database + + Args: + mirror (MirrorStoreModel): Pydantic model of the mirror information + + Returns: + bool: True if data was stored successfully, False otherwise + """ + + key = await self.assemble_key(org, repo, version) + mirror_payload: dict[str, str | list[str]] = {} + + mirror_payload['cid'] = mirror.cid + mirror_payload['filenames'] = mirror.filenames + + try: + await self.redis.json().set(key, '$', mirror_payload) + await self.MirrorsLogger.log("SET", None, key) + except aioredis.RedisError as e: + await self.MirrorsLogger.log("SET", e) + raise e + + return True + + async def exists(self, org: str, repo: str, version: str) -> bool: + """Check if a cdn exists in the database + + Returns: + bool: True if the cdn exists, False otherwise + """ + + key = await self.assemble_key(org, repo, version) + + try: + if await self.redis.exists(key): + await self.MirrorsLogger.log("EXISTS", None, key) + return True + else: + return False + except aioredis.RedisError as e: + await self.MirrorsLogger.log("EXISTS", e) + raise e + + async def get(self, org: str, repo: str, version: str) -> MirrorModel: + """Get the mirror information from the database + + Returns: + dict[str, str | int]: The mirror information + """ + + key = await self.assemble_key(org, repo, version) + + try: + payload: dict[str, str | list[str]] = await self.redis.json().get(key) + + mirror = MirrorModel( + repository=f"{org}/{repo}", + version=version, + cid=payload['cid'], + filenames=payload['filenames'] + ) + + await self.MirrorsLogger.log("GET", None, key) + return mirror + except aioredis.RedisError as e: + await self.MirrorsLogger.log("GET", e) + raise e + + async def delete(self, org: str, repo: str, version: str) -> bool: + """Delete the cdn from the database + + Returns: + bool: True if the cdn was deleted successfully, False otherwise + """ + + key = await self.assemble_key(org, repo, version) + + try: + await self.redis.delete(key) + await self.MirrorsLogger.log("DELETE", None, key) + return True + except aioredis.RedisError as e: + await self.MirrorsLogger.log("DELETE", e) + raise e + + \ No newline at end of file diff --git a/app/main.py b/app/main.py index 4d41f66..0b39b6e 100755 --- a/app/main.py +++ b/app/main.py @@ -31,6 +31,7 @@ from app.routers import auth from app.routers import tools from app.routers import clients from app.routers import patches +from app.routers import mirrors from app.routers import changelogs from app.routers import contributors from app.routers import announcement @@ -74,6 +75,7 @@ app.include_router(changelogs.router) app.include_router(auth.router) app.include_router(clients.router) app.include_router(announcement.router) +app.include_router(mirrors.router) app.include_router(ping.router) # Setup cache diff --git a/app/models/GeneralErrors.py b/app/models/GeneralErrors.py index 97c0879..a811556 100644 --- a/app/models/GeneralErrors.py +++ b/app/models/GeneralErrors.py @@ -48,4 +48,24 @@ class Unauthorized(BaseModel): """ error: str = "Unauthorized" - message: str = "The client is unauthorized to access this resource" \ No newline at end of file + message: str = "The client is unauthorized to access this resource" + +class MirrorNotFoundError(BaseModel): + """Implements the response fields for when a mirror is not found. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + error: str = "Not Found" + message: str = "No mirror was found for the organization, repository, and version provided." + +class MirrorAlreadyExistsError(BaseModel): + """Implements the response fields for when a mirror already exists. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + error: str = "Conflict" + message: str = "A mirror already exists for the organization, repository, and version provided. Please use the PUT method to update the mirror." \ No newline at end of file diff --git a/app/models/MirrorModels.py b/app/models/MirrorModels.py new file mode 100644 index 0000000..2226dbe --- /dev/null +++ b/app/models/MirrorModels.py @@ -0,0 +1,52 @@ +from pydantic import BaseModel + +class MirrorModel(BaseModel): + """Implements the response fields for the CDN mirror. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + repository: str + version: str + cid: str + filenames: list[str] + +class MirrorStoreModel(BaseModel): + """Implements the fields for storing CDN mirror information. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + cid: str + filenames: list[str] + +class MirrorCreatedResponseModel(BaseModel): + """Implements the response fields for stored CDN mirrors. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + created: bool + key: str + +class MirrorUpdatedResponseModel(BaseModel): + """Implements the response fields for updated CDN mirrors. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + updated: bool + key: str + +class MirrorDeletedResponseModel(BaseModel): + """Implements the response fields for deleted CDN mirrors. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + deleted: bool + key: str \ No newline at end of file diff --git a/app/routers/mirrors.py b/app/routers/mirrors.py new file mode 100644 index 0000000..c7444a9 --- /dev/null +++ b/app/routers/mirrors.py @@ -0,0 +1,139 @@ +from fastapi_paseto_auth import AuthPASETO +from fastapi import APIRouter, Request, Response, Depends, status, HTTPException +from app.dependencies import load_config +from fastapi_cache.decorator import cache +from app.controllers.Clients import Clients +from app.controllers.Mirrors import Mirrors +import app.models.MirrorModels as MirrorModels +import app.models.GeneralErrors as GeneralErrors + +router = APIRouter( + prefix="/mirrors", + tags=['CDN Mirrors'] +) + +clients = Clients() +mirrors = Mirrors() + +config: dict = load_config() + +@router.get('/{org}/{repo}/{version}', status_code=status.HTTP_200_OK, response_model=MirrorModels.MirrorModel) +async def get_mirrors(request: Request, response: Response, org: str, repo: str, version: str) -> MirrorModels.MirrorModel: + """Get CDN mirror information for a given release. + + Returns: + json: mirror information + """ + + if not await mirrors.exists(org, repo, version): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={ + "error": GeneralErrors.MirrorNotFoundError().error, + "message": GeneralErrors.MirrorNotFoundError().message + } + ) + else: + return await mirrors.get(org, repo, version) + +@router.post('/{org}/{repo}/{version}', status_code=status.HTTP_201_CREATED, response_model=MirrorModels.MirrorCreatedResponseModel) +async def create_mirror(request: Request, response: Response, org: str, repo: str, version: str, + mirror: MirrorModels.MirrorStoreModel, Authorize: AuthPASETO = Depends()) -> dict: + """Stores information about a new CDN mirror for a given release. + + Returns: + bool: True if successful, False otherwise + """ + Authorize.paseto_required() + + if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()): + if await mirrors.exists(org, repo, version): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={ + "error": GeneralErrors.MirrorAlreadyExistsError().error, + "message": GeneralErrors.MirrorAlreadyExistsError().message + } + ) + else: + key = await mirrors.assemble_key(org, repo, version) + created: bool = await mirrors.store(org, repo, version, mirror) + if created: + return {"created": created, "key": key} + else: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ + "error": GeneralErrors.InternalServerError().error, + "message": GeneralErrors.InternalServerError().message + } + ) + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) + +@router.put('/{org}/{repo}/{version}', status_code=status.HTTP_200_OK, response_model=MirrorModels.MirrorUpdatedResponseModel) +async def update_mirror(request: Request, response: Response, org: str, repo: str, version: str, + mirror: MirrorModels.MirrorStoreModel,Authorize: AuthPASETO = Depends()) -> dict: + """Updates a stored information about CDN mirrors for a given release + + Returns: + bool: True if successful, False otherwise + """ + Authorize.paseto_required() + + if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()): + if not await mirrors.exists(org, repo, version): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={ + "error": GeneralErrors.MirrorNotFoundError().error, + "message": GeneralErrors.MirrorNotFoundError().message + } + ) + else: + key = await mirrors.assemble_key(org, repo, version) + updated: bool = await mirrors.store(org, repo, version, mirror) + if updated: + return {"updated": updated, "key": key} + else: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ + "error": GeneralErrors.InternalServerError().error, + "message": GeneralErrors.InternalServerError().message + } + ) + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) + +@router.delete('/{org}/{repo}/{version}', status_code=status.HTTP_200_OK, response_model=MirrorModels.MirrorDeletedResponseModel) +async def delete_mirror(request: Request, response: Response, org: str, repo: str, version: str, Authorize: AuthPASETO = Depends()) -> dict: + """Deletes a stored information about CDN mirrors for a given release + + Returns: + json: _description_ + """ + Authorize.paseto_required() + + if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()): + if not await mirrors.exists(org, repo, version): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={ + "error": GeneralErrors.MirrorNotFoundError().error, + "message": GeneralErrors.MirrorNotFoundError().message + } + ) + else: + key = await mirrors.assemble_key(org, repo, version) + deleted: bool = await mirrors.delete(org, repo, version) + if deleted: + return {"deleted": deleted, "key": key} + else: + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={ + "error": GeneralErrors.InternalServerError().error, + "message": GeneralErrors.InternalServerError().message + } + ) + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) \ No newline at end of file diff --git a/app/utils/Logger.py b/app/utils/Logger.py index ed9c815..7940212 100644 --- a/app/utils/Logger.py +++ b/app/utils/Logger.py @@ -1,5 +1,3 @@ -import sys -import logging from loguru import logger from redis import RedisError from argon2.exceptions import VerifyMismatchError @@ -66,19 +64,15 @@ class AnnouncementsLogger: else: logger.info(f"[User] REDIS {operation} {key} - OK") -def setup_logging(LOG_LEVEL: str, JSON_LOGS: bool) -> None: - - """Setup logging for uvicorn and FastAPI.""" - - # intercept everything at the root logger - logging.root.handlers = [InterceptHandler()] - logging.root.setLevel(LOG_LEVEL) - - # remove every other logger's handlers - # and propagate to root logger - for name in logging.root.manager.loggerDict.keys(): - logging.getLogger(name).handlers = [] - logging.getLogger(name).propagate = True - - # configure loguru - logger.configure(handlers=[{"sink": sys.stdout, "serialize": JSON_LOGS}]) +class MirrorsLogger: + async def log(self, operation: str, result: RedisError | None = None, key: str = "") -> None: + """Logs internal cache operations + + Args: + operation (str): Operation name + key (str): Key used in the operation + """ + if type(result) is RedisError: + logger.error(f"[User] REDIS {operation} - Failed with error: {result}") + else: + logger.info(f"[User] REDIS {operation} {key} - OK") diff --git a/config.toml b/config.toml index 2d3a80a..ea447ea 100644 --- a/config.toml +++ b/config.toml @@ -2,28 +2,28 @@ title = "ReVanced Releases API" description = """ -# The official JSON API for ReVanced Releases 🚀 +## The official JSON API for ReVanced Releases 🚀 -## Links +### Links - [Changelogs](https://github.com/revanced/) - [Official links to ReVanced](https://revanced.app) -## Important Information +### Important Information * Rate Limiting - 60 requests per minute * Cache - 5 minutes * Token duration - 1 hour * Token refresh - 30 days -## Additional Notes +### Additional Notes 1. Breaking changes are to be expected 2. Client side caching is adviced to avoid unnecessary requests 3. Abuse of the API will result in IP blocks """ -version = "0.8.5 RC" +version = "0.9 RC2" [license] @@ -51,6 +51,9 @@ database = 3 [announcements] database = 4 +[mirrors] +database = 5 + [app] repositories = ["TeamVanced/VancedMicroG", "revanced/revanced-cli", "revanced/revanced-patcher", "revanced/revanced-patches", "revanced/revanced-integrations", "revanced/revanced-manager"]