mirror of
https://github.com/revanced/revanced-releases-api.git
synced 2025-05-02 07:04:28 +02:00
* 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:
parent
6133b4f776
commit
0ce5780a4e
@ -20,4 +20,4 @@ RUN apt update && \
|
|||||||
apt-get install build-essential libffi-dev -y \
|
apt-get install build-essential libffi-dev -y \
|
||||||
&& pip install --no-cache-dir -r requirements.txt
|
&& pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
CMD [ "/bin/bash", "./run.sh" ]
|
CMD [ "python3", "./run.py" ]
|
@ -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. |
|
| `REDIS_PORT` | The port of your redis server. |
|
||||||
| `HYPERCORN_HOST` | The hostname/IP of the API. |
|
| `HYPERCORN_HOST` | The hostname/IP of the API. |
|
||||||
| `HYPERCORN_PORT` | The port 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. |
|
| `SENTRY_DSN` | The DSN of your Sentry instance. |
|
||||||
|
|
||||||
Please note that there are no default values for any of these variables.
|
Please note that there are no default values for any of these variables.
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import toml
|
import toml
|
||||||
from redis import asyncio as aioredis
|
from redis import asyncio as aioredis
|
||||||
|
|
||||||
import src.utils.Logger as Logger
|
import app.utils.Logger as Logger
|
||||||
from src.utils.Generators import Generators
|
from app.utils.Generators import Generators
|
||||||
from src.models.AnnouncementModels import AnnouncementCreateModel
|
from app.models.AnnouncementModels import AnnouncementCreateModel
|
||||||
from src.utils.RedisConnector import RedisConnector
|
from app.utils.RedisConnector import RedisConnector
|
||||||
|
|
||||||
config: dict = toml.load("config.toml")
|
config: dict = toml.load("config.toml")
|
||||||
|
|
@ -7,10 +7,10 @@ from redis import asyncio as aioredis
|
|||||||
import aiofiles
|
import aiofiles
|
||||||
import uvloop
|
import uvloop
|
||||||
|
|
||||||
import src.utils.Logger as Logger
|
import app.utils.Logger as Logger
|
||||||
from src.utils.Generators import Generators
|
from app.utils.Generators import Generators
|
||||||
from src.models.ClientModels import ClientModel
|
from app.models.ClientModels import ClientModel
|
||||||
from src.utils.RedisConnector import RedisConnector
|
from app.utils.RedisConnector import RedisConnector
|
||||||
|
|
||||||
config: dict = toml.load("config.toml")
|
config: dict = toml.load("config.toml")
|
||||||
|
|
@ -3,8 +3,7 @@ import asyncio
|
|||||||
import uvloop
|
import uvloop
|
||||||
import orjson
|
import orjson
|
||||||
from base64 import b64decode
|
from base64 import b64decode
|
||||||
from src.utils.HTTPXClient import HTTPXClient
|
from app.utils.HTTPXClient import HTTPXClient
|
||||||
from src.utils.InternalCache import InternalCache
|
|
||||||
|
|
||||||
|
|
||||||
class Releases:
|
class Releases:
|
||||||
@ -15,8 +14,6 @@ class Releases:
|
|||||||
|
|
||||||
httpx_client = HTTPXClient.create()
|
httpx_client = HTTPXClient.create()
|
||||||
|
|
||||||
InternalCache = InternalCache()
|
|
||||||
|
|
||||||
async def __get_release(self, repository: str) -> list:
|
async def __get_release(self, repository: str) -> list:
|
||||||
# Get assets from latest release in a given repository.
|
# Get assets from latest release in a given repository.
|
||||||
#
|
#
|
||||||
@ -65,21 +62,14 @@ class Releases:
|
|||||||
dict: A dictionary containing assets from each repository
|
dict: A dictionary containing assets from each repository
|
||||||
"""
|
"""
|
||||||
|
|
||||||
releases: dict[str, list]
|
releases: dict[str, list] = {}
|
||||||
|
releases['tools'] = []
|
||||||
|
|
||||||
if await self.InternalCache.exists('releases'):
|
results: list = await asyncio.gather(*[self.__get_release(repository) for repository in repositories])
|
||||||
releases = await self.InternalCache.get('releases')
|
|
||||||
else:
|
for result in results:
|
||||||
releases = {}
|
for asset in result:
|
||||||
releases['tools'] = []
|
releases['tools'].append(asset)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
return releases
|
return releases
|
||||||
|
|
||||||
@ -101,11 +91,7 @@ class Releases:
|
|||||||
Returns:
|
Returns:
|
||||||
dict: Patches available for a given app
|
dict: Patches available for a given app
|
||||||
"""
|
"""
|
||||||
if await self.InternalCache.exists('patches'):
|
patches: dict = await self.__get_patches_json()
|
||||||
patches = await self.InternalCache.get('patches')
|
|
||||||
else:
|
|
||||||
patches = await self.__get_patches_json()
|
|
||||||
await self.InternalCache.store('patches', patches)
|
|
||||||
|
|
||||||
return patches
|
return patches
|
||||||
|
|
||||||
@ -139,22 +125,17 @@ class Releases:
|
|||||||
|
|
||||||
contributors: dict[str, list]
|
contributors: dict[str, list]
|
||||||
|
|
||||||
if await self.InternalCache.exists('contributors'):
|
contributors = {}
|
||||||
contributors = await self.InternalCache.get('contributors')
|
contributors['repositories'] = []
|
||||||
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)
|
|
||||||
|
|
||||||
|
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
|
return contributors
|
||||||
|
|
||||||
async def get_commits(self, org: str, repository: str, path: str) -> dict:
|
async def get_commits(self, org: str, repository: str, path: str) -> dict:
|
||||||
@ -180,36 +161,29 @@ class Releases:
|
|||||||
payload["commits"] = []
|
payload["commits"] = []
|
||||||
|
|
||||||
if org == 'revanced' or org == 'vancedapp':
|
if org == 'revanced' or org == 'vancedapp':
|
||||||
key: str = f"{org}/{repository}/{path}"
|
_releases = await self.httpx_client.get(
|
||||||
if await self.InternalCache.exists(key):
|
f"https://api.github.com/repos/{org}/{repository}/releases?per_page=2"
|
||||||
return await self.InternalCache.get(key)
|
)
|
||||||
else:
|
|
||||||
|
releases = _releases.json()
|
||||||
_releases = await self.httpx_client.get(
|
|
||||||
f"https://api.github.com/repos/{org}/{repository}/releases?per_page=2"
|
since = releases[1]['created_at']
|
||||||
)
|
until = releases[0]['created_at']
|
||||||
|
|
||||||
releases = _releases.json()
|
_response = await self.httpx_client.get(
|
||||||
|
f"https://api.github.com/repos/{org}/{repository}/commits?path={path}&since={since}&until={until}"
|
||||||
since = releases[1]['created_at']
|
)
|
||||||
until = releases[0]['created_at']
|
|
||||||
|
response = _response.json()
|
||||||
_response = await self.httpx_client.get(
|
|
||||||
f"https://api.github.com/repos/{org}/{repository}/commits?path={path}&since={since}&until={until}"
|
for commit in response:
|
||||||
)
|
data: dict[str, str] = {}
|
||||||
|
data["sha"] = commit["sha"]
|
||||||
response = _response.json()
|
data["author"] = commit["commit"]["author"]["name"]
|
||||||
|
data["message"] = commit["commit"]["message"]
|
||||||
for commit in response:
|
data["html_url"] = commit["html_url"]
|
||||||
data: dict[str, str] = {}
|
payload['commits'].append(data)
|
||||||
data["sha"] = commit["sha"]
|
|
||||||
data["author"] = commit["commit"]["author"]["name"]
|
return payload
|
||||||
data["message"] = commit["commit"]["message"]
|
|
||||||
data["html_url"] = commit["html_url"]
|
|
||||||
payload['commits'].append(data)
|
|
||||||
|
|
||||||
await self.InternalCache.store(key, payload)
|
|
||||||
|
|
||||||
return payload
|
|
||||||
else:
|
else:
|
||||||
raise Exception("Invalid organization.")
|
raise Exception("Invalid organization.")
|
9
app/dependencies.py
Normal file
9
app/dependencies.py
Normal 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
156
app/main.py
Executable 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
|
@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel
|
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."""
|
"""Implements pydantic models and model generator for the API's responses."""
|
||||||
|
|
92
app/routers/announcement.py
Normal file
92
app/routers/announcement.py
Normal 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
78
app/routers/auth.py
Normal 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
25
app/routers/changelogs.py
Normal 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
172
app/routers/clients.py
Normal 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
|
||||||
|
}
|
||||||
|
)
|
21
app/routers/contributors.py
Normal file
21
app/routers/contributors.py
Normal 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
22
app/routers/patches.py
Normal 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
12
app/routers/ping.py
Normal 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
14
app/routers/root.py
Normal 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
21
app/routers/tools.py
Normal 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'])
|
@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
import httpx_cache
|
import httpx_cache
|
||||||
import src.utils.Logger as Logger
|
import app.utils.Logger as Logger
|
||||||
|
|
||||||
class HTTPXClient:
|
class HTTPXClient:
|
||||||
|
|
@ -1,52 +1,9 @@
|
|||||||
import sys
|
import sys
|
||||||
import logging
|
import logging
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from typing import Optional
|
|
||||||
from types import FrameType
|
|
||||||
from redis import RedisError
|
from redis import RedisError
|
||||||
from argon2.exceptions import VerifyMismatchError
|
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():
|
class HTTPXLogger():
|
||||||
"""Logger adapter for HTTPX."""
|
"""Logger adapter for HTTPX."""
|
||||||
|
|
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
13
config.toml
13
config.toml
@ -24,28 +24,23 @@ The team also have a [Discord Server](https://revanced.app/discord) if you need
|
|||||||
Godspeed 💀
|
Godspeed 💀
|
||||||
|
|
||||||
"""
|
"""
|
||||||
version = "0.8 RC"
|
version = "0.8.5 RC"
|
||||||
|
|
||||||
[license]
|
[license]
|
||||||
|
|
||||||
name = "AGPL-3.0"
|
name = "AGPL-3.0"
|
||||||
url = "https://www.gnu.org/licenses/agpl-3.0.en.html"
|
url = "https://www.gnu.org/licenses/agpl-3.0.en.html"
|
||||||
|
|
||||||
[slowapi]
|
|
||||||
|
|
||||||
limit = "60/minute"
|
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
|
|
||||||
level = "INFO"
|
level = "INFO"
|
||||||
json_logs = false
|
json_logs = false
|
||||||
|
|
||||||
[cache]
|
[cache]
|
||||||
expire = 120
|
expire = 300
|
||||||
database = 0
|
database = 0
|
||||||
|
|
||||||
[internal-cache]
|
[slowapi]
|
||||||
expire = 300
|
limit = "60/minute"
|
||||||
database = 1
|
database = 1
|
||||||
|
|
||||||
[clients]
|
[clients]
|
||||||
|
501
main.py
501
main.py
@ -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"])
|
|
4
mypy.ini
4
mypy.ini
@ -67,4 +67,8 @@ ignore_missing_imports = True
|
|||||||
|
|
||||||
[mypy-aiofiles.*]
|
[mypy-aiofiles.*]
|
||||||
# No stubs available
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-gunicorn.*]
|
||||||
|
# No stubs available
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
26
poetry.lock
generated
26
poetry.lock
generated
@ -232,6 +232,20 @@ category = "main"
|
|||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.12.0"
|
version = "0.12.0"
|
||||||
@ -309,7 +323,7 @@ socks = ["socksio (>=1.0.0,<2.0.0)"]
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx-cache"
|
name = "httpx-cache"
|
||||||
version = "0.6.0"
|
version = "0.6.1"
|
||||||
description = "Simple caching transport for httpx."
|
description = "Simple caching transport for httpx."
|
||||||
category = "main"
|
category = "main"
|
||||||
optional = false
|
optional = false
|
||||||
@ -797,7 +811,7 @@ h11 = ">=0.9.0,<1"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "1.1"
|
lock-version = "1.1"
|
||||||
python-versions = "^3.10"
|
python-versions = "^3.10"
|
||||||
content-hash = "b35d9d99689d712256be20c9124a3786a777bcbb9854a57c76ee875a6a812931"
|
content-hash = "424ef1ced5a48b1e652aefa77c0629df286a1f66d6d9286584b46dbac57e80ea"
|
||||||
|
|
||||||
[metadata.files]
|
[metadata.files]
|
||||||
aiofiles = [
|
aiofiles = [
|
||||||
@ -1055,6 +1069,10 @@ fasteners = [
|
|||||||
{file = "fasteners-0.17.3-py3-none-any.whl", hash = "sha256:cae0772df265923e71435cc5057840138f4e8b6302f888a567d06ed8e1cbca03"},
|
{file = "fasteners-0.17.3-py3-none-any.whl", hash = "sha256:cae0772df265923e71435cc5057840138f4e8b6302f888a567d06ed8e1cbca03"},
|
||||||
{file = "fasteners-0.17.3.tar.gz", hash = "sha256:a9a42a208573d4074c77d041447336cf4e3c1389a256fd3e113ef59cf29b7980"},
|
{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 = [
|
h11 = [
|
||||||
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
|
{file = "h11-0.12.0-py3-none-any.whl", hash = "sha256:36a3cb8c0a032f56e2da7084577878a035d3b61d104230d4bd49c0c6b555a9c6"},
|
||||||
{file = "h11-0.12.0.tar.gz", hash = "sha256:47222cb6067e4a307d535814917cd98fd0a57b6788ce715755fa2b6c28b56042"},
|
{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"},
|
{file = "httpx-0.23.0.tar.gz", hash = "sha256:f28eac771ec9eb4866d3fb4ab65abd42d38c424739e80c08d8d20570de60b0ef"},
|
||||||
]
|
]
|
||||||
httpx-cache = [
|
httpx-cache = [
|
||||||
{file = "httpx-cache-0.6.0.tar.gz", hash = "sha256:3d136ad207d8004e59a69283aa6fc40e1190dad5edbde5859b37778f6d1ecdbf"},
|
{file = "httpx-cache-0.6.1.tar.gz", hash = "sha256:699f648e781f6d06c9f50fa398f7ae30fe5b7668711975760d277f14c601d124"},
|
||||||
{file = "httpx_cache-0.6.0-py3-none-any.whl", hash = "sha256:2b548d68fa55159e2fdc49ea151f513217c21cb4f0057e79fec8376ce15bfe7a"},
|
{file = "httpx_cache-0.6.1-py3-none-any.whl", hash = "sha256:b468b9f3d8d8063d3a0f401401cfd5237e37721f975c436598e4e62fd1f5685c"},
|
||||||
]
|
]
|
||||||
hypercorn = [
|
hypercorn = [
|
||||||
{file = "Hypercorn-0.14.3-py3-none-any.whl", hash = "sha256:7c491d5184f28ee960dcdc14ab45d14633ca79d72ddd13cf4fcb4cb854d679ab"},
|
{file = "Hypercorn-0.14.3-py3-none-any.whl", hash = "sha256:7c491d5184f28ee960dcdc14ab45d14633ca79d72ddd13cf4fcb4cb854d679ab"},
|
||||||
|
@ -24,6 +24,8 @@ fastapi-paseto-auth = "^0.6.0"
|
|||||||
ujson = ">=5.5.0"
|
ujson = ">=5.5.0"
|
||||||
hiredis = ">=2.0.0"
|
hiredis = ">=2.0.0"
|
||||||
aiofiles = ">=22.1.0"
|
aiofiles = ">=22.1.0"
|
||||||
|
uvicorn = ">=0.18.3"
|
||||||
|
gunicorn = ">=20.1.0"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
mypy = ">=0.971"
|
mypy = ">=0.971"
|
||||||
|
@ -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-paseto-auth==0.6.0; python_version >= "3.10"
|
||||||
fastapi==0.85.0; python_version >= "3.7"
|
fastapi==0.85.0; python_version >= "3.7"
|
||||||
fasteners==0.17.3; python_version >= "3.7" and python_version < "4.0"
|
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"
|
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"
|
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"
|
hiredis==2.0.0; python_version >= "3.6"
|
||||||
hpack==4.0.0; python_version >= "3.7" and python_full_version >= "3.6.1"
|
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"
|
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"
|
httpx==0.23.0; python_version >= "3.7"
|
||||||
hypercorn==0.14.3; 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"
|
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"
|
typing-extensions==4.4.0; python_version >= "3.10"
|
||||||
ujson==5.5.0; python_version >= "3.7"
|
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"
|
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"
|
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"
|
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"
|
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
148
run.py
Executable 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
12
run.sh
@ -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
|
|
@ -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
|
|
||||||
|
|
||||||
|
|
||||||
|
|
Loading…
x
Reference in New Issue
Block a user