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 <johan.melkonyan1@web.de>
This commit is contained in:
Alexandre Teles 2022-10-13 01:48:07 -03:00 committed by GitHub
parent ca49a3b31a
commit 3b418197c2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1208 additions and 763 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")

111
app/controllers/Mirrors.py Normal file
View File

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

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

158
app/main.py Executable file
View File

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

View File

@ -48,4 +48,24 @@ class Unauthorized(BaseModel):
"""
error: str = "Unauthorized"
message: str = "The client is unauthorized to access this resource"
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."

View File

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

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

139
app/routers/mirrors.py Normal file
View File

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

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

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

View File

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

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