From 3b418197c2bb8203dc9022c6188d73fd6729e14a Mon Sep 17 00:00:00 2001 From: Alexandre Teles Date: Thu, 13 Oct 2022 01:48:07 -0300 Subject: [PATCH] feat: implements cdn mirrors endpoints, fix docs, move endpoints to custom routers (#18) * feat: resolves #5, resolves #4 (#7) * Implements client generation and management * fix announcements endpoints * change annoucements model * bump deps * sync with main * refactor: adopt some functional standards in Releases.py * feat: add new workflows * chore: remove unused files * refactor: update build badge * refactor: move files around and delete unused ones * feat: add authentication endpoints * refactor: clean up code on Clients.py controller * fix: fix the client secret update endpoint * refactor: clean up authentication code * feat: add authentication to client endpoints * chore: bump deps * feat: add admin user generation * feature: add /changelogs endpoint (#10) * feat: move endpoints into custom routers, resolves #12 (#14) * refactor: import routers from old branch * refactor: import InternalCache removal * refactor: move routes into dedicated routers * fix: fixes entrypoint * refactor: add documentation and bump libs * docs: update description (#16) * 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 Co-authored-by: oSumAtrIX --- Dockerfile | 2 +- README.md | 1 - {src => app}/__init__.py | 0 {src => app}/controllers/Announcements.py | 8 +- {src => app}/controllers/Auth.py | 0 {src => app}/controllers/Clients.py | 8 +- app/controllers/Mirrors.py | 111 +++++ {src => app}/controllers/Releases.py | 112 ++--- {src => app}/controllers/__init__.py | 0 app/dependencies.py | 9 + app/main.py | 158 +++++++ {src => app}/models/AnnouncementModels.py | 0 {src => app}/models/ClientModels.py | 0 {src => app}/models/GeneralErrors.py | 22 +- app/models/MirrorModels.py | 52 +++ {src => app}/models/ResponseFields.py | 0 {src => app}/models/ResponseModels.py | 2 +- {src => app}/models/__init__.py | 0 {src/utils => app/routers}/__init__.py | 0 app/routers/announcement.py | 92 ++++ app/routers/auth.py | 78 ++++ app/routers/changelogs.py | 25 ++ app/routers/clients.py | 172 ++++++++ app/routers/contributors.py | 21 + app/routers/mirrors.py | 139 ++++++ app/routers/patches.py | 22 + app/routers/ping.py | 12 + app/routers/root.py | 14 + app/routers/tools.py | 21 + {src => app}/utils/Generators.py | 0 {src => app}/utils/HTTPXClient.py | 2 +- {src => app}/utils/Logger.py | 73 +--- {src => app}/utils/RedisConnector.py | 0 app/utils/__init__.py | 0 config.toml | 35 +- main.py | 501 ---------------------- mypy.ini | 4 + poetry.lock | 26 +- pyproject.toml | 2 + requirements.txt | 5 +- run.py | 148 +++++++ run.sh | 12 - src/utils/InternalCache.py | 82 ---- 43 files changed, 1208 insertions(+), 763 deletions(-) rename {src => app}/__init__.py (100%) rename {src => app}/controllers/Announcements.py (94%) rename {src => app}/controllers/Auth.py (100%) rename {src => app}/controllers/Clients.py (98%) create mode 100644 app/controllers/Mirrors.py rename {src => app}/controllers/Releases.py (64%) rename {src => app}/controllers/__init__.py (100%) create mode 100644 app/dependencies.py create mode 100755 app/main.py rename {src => app}/models/AnnouncementModels.py (100%) rename {src => app}/models/ClientModels.py (100%) rename {src => app}/models/GeneralErrors.py (67%) create mode 100644 app/models/MirrorModels.py rename {src => app}/models/ResponseFields.py (100%) rename {src => app}/models/ResponseModels.py (98%) rename {src => app}/models/__init__.py (100%) rename {src/utils => app/routers}/__init__.py (100%) create mode 100644 app/routers/announcement.py create mode 100644 app/routers/auth.py create mode 100644 app/routers/changelogs.py create mode 100644 app/routers/clients.py create mode 100644 app/routers/contributors.py create mode 100644 app/routers/mirrors.py create mode 100644 app/routers/patches.py create mode 100644 app/routers/ping.py create mode 100644 app/routers/root.py create mode 100644 app/routers/tools.py rename {src => app}/utils/Generators.py (100%) rename {src => app}/utils/HTTPXClient.py (96%) rename {src => app}/utils/Logger.py (53%) rename {src => app}/utils/RedisConnector.py (100%) create mode 100644 app/utils/__init__.py delete mode 100755 main.py create mode 100755 run.py delete mode 100755 run.sh delete mode 100644 src/utils/InternalCache.py diff --git a/Dockerfile b/Dockerfile index 7392ede..d738c3a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,4 +20,4 @@ RUN apt update && \ apt-get install build-essential libffi-dev -y \ && pip install --no-cache-dir -r requirements.txt -CMD [ "/bin/bash", "./run.sh" ] \ No newline at end of file +CMD [ "python3", "./run.py" ] \ No newline at end of file diff --git a/README.md b/README.md index c8f964d..7fe16cf 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,6 @@ You can deploy your own instance by cloning this repository, editing the `docker | `REDIS_PORT` | The port of your redis server. | | `HYPERCORN_HOST` | The hostname/IP of the API. | | `HYPERCORN_PORT` | The port of the API. | -| `HYPERCORN_LOG_LEVEL` | The log level of the API. | | `SENTRY_DSN` | The DSN of your Sentry instance. | Please note that there are no default values for any of these variables. diff --git a/src/__init__.py b/app/__init__.py similarity index 100% rename from src/__init__.py rename to app/__init__.py diff --git a/src/controllers/Announcements.py b/app/controllers/Announcements.py similarity index 94% rename from src/controllers/Announcements.py rename to app/controllers/Announcements.py index 3e8d932..1f7156c 100644 --- a/src/controllers/Announcements.py +++ b/app/controllers/Announcements.py @@ -1,10 +1,10 @@ import toml from redis import asyncio as aioredis -import src.utils.Logger as Logger -from src.utils.Generators import Generators -from src.models.AnnouncementModels import AnnouncementCreateModel -from src.utils.RedisConnector import RedisConnector +import app.utils.Logger as Logger +from app.utils.Generators import Generators +from app.models.AnnouncementModels import AnnouncementCreateModel +from app.utils.RedisConnector import RedisConnector config: dict = toml.load("config.toml") diff --git a/src/controllers/Auth.py b/app/controllers/Auth.py similarity index 100% rename from src/controllers/Auth.py rename to app/controllers/Auth.py diff --git a/src/controllers/Clients.py b/app/controllers/Clients.py similarity index 98% rename from src/controllers/Clients.py rename to app/controllers/Clients.py index 8711c60..709cf80 100644 --- a/src/controllers/Clients.py +++ b/app/controllers/Clients.py @@ -7,10 +7,10 @@ from redis import asyncio as aioredis import aiofiles import uvloop -import src.utils.Logger as Logger -from src.utils.Generators import Generators -from src.models.ClientModels import ClientModel -from src.utils.RedisConnector import RedisConnector +import app.utils.Logger as Logger +from app.utils.Generators import Generators +from app.models.ClientModels import ClientModel +from app.utils.RedisConnector import RedisConnector config: dict = toml.load("config.toml") 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/src/controllers/Releases.py b/app/controllers/Releases.py similarity index 64% rename from src/controllers/Releases.py rename to app/controllers/Releases.py index d48ea8e..37a63da 100644 --- a/src/controllers/Releases.py +++ b/app/controllers/Releases.py @@ -3,8 +3,7 @@ import asyncio import uvloop import orjson from base64 import b64decode -from src.utils.HTTPXClient import HTTPXClient -from src.utils.InternalCache import InternalCache +from app.utils.HTTPXClient import HTTPXClient class Releases: @@ -15,8 +14,6 @@ class Releases: httpx_client = HTTPXClient.create() - InternalCache = InternalCache() - async def __get_release(self, repository: str) -> list: # Get assets from latest release in a given repository. # @@ -65,21 +62,14 @@ class Releases: dict: A dictionary containing assets from each repository """ - releases: dict[str, list] + releases: dict[str, list] = {} + releases['tools'] = [] - if await self.InternalCache.exists('releases'): - releases = await self.InternalCache.get('releases') - else: - releases = {} - releases['tools'] = [] - - results: list = await asyncio.gather(*[self.__get_release(repository) for repository in repositories]) - - for result in results: - for asset in result: - releases['tools'].append(asset) - - await self.InternalCache.store('releases', releases) + results: list = await asyncio.gather(*[self.__get_release(repository) for repository in repositories]) + + for result in results: + for asset in result: + releases['tools'].append(asset) return releases @@ -101,11 +91,7 @@ class Releases: Returns: dict: Patches available for a given app """ - if await self.InternalCache.exists('patches'): - patches = await self.InternalCache.get('patches') - else: - patches = await self.__get_patches_json() - await self.InternalCache.store('patches', patches) + patches: dict = await self.__get_patches_json() return patches @@ -139,22 +125,17 @@ class Releases: contributors: dict[str, list] - if await self.InternalCache.exists('contributors'): - contributors = await self.InternalCache.get('contributors') - else: - contributors = {} - contributors['repositories'] = [] - - revanced_repositories = [repository for repository in repositories if 'revanced' in repository] - - results: list[dict] = await asyncio.gather(*[self.__get_contributors(repository) for repository in revanced_repositories]) - - for key, value in zip(revanced_repositories, results): - data = { 'name': key, 'contributors': value } - contributors['repositories'].append(data) - - await self.InternalCache.store('contributors', contributors) + contributors = {} + contributors['repositories'] = [] + revanced_repositories = [repository for repository in repositories if 'revanced' in repository] + + results: list[dict] = await asyncio.gather(*[self.__get_contributors(repository) for repository in revanced_repositories]) + + for key, value in zip(revanced_repositories, results): + data = { 'name': key, 'contributors': value } + contributors['repositories'].append(data) + return contributors async def get_commits(self, org: str, repository: str, path: str) -> dict: @@ -180,36 +161,29 @@ class Releases: payload["commits"] = [] if org == 'revanced' or org == 'vancedapp': - key: str = f"{org}/{repository}/{path}" - if await self.InternalCache.exists(key): - return await self.InternalCache.get(key) - else: - - _releases = await self.httpx_client.get( - f"https://api.github.com/repos/{org}/{repository}/releases?per_page=2" - ) - - releases = _releases.json() - - since = releases[1]['created_at'] - until = releases[0]['created_at'] - - _response = await self.httpx_client.get( - f"https://api.github.com/repos/{org}/{repository}/commits?path={path}&since={since}&until={until}" - ) - - response = _response.json() - - for commit in response: - data: dict[str, str] = {} - data["sha"] = commit["sha"] - data["author"] = commit["commit"]["author"]["name"] - data["message"] = commit["commit"]["message"] - data["html_url"] = commit["html_url"] - payload['commits'].append(data) - - await self.InternalCache.store(key, payload) - - return payload + _releases = await self.httpx_client.get( + f"https://api.github.com/repos/{org}/{repository}/releases?per_page=2" + ) + + releases = _releases.json() + + since = releases[1]['created_at'] + until = releases[0]['created_at'] + + _response = await self.httpx_client.get( + f"https://api.github.com/repos/{org}/{repository}/commits?path={path}&since={since}&until={until}" + ) + + response = _response.json() + + for commit in response: + data: dict[str, str] = {} + data["sha"] = commit["sha"] + data["author"] = commit["commit"]["author"]["name"] + data["message"] = commit["commit"]["message"] + data["html_url"] = commit["html_url"] + payload['commits'].append(data) + + return payload else: raise Exception("Invalid organization.") \ No newline at end of file diff --git a/src/controllers/__init__.py b/app/controllers/__init__.py similarity index 100% rename from src/controllers/__init__.py rename to app/controllers/__init__.py diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..b67898c --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,9 @@ +import toml + +def load_config() -> dict: + """Loads the config.toml file. + + Returns: + dict: the config.toml file as a dict + """ + return toml.load("config.toml") diff --git a/app/main.py b/app/main.py new file mode 100755 index 0000000..0b39b6e --- /dev/null +++ b/app/main.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 + +import os +import toml +import binascii + +from fastapi import FastAPI, Request, status +from fastapi.responses import JSONResponse, UJSONResponse + +from slowapi.util import get_remote_address +from slowapi.middleware import SlowAPIMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler + +from fastapi_cache import FastAPICache +from fastapi_cache.decorator import cache +from slowapi.errors import RateLimitExceeded +from fastapi_cache.backends.redis import RedisBackend + +from fastapi_paseto_auth import AuthPASETO +from fastapi_paseto_auth.exceptions import AuthPASETOException + +import app.controllers.Auth as Auth +from app.controllers.Clients import Clients +from app.utils.RedisConnector import RedisConnector + +import app.models.GeneralErrors as GeneralErrors + +from app.routers import root +from app.routers import ping +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 + +"""Get latest ReVanced releases from GitHub API.""" + +# Load config + +config: dict = toml.load("config.toml") + +# Create FastAPI instance + +app = FastAPI(title=config['docs']['title'], + description=config['docs']['description'], + version=config['docs']['version'], + license_info={"name": config['license']['name'], + "url": config['license']['url'] + }, + default_response_class=UJSONResponse + ) + +# Hook up rate limiter +limiter = Limiter(key_func=get_remote_address, + default_limits=[ + config['slowapi']['limit'] + ], + headers_enabled=True, + storage_uri=f"redis://{os.environ['REDIS_URL']}:{os.environ['REDIS_PORT']}/{config['slowapi']['database']}" + ) +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +app.add_middleware(SlowAPIMiddleware) + +# Setup routes + +app.include_router(root.router) +app.include_router(tools.router) +app.include_router(patches.router) +app.include_router(contributors.router) +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 + +@cache() +async def get_cache() -> int: + """Get cache TTL from config. + + Returns: + int: Cache TTL + """ + return 1 + +# Setup PASETO + +@AuthPASETO.load_config +def get_config() -> Auth.PasetoSettings: + """Get PASETO config from Auth module + + Returns: + PasetoSettings: PASETO config + """ + return Auth.PasetoSettings() + +# Setup custom error handlers + +@app.exception_handler(AuthPASETOException) +async def authpaseto_exception_handler(request: Request, exc: AuthPASETOException) -> JSONResponse: + """Handle AuthPASETOException + + Args: + request (Request): Request + exc (AuthPASETOException): Exception + + Returns: + JSONResponse: Response + """ + return JSONResponse(status_code=exc.status_code, content={"detail": exc.message}) + +@app.exception_handler(AttributeError) +async def validation_exception_handler(request, exc) -> JSONResponse: + """Handle AttributeError + + Args: + request (Request): Request + exc (AttributeError): Exception + + Returns: + JSONResponse: Response + """ + return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ + "error": "Unprocessable Entity" + }) + +@app.exception_handler(binascii.Error) +async def invalid_token_exception_handler(request, exc) -> JSONResponse: + """Handle binascii.Error + + Args: + request (Request): Request + exc (binascii.Error): Exception + + Returns: + JSONResponse: Response + """ + return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + }) + +@app.on_event("startup") +async def startup() -> None: + """Startup event handler""" + + clients = Clients() + await clients.setup_admin() + FastAPICache.init(RedisBackend(RedisConnector.connect(config['cache']['database'])), + prefix="fastapi-cache") + + return None \ No newline at end of file diff --git a/src/models/AnnouncementModels.py b/app/models/AnnouncementModels.py similarity index 100% rename from src/models/AnnouncementModels.py rename to app/models/AnnouncementModels.py diff --git a/src/models/ClientModels.py b/app/models/ClientModels.py similarity index 100% rename from src/models/ClientModels.py rename to app/models/ClientModels.py diff --git a/src/models/GeneralErrors.py b/app/models/GeneralErrors.py similarity index 67% rename from src/models/GeneralErrors.py rename to app/models/GeneralErrors.py index 97c0879..a811556 100644 --- a/src/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/src/models/ResponseFields.py b/app/models/ResponseFields.py similarity index 100% rename from src/models/ResponseFields.py rename to app/models/ResponseFields.py diff --git a/src/models/ResponseModels.py b/app/models/ResponseModels.py similarity index 98% rename from src/models/ResponseModels.py rename to app/models/ResponseModels.py index 70b9976..ee4cfd7 100644 --- a/src/models/ResponseModels.py +++ b/app/models/ResponseModels.py @@ -1,5 +1,5 @@ from pydantic import BaseModel -import src.models.ResponseFields as ResponseFields +import app.models.ResponseFields as ResponseFields """Implements pydantic models and model generator for the API's responses.""" diff --git a/src/models/__init__.py b/app/models/__init__.py similarity index 100% rename from src/models/__init__.py rename to app/models/__init__.py diff --git a/src/utils/__init__.py b/app/routers/__init__.py similarity index 100% rename from src/utils/__init__.py rename to app/routers/__init__.py diff --git a/app/routers/announcement.py b/app/routers/announcement.py new file mode 100644 index 0000000..73ebcc8 --- /dev/null +++ b/app/routers/announcement.py @@ -0,0 +1,92 @@ +from fastapi_paseto_auth import AuthPASETO +from fastapi import APIRouter, Request, Response, Depends, status, HTTPException +from app.dependencies import load_config +from app.controllers.Announcements import Announcements +from app.controllers.Clients import Clients +import app.models.AnnouncementModels as AnnouncementModels +import app.models.GeneralErrors as GeneralErrors + +router = APIRouter( + prefix="/announcement", + tags=['Announcement'] +) + +clients = Clients() +announcements = Announcements() +config: dict = load_config() + +@router.post('/', response_model=AnnouncementModels.AnnouncementCreatedResponse, + status_code=status.HTTP_201_CREATED) +async def create_announcement(request: Request, response: Response, + announcement: AnnouncementModels.AnnouncementCreateModel, + Authorize: AuthPASETO = Depends()) -> dict: + """Create a new announcement. + + Returns: + json: announcement information + """ + Authorize.paseto_required() + + if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()): + announcement_created: bool = await announcements.store(announcement=announcement, + author=Authorize.get_subject()) + + if announcement_created: + return {"created": announcement_created} + else: + raise HTTPException(status_code=500, 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.get('/', response_model=AnnouncementModels.AnnouncementModel) +async def get_announcement(request: Request, response: Response) -> dict: + """Get an announcement. + + Returns: + json: announcement information + """ + if await announcements.exists(): + return await announcements.get() + else: + raise HTTPException(status_code=404, detail={ + "error": GeneralErrors.AnnouncementNotFound().error, + "message": GeneralErrors.AnnouncementNotFound().message + } + ) + +@router.delete('/', + response_model=AnnouncementModels.AnnouncementDeleted, + status_code=status.HTTP_200_OK) +async def delete_announcement(request: Request, response: Response, + Authorize: AuthPASETO = Depends()) -> dict: + """Delete an announcement. + + Returns: + json: deletion status + """ + + Authorize.paseto_required() + + if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()): + if await announcements.exists(): + return {"deleted": await announcements.delete()} + else: + raise HTTPException(status_code=404, detail={ + "error": GeneralErrors.AnnouncementNotFound().error, + "message": GeneralErrors.AnnouncementNotFound().message + } + ) + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) diff --git a/app/routers/auth.py b/app/routers/auth.py new file mode 100644 index 0000000..cbbbecd --- /dev/null +++ b/app/routers/auth.py @@ -0,0 +1,78 @@ +from fastapi_paseto_auth import AuthPASETO +from fastapi import APIRouter, Request, Response, Depends, status, HTTPException +from app.dependencies import load_config +from app.controllers.Clients import Clients +import app.models.ClientModels as ClientModels +import app.models.GeneralErrors as GeneralErrors +import app.models.ResponseModels as ResponseModels + +router = APIRouter( + prefix="/auth", + tags=['Authentication'] +) +clients = Clients() +config: dict = load_config() + +@router.post('/', response_model=ResponseModels.ClientAuthTokenResponse, status_code=status.HTTP_200_OK) +async def auth(request: Request, response: Response, client: ClientModels.ClientAuthModel, Authorize: AuthPASETO = Depends()) -> dict: + """Authenticate a client and get an auth token. + + Returns: + access_token: auth token + refresh_token: refresh token + """ + + admin_claim: dict[str, bool] + + if await clients.exists(client.id): + authenticated: bool = await clients.authenticate(client.id, client.secret) + + if not authenticated: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) + else: + if await clients.is_admin(client.id): + admin_claim = {"admin": True} + else: + admin_claim = {"admin": False} + + access_token = Authorize.create_access_token(subject=client.id, + user_claims=admin_claim, + fresh=True) + + refresh_token = Authorize.create_refresh_token(subject=client.id, + user_claims=admin_claim) + + return {"access_token": access_token, "refresh_token": refresh_token} + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) + +@router.post('/refresh', response_model=ResponseModels.ClientTokenRefreshResponse, + status_code=status.HTTP_200_OK, tags=['Authentication']) +async def refresh(request: Request, response: Response, + Authorize: AuthPASETO = Depends()) -> dict: + """Refresh an auth token. + + Returns: + access_token: auth token + """ + + Authorize.paseto_required(refresh_token=True) + + admin_claim: dict[str, bool] = {"admin": False} + + current_user: str | int | None = Authorize.get_subject() + + if 'admin' in Authorize.get_token_payload(): + admin_claim = {"admin": Authorize.get_token_payload()['admin']} + + return {"access_token": Authorize.create_access_token(subject=current_user, + user_claims=admin_claim, + fresh=False)} \ No newline at end of file diff --git a/app/routers/changelogs.py b/app/routers/changelogs.py new file mode 100644 index 0000000..cd406f2 --- /dev/null +++ b/app/routers/changelogs.py @@ -0,0 +1,25 @@ +from fastapi import APIRouter, Request, Response +from fastapi_cache.decorator import cache +from app.dependencies import load_config +from app.controllers.Releases import Releases +import app.models.ResponseModels as ResponseModels + +router = APIRouter() + +releases = Releases() + +config: dict = load_config() + +@router.get('/changelogs/{org}/{repo}', response_model=ResponseModels.ChangelogsResponseModel, tags=['ReVanced Tools']) +@cache(config['cache']['expire']) +async def changelogs(request: Request, response: Response, org: str, repo: str, path: str) -> dict: + """Get the latest changes from a repository. + + Returns: + json: list of commits + """ + return await releases.get_commits( + org=org, + repository=repo, + path=path + ) diff --git a/app/routers/clients.py b/app/routers/clients.py new file mode 100644 index 0000000..2086193 --- /dev/null +++ b/app/routers/clients.py @@ -0,0 +1,172 @@ +from fastapi_paseto_auth import AuthPASETO +from fastapi import APIRouter, Request, Response, Depends, status, HTTPException +from app.dependencies import load_config +from app.controllers.Clients import Clients +import app.models.ClientModels as ClientModels +import app.models.ResponseModels as ResponseModels +import app.models.GeneralErrors as GeneralErrors +from app.utils.Generators import Generators + +router = APIRouter( + prefix="/client", + tags=['Clients'] +) +generators = Generators() +clients = Clients() +config: dict = load_config() + +@router.post('/', response_model=ClientModels.ClientModel, status_code=status.HTTP_201_CREATED) +async def create_client(request: Request, response: Response, admin: bool | None = False, Authorize: AuthPASETO = Depends()) -> ClientModels.ClientModel: + """Create a new API client. + + Returns: + json: client information + """ + + Authorize.paseto_required() + + admin_claim: dict[str, bool] = {"admin": False} + + current_user: str | int | None = Authorize.get_subject() + + if 'admin' in Authorize.get_token_payload(): + admin_claim = {"admin": Authorize.get_token_payload()['admin']} + + if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and + admin_claim['admin'] == True): + + client: ClientModels.ClientModel = await clients.generate(admin=admin) + await clients.store(client) + + return client + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) + + +@router.delete('/{client_id}', response_model=ResponseModels.ClientDeletedResponse, status_code=status.HTTP_200_OK) +async def delete_client(request: Request, response: Response, client_id: str, Authorize: AuthPASETO = Depends()) -> dict: + """Delete an API client. + + Returns: + json: deletion status + """ + + Authorize.paseto_required() + + admin_claim: dict[str, bool] = {"admin": False} + + current_user: str | int | None = Authorize.get_subject() + + if 'admin' in Authorize.get_token_payload(): + admin_claim = {"admin": Authorize.get_token_payload()['admin']} + + if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and + ( admin_claim['admin'] == True or + current_user == client_id ) ): + + if await clients.exists(client_id): + return {"id": client_id, "deleted": await clients.delete(client_id)} + else: + raise HTTPException(status_code=404, detail={ + "error": GeneralErrors.ClientNotFound().error, + "message": GeneralErrors.ClientNotFound().message + } + ) + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) + +@router.patch('/{client_id}/secret', response_model=ResponseModels.ClientSecretUpdatedResponse, status_code=status.HTTP_200_OK) +async def update_client(request: Request, response: Response, client_id: str, Authorize: AuthPASETO = Depends()) -> dict: + """Update an API client's secret. + + Returns: + json: client ID and secret + """ + + Authorize.paseto_required() + + admin_claim: dict[str, bool] = {"admin": False} + + current_user: str | int | None = Authorize.get_subject() + + if 'admin' in Authorize.get_token_payload(): + admin_claim = {"admin": Authorize.get_token_payload()['admin']} + + if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and + ( admin_claim['admin'] == True or + current_user == client_id ) ): + + if await clients.exists(client_id): + new_secret: str = await generators.generate_secret() + + if await clients.update_secret(client_id, new_secret): + return {"id": client_id, "secret": new_secret} + else: + raise HTTPException(status_code=500, detail={ + "error": GeneralErrors.InternalServerError().error, + "message": GeneralErrors.InternalServerError().message + } + ) + else: + raise HTTPException(status_code=404, detail={ + "error": GeneralErrors.ClientNotFound().error, + "message": GeneralErrors.ClientNotFound().message + } + ) + else: + raise HTTPException(status_code=401, detail={ + "error": GeneralErrors.Unauthorized().error, + "message": GeneralErrors.Unauthorized().message + } + ) + +@router.patch('/client/{client_id}/status', response_model=ResponseModels.ClientStatusResponse, status_code=status.HTTP_200_OK) +async def client_status(request: Request, response: Response, client_id: str, active: bool, Authorize: AuthPASETO = Depends()) -> dict: + """Activate or deactivate a client + + Returns: + json: json response containing client ID and activation status + """ + + Authorize.paseto_required() + + admin_claim: dict[str, bool] = {"admin": False} + + current_user: str | int | None = Authorize.get_subject() + + if 'admin' in Authorize.get_token_payload(): + admin_claim = {"admin": Authorize.get_token_payload()['admin']} + + if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and + ( admin_claim['admin'] == True or + current_user == client_id ) ): + + if await clients.exists(client_id): + if await clients.status(client_id, active): + return {"id": client_id, "active": active} + else: + raise HTTPException(status_code=500, detail={ + "error": GeneralErrors.InternalServerError().error, + "message": GeneralErrors.InternalServerError().message + } + ) + else: + raise HTTPException(status_code=404, detail={ + "error": GeneralErrors.ClientNotFound().error, + "message": GeneralErrors.ClientNotFound().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/routers/contributors.py b/app/routers/contributors.py new file mode 100644 index 0000000..1e1552a --- /dev/null +++ b/app/routers/contributors.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Request, Response +from fastapi_cache.decorator import cache +from app.dependencies import load_config +from app.controllers.Releases import Releases +import app.models.ResponseModels as ResponseModels + +router = APIRouter() + +releases = Releases() + +config: dict = load_config() + +@router.get('/contributors', response_model=ResponseModels.ContributorsResponseModel, tags=['ReVanced Tools']) +@cache(config['cache']['expire']) +async def contributors(request: Request, response: Response) -> dict: + """Get contributors. + + Returns: + json: list of contributors + """ + return await releases.get_contributors(config['app']['repositories']) \ 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/routers/patches.py b/app/routers/patches.py new file mode 100644 index 0000000..8213571 --- /dev/null +++ b/app/routers/patches.py @@ -0,0 +1,22 @@ +from fastapi import APIRouter, Request, Response +from fastapi_cache.decorator import cache +from app.dependencies import load_config +from app.controllers.Releases import Releases +import app.models.ResponseModels as ResponseModels + +router = APIRouter() + +releases = Releases() + +config: dict = load_config() + +@router.get('/patches', response_model=ResponseModels.PatchesResponseModel, tags=['ReVanced Tools']) +@cache(config['cache']['expire']) +async def patches(request: Request, response: Response) -> dict: + """Get latest patches. + + Returns: + json: list of latest patches + """ + + return await releases.get_patches_json() diff --git a/app/routers/ping.py b/app/routers/ping.py new file mode 100644 index 0000000..b0ecb88 --- /dev/null +++ b/app/routers/ping.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter, Request, Response + +router = APIRouter() + +@router.head('/ping', status_code=204, tags=['Ping']) +async def ping(request: Request, response: Response) -> None: + """Check if the API is running. + + Returns: + None + """ + return None \ No newline at end of file diff --git a/app/routers/root.py b/app/routers/root.py new file mode 100644 index 0000000..ccd1ae2 --- /dev/null +++ b/app/routers/root.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Request, Response, status +from fastapi.responses import RedirectResponse + +router = APIRouter() + +@router.get("/", response_class=RedirectResponse, + status_code=status.HTTP_301_MOVED_PERMANENTLY, tags=['Root']) +async def root(request: Request, response: Response) -> RedirectResponse: + """Brings up API documentation + + Returns: + None: Redirects to /docs + """ + return RedirectResponse(url="/docs") diff --git a/app/routers/tools.py b/app/routers/tools.py new file mode 100644 index 0000000..8e26f3d --- /dev/null +++ b/app/routers/tools.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Request, Response +from fastapi_cache.decorator import cache +from app.dependencies import load_config +from app.controllers.Releases import Releases +import app.models.ResponseModels as ResponseModels + +router = APIRouter() + +releases = Releases() + +config: dict = load_config() + +@router.get('/tools', response_model=ResponseModels.ToolsResponseModel, tags=['ReVanced Tools']) +@cache(config['cache']['expire']) +async def tools(request: Request, response: Response) -> dict: + """Get patching tools' latest version. + + Returns: + json: information about the patching tools' latest version + """ + return await releases.get_latest_releases(config['app']['repositories']) diff --git a/src/utils/Generators.py b/app/utils/Generators.py similarity index 100% rename from src/utils/Generators.py rename to app/utils/Generators.py diff --git a/src/utils/HTTPXClient.py b/app/utils/HTTPXClient.py similarity index 96% rename from src/utils/HTTPXClient.py rename to app/utils/HTTPXClient.py index 1d5ef4b..bcdd5d0 100644 --- a/src/utils/HTTPXClient.py +++ b/app/utils/HTTPXClient.py @@ -1,6 +1,6 @@ import os import httpx_cache -import src.utils.Logger as Logger +import app.utils.Logger as Logger class HTTPXClient: diff --git a/src/utils/Logger.py b/app/utils/Logger.py similarity index 53% rename from src/utils/Logger.py rename to app/utils/Logger.py index ae7bffe..7940212 100644 --- a/src/utils/Logger.py +++ b/app/utils/Logger.py @@ -1,52 +1,7 @@ -import sys -import logging from loguru import logger -from typing import Optional -from types import FrameType from redis import RedisError from argon2.exceptions import VerifyMismatchError -class InterceptHandler(logging.Handler): - """Setups a loging handler for uvicorn and FastAPI. - - Args: - logging (logging.Handler) - """ - - def emit(self, record: logging.LogRecord) -> None: - """Emit a log record. - - Args: - record (LogRecord): Logging record - """ - - level: str | int - frame: Optional[FrameType] - depth: int - - # Get corresponding Loguru level if it exists - # If not, use default level - - try: - level = logger.level(record.levelname).name - except ValueError: - level = record.levelno - - # Find caller from where originated the logged message - # Set depth to 2 to avoid logging of loguru internal calls - frame = logging.currentframe() - depth = 2 - - # Find caller from where originated the logged message - # The logging module uses a stack frame to keep track of where logging messages originate - # This stack frame is used to find the correct place in the code where the logging message was generated - # The mypy error is ignored because the logging module is not properly typed - while frame.f_code.co_filename == logging.__file__: - frame = frame.f_back - depth += 1 - - logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - class HTTPXLogger(): """Logger adapter for HTTPX.""" @@ -109,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/src/utils/RedisConnector.py b/app/utils/RedisConnector.py similarity index 100% rename from src/utils/RedisConnector.py rename to app/utils/RedisConnector.py diff --git a/app/utils/__init__.py b/app/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config.toml b/config.toml index 2d7a5e1..ea447ea 100644 --- a/config.toml +++ b/config.toml @@ -2,50 +2,44 @@ title = "ReVanced Releases API" description = """ -This website provides a JSON API for ReVanced Releases 🚀 +## The official JSON API for ReVanced Releases 🚀 -Changelogs are not included but can be found on the [ReVanced Repositories](https://github.com/revanced/). +### Links -The team also have a [Discord Server](https://revanced.app/discord) if you need help. +- [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. Although we will try to avoid breaking changes, we can't guarantee that it won't happen. -2. Make sure to implement a cache system on your end to avoid unnecessary requests. -3. API abuse will result in IP blocks. - -Godspeed 💀 +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 RC" +version = "0.9 RC2" [license] name = "AGPL-3.0" url = "https://www.gnu.org/licenses/agpl-3.0.en.html" -[slowapi] - -limit = "60/minute" - [logging] - level = "INFO" json_logs = false [cache] -expire = 120 +expire = 300 database = 0 -[internal-cache] -expire = 300 +[slowapi] +limit = "60/minute" database = 1 [clients] @@ -57,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"] diff --git a/main.py b/main.py deleted file mode 100755 index 21f581b..0000000 --- a/main.py +++ /dev/null @@ -1,501 +0,0 @@ -#!/usr/bin/env python3 - -import binascii -import os -from typing import Coroutine -import toml -import sentry_sdk -import asyncio -import uvloop - -from fastapi import FastAPI, Request, Response, status, HTTPException, Depends -from fastapi.responses import RedirectResponse, JSONResponse, UJSONResponse - -from slowapi.util import get_remote_address -from slowapi import Limiter, _rate_limit_exceeded_handler - -from fastapi_cache import FastAPICache -from fastapi_cache.decorator import cache -from slowapi.errors import RateLimitExceeded -from fastapi_cache.backends.redis import RedisBackend -from fastapi.exceptions import RequestValidationError - -from fastapi_paseto_auth import AuthPASETO -from fastapi_paseto_auth.exceptions import AuthPASETOException - -from sentry_sdk.integrations.redis import RedisIntegration -from sentry_sdk.integrations.httpx import HttpxIntegration -from sentry_sdk.integrations.gnu_backtrace import GnuBacktraceIntegration - -import src.controllers.Auth as Auth -from src.controllers.Releases import Releases -from src.controllers.Clients import Clients -from src.controllers.Announcements import Announcements - -from src.utils.Generators import Generators -from src.utils.RedisConnector import RedisConnector - -import src.models.ClientModels as ClientModels -import src.models.GeneralErrors as GeneralErrors -import src.models.ResponseModels as ResponseModels -import src.models.AnnouncementModels as AnnouncementModels - -import src.utils.Logger as Logger - -# Enable sentry logging - -sentry_sdk.init(os.environ['SENTRY_DSN'], traces_sample_rate=1.0, integrations=[ - RedisIntegration(), - HttpxIntegration(), - GnuBacktraceIntegration(), - ],) - -"""Get latest ReVanced releases from GitHub API.""" - -# Load config - -config: dict = toml.load("config.toml") - -# Class instances - -generators = Generators() - -releases = Releases() - -clients = Clients() - -announcements = Announcements() - -# Setup admin client -uvloop.install() - -loop: asyncio.AbstractEventLoop = asyncio.get_event_loop() -coroutine: Coroutine = clients.setup_admin() -loop.run_until_complete(coroutine) - -# Create FastAPI instance - -app = FastAPI(title=config['docs']['title'], - description=config['docs']['description'], - version=config['docs']['version'], - license_info={"name": config['license']['name'], - "url": config['license']['url'] - }, - default_response_class=UJSONResponse - ) - -# Slowapi limiter - -limiter = Limiter(key_func=get_remote_address, headers_enabled=True) -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - -# Setup cache - -@cache() -async def get_cache() -> int: - return 1 - -# Setup PASETO - -@AuthPASETO.load_config -def get_config(): - return Auth.PasetoSettings() - -# Setup custom error handlers - -@app.exception_handler(AuthPASETOException) -async def authpaseto_exception_handler(request: Request, exc: AuthPASETOException): - return JSONResponse(status_code=exc.status_code, content={"detail": exc.message}) - -@app.exception_handler(AttributeError) -async def validation_exception_handler(request, exc): - return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ - "error": "Unprocessable Entity" - }) - -@app.exception_handler(binascii.Error) -async def invalid_token_exception_handler(request, exc): - return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - }) - -# Routes - -@app.get("/", response_class=RedirectResponse, - status_code=status.HTTP_301_MOVED_PERMANENTLY, tags=['Root']) -@limiter.limit(config['slowapi']['limit']) -async def root(request: Request, response: Response) -> RedirectResponse: - """Brings up API documentation - - Returns: - None: Redirects to /docs - """ - return RedirectResponse(url="/docs") - -@app.get('/tools', response_model=ResponseModels.ToolsResponseModel, tags=['ReVanced Tools']) -@limiter.limit(config['slowapi']['limit']) -@cache(config['cache']['expire']) -async def tools(request: Request, response: Response) -> dict: - """Get patching tools' latest version. - - Returns: - json: information about the patching tools' latest version - """ - return await releases.get_latest_releases(config['app']['repositories']) - -@app.get('/patches', response_model=ResponseModels.PatchesResponseModel, tags=['ReVanced Tools']) -@limiter.limit(config['slowapi']['limit']) -@cache(config['cache']['expire']) -async def patches(request: Request, response: Response) -> dict: - """Get latest patches. - - Returns: - json: list of latest patches - """ - - return await releases.get_patches_json() - -@app.get('/contributors', response_model=ResponseModels.ContributorsResponseModel, tags=['ReVanced Tools']) -@limiter.limit(config['slowapi']['limit']) -@cache(config['cache']['expire']) -async def contributors(request: Request, response: Response) -> dict: - """Get contributors. - - Returns: - json: list of contributors - """ - return await releases.get_contributors(config['app']['repositories']) - -@app.get('/changelogs/{org}/{repo}', response_model=ResponseModels.ChangelogsResponseModel, tags=['ReVanced Tools']) -@limiter.limit(config['slowapi']['limit']) -@cache(config['cache']['expire']) -async def changelogs(request: Request, response: Response, org: str, repo: str, path: str) -> dict: - """Get the latest changes from a repository. - - Returns: - json: list of commits - """ - return await releases.get_commits( - org=org, - repository=repo, - path=path - ) - -@app.post('/client', response_model=ClientModels.ClientModel, status_code=status.HTTP_201_CREATED, tags=['Clients']) -@limiter.limit(config['slowapi']['limit']) -async def create_client(request: Request, response: Response, admin: bool | None = False, Authorize: AuthPASETO = Depends()) -> ClientModels.ClientModel: - """Create a new API client. - - Returns: - json: client information - """ - - Authorize.paseto_required() - - admin_claim: dict[str, bool] = {"admin": False} - - current_user: str | int | None = Authorize.get_subject() - - if 'admin' in Authorize.get_token_payload(): - admin_claim = {"admin": Authorize.get_token_payload()['admin']} - - if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and - admin_claim['admin'] == True): - - client: ClientModels.ClientModel = await clients.generate(admin=admin) - await clients.store(client) - - return client - else: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - - -@app.delete('/client/{client_id}', response_model=ResponseModels.ClientDeletedResponse, status_code=status.HTTP_200_OK, tags=['Clients']) -@limiter.limit(config['slowapi']['limit']) -async def delete_client(request: Request, response: Response, client_id: str, Authorize: AuthPASETO = Depends()) -> dict: - """Delete an API client. - - Returns: - json: deletion status - """ - - Authorize.paseto_required() - - admin_claim: dict[str, bool] = {"admin": False} - - current_user: str | int | None = Authorize.get_subject() - - if 'admin' in Authorize.get_token_payload(): - admin_claim = {"admin": Authorize.get_token_payload()['admin']} - - if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and - ( admin_claim['admin'] == True or - current_user == client_id ) ): - - if await clients.exists(client_id): - return {"id": client_id, "deleted": await clients.delete(client_id)} - else: - raise HTTPException(status_code=404, detail={ - "error": GeneralErrors.ClientNotFound().error, - "message": GeneralErrors.ClientNotFound().message - } - ) - else: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - -@app.patch('/client/{client_id}/secret', response_model=ResponseModels.ClientSecretUpdatedResponse, status_code=status.HTTP_200_OK, tags=['Clients']) -@limiter.limit(config['slowapi']['limit']) -async def update_client(request: Request, response: Response, client_id: str, Authorize: AuthPASETO = Depends()) -> dict: - """Update an API client's secret. - - Returns: - json: client ID and secret - """ - - Authorize.paseto_required() - - admin_claim: dict[str, bool] = {"admin": False} - - current_user: str | int | None = Authorize.get_subject() - - if 'admin' in Authorize.get_token_payload(): - admin_claim = {"admin": Authorize.get_token_payload()['admin']} - - if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and - ( admin_claim['admin'] == True or - current_user == client_id ) ): - - if await clients.exists(client_id): - new_secret: str = await generators.generate_secret() - - if await clients.update_secret(client_id, new_secret): - return {"id": client_id, "secret": new_secret} - else: - raise HTTPException(status_code=500, detail={ - "error": GeneralErrors.InternalServerError().error, - "message": GeneralErrors.InternalServerError().message - } - ) - else: - raise HTTPException(status_code=404, detail={ - "error": GeneralErrors.ClientNotFound().error, - "message": GeneralErrors.ClientNotFound().message - } - ) - else: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - -@app.patch('/client/{client_id}/status', response_model=ResponseModels.ClientStatusResponse, status_code=status.HTTP_200_OK, tags=['Clients']) -async def client_status(request: Request, response: Response, client_id: str, active: bool, Authorize: AuthPASETO = Depends()) -> dict: - """Activate or deactivate a client - - Returns: - json: json response containing client ID and activation status - """ - - Authorize.paseto_required() - - admin_claim: dict[str, bool] = {"admin": False} - - current_user: str | int | None = Authorize.get_subject() - - if 'admin' in Authorize.get_token_payload(): - admin_claim = {"admin": Authorize.get_token_payload()['admin']} - - if ( await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()) and - ( admin_claim['admin'] == True or - current_user == client_id ) ): - - if await clients.exists(client_id): - if await clients.status(client_id, active): - return {"id": client_id, "active": active} - else: - raise HTTPException(status_code=500, detail={ - "error": GeneralErrors.InternalServerError().error, - "message": GeneralErrors.InternalServerError().message - } - ) - else: - raise HTTPException(status_code=404, detail={ - "error": GeneralErrors.ClientNotFound().error, - "message": GeneralErrors.ClientNotFound().message - } - ) - else: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - - -@app.post('/announcement', response_model=AnnouncementModels.AnnouncementCreatedResponse, - status_code=status.HTTP_201_CREATED, tags=['Announcements']) -@limiter.limit(config['slowapi']['limit']) -async def create_announcement(request: Request, response: Response, - announcement: AnnouncementModels.AnnouncementCreateModel, - Authorize: AuthPASETO = Depends()) -> dict: - """Create a new announcement. - - Returns: - json: announcement information - """ - Authorize.paseto_required() - - if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()): - announcement_created: bool = await announcements.store(announcement=announcement, - author=Authorize.get_subject()) - - if announcement_created: - return {"created": announcement_created} - else: - raise HTTPException(status_code=500, detail={ - "error": GeneralErrors.InternalServerError().error, - "message": GeneralErrors.InternalServerError().message - } - ) - else: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - -@app.get('/announcement', response_model=AnnouncementModels.AnnouncementModel, tags=['Announcements']) -@limiter.limit(config['slowapi']['limit']) -async def get_announcement(request: Request, response: Response) -> dict: - """Get an announcement. - - Returns: - json: announcement information - """ - if await announcements.exists(): - return await announcements.get() - else: - raise HTTPException(status_code=404, detail={ - "error": GeneralErrors.AnnouncementNotFound().error, - "message": GeneralErrors.AnnouncementNotFound().message - } - ) - -@app.delete('/announcement', - response_model=AnnouncementModels.AnnouncementDeleted, - status_code=status.HTTP_200_OK, tags=['Announcements']) -@limiter.limit(config['slowapi']['limit']) -async def delete_announcement(request: Request, response: Response, - Authorize: AuthPASETO = Depends()) -> dict: - """Delete an announcement. - - Returns: - json: deletion status - """ - - Authorize.paseto_required() - - if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()): - if await announcements.exists(): - return {"deleted": await announcements.delete()} - else: - raise HTTPException(status_code=404, detail={ - "error": GeneralErrors.AnnouncementNotFound().error, - "message": GeneralErrors.AnnouncementNotFound().message - } - ) - else: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - -@app.post('/auth', response_model=ResponseModels.ClientAuthTokenResponse, status_code=status.HTTP_200_OK, tags=['Authentication']) -@limiter.limit(config['slowapi']['limit']) -async def auth(request: Request, response: Response, client: ClientModels.ClientAuthModel, Authorize: AuthPASETO = Depends()) -> dict: - """Authenticate a client and get an auth token. - - Returns: - access_token: auth token - refresh_token: refresh token - """ - - admin_claim: dict[str, bool] - - if await clients.exists(client.id): - authenticated: bool = await clients.authenticate(client.id, client.secret) - - if not authenticated: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - else: - if await clients.is_admin(client.id): - admin_claim = {"admin": True} - else: - admin_claim = {"admin": False} - - access_token = Authorize.create_access_token(subject=client.id, - user_claims=admin_claim, - fresh=True) - - refresh_token = Authorize.create_refresh_token(subject=client.id, - user_claims=admin_claim) - - return {"access_token": access_token, "refresh_token": refresh_token} - else: - raise HTTPException(status_code=401, detail={ - "error": GeneralErrors.Unauthorized().error, - "message": GeneralErrors.Unauthorized().message - } - ) - -@app.post('/auth/refresh', response_model=ResponseModels.ClientTokenRefreshResponse, - status_code=status.HTTP_200_OK, tags=['Authentication']) -@limiter.limit(config['slowapi']['limit']) -async def refresh(request: Request, response: Response, - Authorize: AuthPASETO = Depends()) -> dict: - """Refresh an auth token. - - Returns: - access_token: auth token - """ - - Authorize.paseto_required(refresh_token=True) - - admin_claim: dict[str, bool] = {"admin": False} - - current_user: str | int | None = Authorize.get_subject() - - if 'admin' in Authorize.get_token_payload(): - admin_claim = {"admin": Authorize.get_token_payload()['admin']} - - return {"access_token": Authorize.create_access_token(subject=current_user, - user_claims=admin_claim, - fresh=False)} - -@app.on_event("startup") -async def startup() -> None: - FastAPICache.init(RedisBackend(RedisConnector.connect(config['cache']['database'])), - prefix="fastapi-cache") - - return None - -# setup right before running to make sure no other library overwrites it - -Logger.setup_logging(LOG_LEVEL=config["logging"]["level"], - JSON_LOGS=config["logging"]["json_logs"]) \ No newline at end of file diff --git a/mypy.ini b/mypy.ini index 35dd8a9..baf2713 100644 --- a/mypy.ini +++ b/mypy.ini @@ -67,4 +67,8 @@ ignore_missing_imports = True [mypy-aiofiles.*] # No stubs available +ignore_missing_imports = True + +[mypy-gunicorn.*] +# No stubs available ignore_missing_imports = True \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 7527fcb..b9ac540 100644 --- a/poetry.lock +++ b/poetry.lock @@ -232,6 +232,20 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "gunicorn" +version = "20.1.0" +description = "WSGI HTTP Server for UNIX" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + [[package]] name = "h11" version = "0.12.0" @@ -309,7 +323,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"] [[package]] name = "httpx-cache" -version = "0.6.0" +version = "0.6.1" description = "Simple caching transport for httpx." category = "main" optional = false @@ -797,7 +811,7 @@ h11 = ">=0.9.0,<1" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "b35d9d99689d712256be20c9124a3786a777bcbb9854a57c76ee875a6a812931" +content-hash = "424ef1ced5a48b1e652aefa77c0629df286a1f66d6d9286584b46dbac57e80ea" [metadata.files] aiofiles = [ @@ -1055,6 +1069,10 @@ fasteners = [ {file = "fasteners-0.17.3-py3-none-any.whl", hash = "sha256:cae0772df265923e71435cc5057840138f4e8b6302f888a567d06ed8e1cbca03"}, {file = "fasteners-0.17.3.tar.gz", hash = "sha256:a9a42a208573d4074c77d041447336cf4e3c1389a256fd3e113ef59cf29b7980"}, ] +gunicorn = [ + {file = "gunicorn-20.1.0-py3-none-any.whl", hash = "sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e"}, + {file = "gunicorn-20.1.0.tar.gz", hash = "sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8"}, +] h11 = [ {file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"}, {file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"}, @@ -1119,8 +1137,8 @@ httpx = [ {file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"}, ] httpx-cache = [ - {file = "httpx-cache-0.6.0.tar.gz", hash = "sha256:3d136ad207d8004e59a69283aa6fc40e1190dad5edbde5859b37778f6d1ecdbf"}, - {file = "httpx_cache-0.6.0-py3-none-any.whl", hash = "sha256:2b548d68fa55159e2fdc49ea151f513217c21cb4f0057e79fec8376ce15bfe7a"}, + {file = "httpx-cache-0.6.1.tar.gz", hash = "sha256:699f648e781f6d06c9f50fa398f7ae30fe5b7668711975760d277f14c601d124"}, + {file = "httpx_cache-0.6.1-py3-none-any.whl", hash = "sha256:b468b9f3d8d8063d3a0f401401cfd5237e37721f975c436598e4e62fd1f5685c"}, ] hypercorn = [ {file = "Hypercorn-0.14.3-py3-none-any.whl", hash = "sha256:7c491d5184f28ee960dcdc14ab45d14633ca79d72ddd13cf4fcb4cb854d679ab"}, diff --git a/pyproject.toml b/pyproject.toml index 1ef9d4e..475645c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ fastapi-paseto-auth = "^0.6.0" ujson = ">=5.5.0" hiredis = ">=2.0.0" aiofiles = ">=22.1.0" +uvicorn = ">=0.18.3" +gunicorn = ">=20.1.0" [tool.poetry.dev-dependencies] mypy = ">=0.971" diff --git a/requirements.txt b/requirements.txt index d16620f..2620bb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,12 +16,13 @@ fastapi-cache2==0.1.9; python_version >= "3.7" and python_version < "4.0" fastapi-paseto-auth==0.6.0; python_version >= "3.10" fastapi==0.85.0; python_version >= "3.7" fasteners==0.17.3; python_version >= "3.7" and python_version < "4.0" +gunicorn==20.1.0; python_version >= "3.5" h11==0.12.0; python_version >= "3.7" and python_version < "4.0" and python_full_version >= "3.7.0" h2==4.1.0; python_version >= "3.7" and python_full_version >= "3.6.1" and python_version < "4.0" hiredis==2.0.0; python_version >= "3.6" hpack==4.0.0; python_version >= "3.7" and python_full_version >= "3.6.1" httpcore==0.15.0; python_version >= "3.7" and python_version < "4.0" -httpx-cache==0.6.0; python_version >= "3.7" and python_version < "4.0" +httpx-cache==0.6.1; python_version >= "3.7" and python_version < "4.0" httpx==0.23.0; python_version >= "3.7" hypercorn==0.14.3; python_version >= "3.7" hyperframe==6.0.1; python_version >= "3.7" and python_full_version >= "3.6.1" @@ -54,7 +55,7 @@ toolz==0.12.0; python_version >= "3.5" typing-extensions==4.4.0; python_version >= "3.10" ujson==5.5.0; python_version >= "3.7" urllib3==1.26.12; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.6.0" and python_version < "4" and python_version >= "3.6" -uvicorn==0.18.3; python_version >= "3.7" and python_version < "4.0" +uvicorn==0.18.3; python_version >= "3.7" uvloop==0.17.0; platform_system != "Windows" and python_version >= "3.7" win32-setctime==1.1.0; sys_platform == "win32" and python_version >= "3.5" wrapt==1.14.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" diff --git a/run.py b/run.py new file mode 100755 index 0000000..de2aca0 --- /dev/null +++ b/run.py @@ -0,0 +1,148 @@ +import os +import sys +import toml +import logging +import sentry_sdk +from app.main import app +from loguru import logger +from fastapi import FastAPI +from types import FrameType +from typing import Any, Optional +from multiprocessing import cpu_count +from gunicorn.glogging import Logger +from gunicorn.app.base import BaseApplication +from sentry_sdk.integrations.redis import RedisIntegration +from sentry_sdk.integrations.httpx import HttpxIntegration +from sentry_sdk.integrations.gnu_backtrace import GnuBacktraceIntegration + +config: dict = toml.load("config.toml") + +# Enable sentry logging + +sentry_sdk.init(os.environ['SENTRY_DSN'], traces_sample_rate=1.0, integrations=[ + RedisIntegration(), + HttpxIntegration(), + GnuBacktraceIntegration(), + ],) + +LOG_LEVEL: Any = logging.getLevelName(config['logging']['level']) +JSON_LOGS: bool = config['logging']['json_logs'] +WORKERS: int = int(cpu_count() + 1) +BIND: str = f'{os.environ.get("HYPERCORN_HOST")}:{os.environ.get("HYPERCORN_PORT")}' + +class InterceptHandler(logging.Handler): + """Intercept logs and forward them to Loguru. + + Args: + logging.Handler (Filterer): Handler to filter logs + """ + def emit(self, record: logging.LogRecord) -> None: + """Emit a log record.""" + + # Get corresponding Loguru level if it exists + level: str | int + frame: FrameType + depth: int + + try: + level = logger.level(record.levelname).name + except ValueError: + level = record.levelno + + # Find caller from where originated the logged message + frame, depth = logging.currentframe(), 2 + while frame.f_code.co_filename == logging.__file__: + frame = frame.f_back + depth += 1 + + logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +class StubbedGunicornLogger(Logger): + """Defining a custom logger class to prevent gunicorn from logging to stdout + + Args: + Logger (object): Gunicon logger class + """ + def setup(self, cfg) -> None: + """Setup logger.""" + + handler: logging.NullHandler = logging.NullHandler() + self.error_logger: Logger = logging.getLogger("gunicorn.error") + + self.error_logger.addHandler(handler) + + self.access_logger: Logger = logging.getLogger("gunicorn.access") + + self.access_logger.addHandler(handler) + self.error_logger.setLevel(LOG_LEVEL) + self.access_logger.setLevel(LOG_LEVEL) + + +class StandaloneApplication(BaseApplication): + """Defines a Guicorn application + + Args: + BaseApplication (object): Base class for Gunicorn applications + """ + + def __init__(self, app: FastAPI, options: dict | None = None): + """Initialize the application + + Args: + app (fastapi.FastAPI): FastAPI application + options (dict, optional): Gunicorn options. Defaults to None. + """ + self.options: dict = options or {} + self.application: FastAPI = app + super().__init__() + + def load_config(self) -> None: + """Load Gunicorn configuration.""" + config: dict = { + key: value for key, value in self.options.items() + if key in self.cfg.settings and value is not None + } + for key, value in config.items(): + self.cfg.set(key.lower(), value) + + def load(self) -> FastAPI: + """Load the application + + Returns: + FastAPI: FastAPI application + """ + return self.application + + +if __name__ == '__main__': + intercept_handler = InterceptHandler() + logging.root.setLevel(LOG_LEVEL) + + seen: set = set() + for name in [ + *logging.root.manager.loggerDict.keys(), + "gunicorn", + "gunicorn.access", + "gunicorn.error", + "uvicorn", + "uvicorn.access", + "uvicorn.error", + ]: + if name not in seen: + seen.add(name.split(".")[0]) + logging.getLogger(name).handlers = [intercept_handler] + + logger.configure(handlers=[{"sink": sys.stdout, "serialize": JSON_LOGS}]) + + options: dict = { + "bind": BIND, + "workers": WORKERS, + "accesslog": "-", + "errorlog": "-", + "worker_class": "uvicorn.workers.UvicornWorker", + "logger_class": StubbedGunicornLogger, + "preload": True, + } + + StandaloneApplication(app, options).run() diff --git a/run.sh b/run.sh deleted file mode 100755 index f86523f..0000000 --- a/run.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -# This script is used to run the application -# It is used by the Dockerfile - -# get number of cores -CORES=$(grep -c ^processor /proc/cpuinfo) - -# Start the application -hypercorn main:app --bind="${HYPERCORN_HOST}:${HYPERCORN_PORT}" \ ---workers="$CORES" --log-level="$HYPERCORN_LOG_LEVEL" \ ---worker-class uvloop \ No newline at end of file diff --git a/src/utils/InternalCache.py b/src/utils/InternalCache.py deleted file mode 100644 index fa3be43..0000000 --- a/src/utils/InternalCache.py +++ /dev/null @@ -1,82 +0,0 @@ -import os -import toml -from typing import Any -from redis import asyncio as aioredis - -import src.utils.Logger as Logger -from src.utils.RedisConnector import RedisConnector - -config: dict = toml.load("config.toml") - -class InternalCache: - """Implements an internal cache for ReVanced Releases API.""" - - redis = RedisConnector.connect(config['internal-cache']['database']) - - InternalCacheLogger = Logger.InternalCacheLogger() - - async def store(self, key: str, value: dict) -> None: - """Stores a key-value pair in the cache. - - Args: - key (str): the key to store - value (dict): the JSON value to store - """ - try: - await self.redis.json().set(key, '$', value) - await self.redis.expire(key, config['internal-cache']['expire']) - await self.InternalCacheLogger.log("SET", None, key) - except aioredis.RedisError as e: - await self.InternalCacheLogger.log("SET", e) - - async def delete(self, key: str) -> None: - """Removes a key-value pair from the cache. - - Args: - key (str): the key to delete - """ - try: - await self.redis.delete(key) - await self.InternalCacheLogger.log("DEL", None, key) - except aioredis.RedisError as e: - await self.InternalCacheLogger.log("DEL", e) - - async def get(self, key: str) -> dict: - """Gets a key-value pair from the cache. - - Args: - key (str): the key to retrieve - - Returns: - dict: the JSON value stored in the cache or an empty dict if key doesn't exist or an error occurred - """ - try: - payload: dict[Any, Any] = await self.redis.json().get(key) - await self.InternalCacheLogger.log("GET", None, key) - return payload - except aioredis.RedisError as e: - await self.InternalCacheLogger.log("GET", e) - return {} - - async def exists(self, key: str) -> bool: - """Checks if a key exists in the cache. - - Args: - key (str): key to check - - Returns: - bool: True if key exists, False if key doesn't exist or an error occurred - """ - try: - if await self.redis.exists(key): - await self.InternalCacheLogger.log("EXISTS", None, key) - return True - else: - await self.InternalCacheLogger.log("EXISTS", None, key) - return False - except aioredis.RedisError as e: - await self.InternalCacheLogger.log("EXISTS", e) - return False - - - \ No newline at end of file