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
This commit is contained in:
Alexandre Teles 2022-10-11 00:10:56 -03:00 committed by GitHub
parent 6133b4f776
commit 0ce5780a4e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 859 additions and 734 deletions

View File

@ -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" ]
CMD [ "python3", "./run.py" ]

View File

@ -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.

View File

@ -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")

View File

@ -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")

View File

@ -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.")

9
app/dependencies.py Normal file
View File

@ -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")

156
app/main.py Executable file
View File

@ -0,0 +1,156 @@
#!/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 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(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

View File

@ -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."""

View File

@ -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
}
)

78
app/routers/auth.py Normal file
View File

@ -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)}

25
app/routers/changelogs.py Normal file
View File

@ -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
)

172
app/routers/clients.py Normal file
View File

@ -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
}
)

View File

@ -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'])

22
app/routers/patches.py Normal file
View File

@ -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()

12
app/routers/ping.py Normal file
View File

@ -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

14
app/routers/root.py Normal file
View File

@ -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")

21
app/routers/tools.py Normal file
View File

@ -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'])

View File

@ -1,6 +1,6 @@
import os
import httpx_cache
import src.utils.Logger as Logger
import app.utils.Logger as Logger
class HTTPXClient:

View File

@ -1,52 +1,9 @@
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."""

0
app/utils/__init__.py Normal file
View File

View File

@ -24,28 +24,23 @@ The team also have a [Discord Server](https://revanced.app/discord) if you need
Godspeed 💀
"""
version = "0.8 RC"
version = "0.8.5 RC"
[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]

501
main.py
View File

@ -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"])

View File

@ -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

26
poetry.lock generated
View File

@ -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"},

View File

@ -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"

View File

@ -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"

148
run.py Executable file
View File

@ -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()

12
run.sh
View File

@ -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

View File

@ -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