mirror of
https://github.com/revanced/revanced-polling-api.git
synced 2025-04-29 22:24:26 +02:00
INITIAL COMMIT
This commit is contained in:
parent
333b6b3758
commit
d4e43feae3
31
.gitignore
vendored
31
.gitignore
vendored
@ -20,7 +20,6 @@ parts/
|
|||||||
sdist/
|
sdist/
|
||||||
var/
|
var/
|
||||||
wheels/
|
wheels/
|
||||||
pip-wheel-metadata/
|
|
||||||
share/python-wheels/
|
share/python-wheels/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
.installed.cfg
|
.installed.cfg
|
||||||
@ -50,6 +49,7 @@ coverage.xml
|
|||||||
*.py,cover
|
*.py,cover
|
||||||
.hypothesis/
|
.hypothesis/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
@ -72,6 +72,7 @@ instance/
|
|||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
target/
|
target/
|
||||||
|
|
||||||
# Jupyter Notebook
|
# Jupyter Notebook
|
||||||
@ -82,7 +83,9 @@ profile_default/
|
|||||||
ipython_config.py
|
ipython_config.py
|
||||||
|
|
||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
# For a library or package, you might want to ignore these files since the code is
|
||||||
|
# intended to run in multiple environments; otherwise, check them in:
|
||||||
|
# .python-version
|
||||||
|
|
||||||
# pipenv
|
# pipenv
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||||
@ -91,6 +94,13 @@ ipython_config.py
|
|||||||
# install all needed dependencies.
|
# install all needed dependencies.
|
||||||
#Pipfile.lock
|
#Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||||
|
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||||
|
# commonly ignored for libraries.
|
||||||
|
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||||
|
#poetry.lock
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||||
__pypackages__/
|
__pypackages__/
|
||||||
|
|
||||||
@ -127,3 +137,20 @@ dmypy.json
|
|||||||
|
|
||||||
# Pyre type checker
|
# Pyre type checker
|
||||||
.pyre/
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# PyCharm
|
||||||
|
# JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
|
||||||
|
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||||
|
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||||
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
|
#.idea/
|
||||||
|
|
||||||
|
# PROJECT SPECIFIC
|
||||||
|
setup_env.sh
|
||||||
|
admin_info.json
|
||||||
|
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
13
app/controllers/Auth.py
Normal file
13
app/controllers/Auth.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
import os
|
||||||
|
import toml
|
||||||
|
from datetime import timedelta
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from fastapi_paseto_auth import AuthPASETO
|
||||||
|
|
||||||
|
config: dict = toml.load("config.toml")
|
||||||
|
|
||||||
|
class PasetoSettings(BaseModel):
|
||||||
|
authpaseto_secret_key: str = os.environ['SECRET_KEY']
|
||||||
|
authpaseto_access_token_expires: int | bool = config['auth']['access_token_expires']
|
||||||
|
authpaseto_denylist_enabled: bool = True
|
47
app/controllers/Clients.py
Normal file
47
app/controllers/Clients.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
from time import sleep
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
import uvloop
|
||||||
|
|
||||||
|
|
||||||
|
import app.utils.Logger as Logger
|
||||||
|
from app.dependencies import load_config
|
||||||
|
from app.utils.RedisConnector import RedisConnector
|
||||||
|
|
||||||
|
config: dict = load_config()
|
||||||
|
|
||||||
|
class Clients:
|
||||||
|
|
||||||
|
"""Implements a client for ReVanced Polling API."""
|
||||||
|
|
||||||
|
redis = RedisConnector.connect(config['tokens']['database'])
|
||||||
|
|
||||||
|
UserLogger = Logger.UserLogger()
|
||||||
|
|
||||||
|
async def ban_token(self, token: str) -> bool:
|
||||||
|
"""Ban a token
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token (str): Token to ban
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the token was banned successfully, False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
banned: bool = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if type(config['auth']['access_token_expires']) is bool:
|
||||||
|
await self.redis.set(name=token, value="", nx=True)
|
||||||
|
else:
|
||||||
|
await self.redis.set(name=token,
|
||||||
|
value="",
|
||||||
|
nx=True,
|
||||||
|
ex=config['auth']['access_token_expires'])
|
||||||
|
await self.UserLogger.log("BAN_TOKEN", None, token)
|
||||||
|
banned = True
|
||||||
|
except aioredis.RedisError as e:
|
||||||
|
await self.UserLogger.log("BAN_TOKEN", e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
return banned
|
||||||
|
|
0
app/controllers/__init__.py
Normal file
0
app/controllers/__init__.py
Normal file
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")
|
142
app/main.py
Normal file
142
app/main.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import os
|
||||||
|
import toml
|
||||||
|
import binascii
|
||||||
|
from redis import Redis
|
||||||
|
|
||||||
|
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.dependencies import load_config
|
||||||
|
|
||||||
|
from app.utils.RedisConnector import RedisConnector
|
||||||
|
|
||||||
|
import app.models.GeneralErrors as GeneralErrors
|
||||||
|
|
||||||
|
from app.routers import root
|
||||||
|
from app.routers import auth
|
||||||
|
|
||||||
|
"""Implements an API for our polling app"""
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
|
||||||
|
config: dict = load_config()
|
||||||
|
|
||||||
|
# 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(auth.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()
|
||||||
|
|
||||||
|
@AuthPASETO.token_in_denylist_loader
|
||||||
|
def check_if_token_in_denylist(decrypted_token):
|
||||||
|
redis = Redis(host=os.environ['REDIS_URL'],
|
||||||
|
port=os.environ['REDIS_PORT'],
|
||||||
|
db=config['tokens']['database'],
|
||||||
|
decode_responses=True)
|
||||||
|
|
||||||
|
return redis.exists(decrypted_token["jti"])
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
|
||||||
|
FastAPICache.init(RedisBackend(RedisConnector.connect(config['cache']['database'])),
|
||||||
|
prefix="fastapi-cache")
|
||||||
|
|
||||||
|
return None
|
12
app/models/BallotFields.py
Normal file
12
app/models/BallotFields.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from collections import deque
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class BallotFields(BaseModel):
|
||||||
|
"""Implements the fields for the ballots.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
cid: str
|
||||||
|
weight: int
|
12
app/models/BallotModel.py
Normal file
12
app/models/BallotModel.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from app.models.BallotFields import BallotFields
|
||||||
|
|
||||||
|
class BallotModel(BaseModel):
|
||||||
|
"""Implements the fields for the ballots.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
discord_id_hash: str
|
||||||
|
ballot: list[BallotFields]
|
23
app/models/ClientModels.py
Normal file
23
app/models/ClientModels.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class ClientModel(BaseModel):
|
||||||
|
"""Implements the fields for the clients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
secret: str
|
||||||
|
discord_id_hash: str
|
||||||
|
|
||||||
|
class ClientAuthModel(BaseModel):
|
||||||
|
"""Implements the fields for client authentication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
secret: str
|
||||||
|
discord_id_hash: str
|
71
app/models/GeneralErrors.py
Normal file
71
app/models/GeneralErrors.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class InternalServerError(BaseModel):
|
||||||
|
"""Implements the response fields for when an internal server error occurs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str = "Internal Server Error"
|
||||||
|
message: str = "An internal server error occurred. Please try again later."
|
||||||
|
|
||||||
|
class AnnouncementNotFound(BaseModel):
|
||||||
|
"""Implements the response fields for when an item is not found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str = "Not Found"
|
||||||
|
message: str = "No announcement was found."
|
||||||
|
|
||||||
|
class ClientNotFound(BaseModel):
|
||||||
|
"""Implements the response fields for when a client is not found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str = "Not Found"
|
||||||
|
message: str = "No client matches the given ID"
|
||||||
|
|
||||||
|
class IdNotProvided(BaseModel):
|
||||||
|
"""Implements the response fields for when the id is not provided.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str = "Bad Request"
|
||||||
|
message: str = "Missing client id"
|
||||||
|
|
||||||
|
class Unauthorized(BaseModel):
|
||||||
|
"""Implements the response fields for when the client is unauthorized.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str = "Unauthorized"
|
||||||
|
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."
|
77
app/models/ResponseFields.py
Normal file
77
app/models/ResponseFields.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
from typing import Any
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class ToolsResponseFields(BaseModel):
|
||||||
|
"""Implements the fields for the /tools endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
repository: str
|
||||||
|
version: str
|
||||||
|
timestamp: str
|
||||||
|
name: str
|
||||||
|
size: str | None = None
|
||||||
|
browser_download_url: str
|
||||||
|
content_type: str
|
||||||
|
class CompatiblePackagesResponseFields(BaseModel):
|
||||||
|
"""Implements the fields for compatible packages in the PatchesResponseFields class.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
versions: list[ str ] | None
|
||||||
|
|
||||||
|
class PatchesOptionsResponseFields(BaseModel):
|
||||||
|
key: str
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
required: bool
|
||||||
|
choices: list[ Any ] | None
|
||||||
|
|
||||||
|
class PatchesResponseFields(BaseModel):
|
||||||
|
"""Implements the fields for the /patches endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version: str
|
||||||
|
excluded: bool
|
||||||
|
deprecated: bool
|
||||||
|
dependencies: list[ str ] | None
|
||||||
|
options: list[ PatchesOptionsResponseFields ] | None
|
||||||
|
compatiblePackages: list[ CompatiblePackagesResponseFields ]
|
||||||
|
|
||||||
|
class ContributorFields(BaseModel):
|
||||||
|
"""Implements the fields for each contributor in the /contributors endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
login: str
|
||||||
|
avatar_url: str
|
||||||
|
html_url: str
|
||||||
|
|
||||||
|
class ContributorsResponseFields(BaseModel):
|
||||||
|
"""Implements the fields for each repository in the /contributors endpoint
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
contributors: list[ ContributorFields ]
|
||||||
|
|
||||||
|
class ChangelogsResponseFields(BaseModel):
|
||||||
|
"""Implements the fields for the /changelogs endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
sha: str
|
||||||
|
author: str
|
||||||
|
message: str
|
||||||
|
html_url: str
|
109
app/models/ResponseModels.py
Normal file
109
app/models/ResponseModels.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
import app.models.ResponseFields as ResponseFields
|
||||||
|
|
||||||
|
"""Implements pydantic models and model generator for the API's responses."""
|
||||||
|
|
||||||
|
class ToolsResponseModel(BaseModel):
|
||||||
|
"""Implements the JSON response model for the /tools endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
tools: list[ ResponseFields.ToolsResponseFields ]
|
||||||
|
|
||||||
|
class PatchesResponseModel(BaseModel):
|
||||||
|
"""Implements the JSON response model for the /patches endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
__root__: list[ ResponseFields.PatchesResponseFields ]
|
||||||
|
|
||||||
|
class ContributorsResponseModel(BaseModel):
|
||||||
|
"""Implements the JSON response model for the /contributors endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
repositories: list[ ResponseFields.ContributorsResponseFields ]
|
||||||
|
|
||||||
|
class PingResponseModel(BaseModel):
|
||||||
|
"""Implements the JSON response model for the /heartbeat endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: int
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
class ClientDeletedResponse(BaseModel):
|
||||||
|
"""Implements the response fields for deleted clients.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
deleted: bool
|
||||||
|
|
||||||
|
class ClientSecretUpdatedResponse(BaseModel):
|
||||||
|
"""Implements the response fields for updated client secrets.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
secret: str
|
||||||
|
|
||||||
|
class ClientAuthTokenResponse(BaseModel):
|
||||||
|
"""Implements the response fields for client auth tokens.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
|
||||||
|
class ClientTokenRefreshResponse(BaseModel):
|
||||||
|
"""Implements the response fields for client token refresh.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
access_token: str
|
||||||
|
|
||||||
|
class ClientStatusResponse(BaseModel):
|
||||||
|
"""Implements the response fields for client status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
active: bool
|
||||||
|
|
||||||
|
class ChangelogsResponseModel(BaseModel):
|
||||||
|
"""Implements the JSON response model for the /changelogs endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
repository: str
|
||||||
|
path: str
|
||||||
|
commits: list[ ResponseFields.ChangelogsResponseFields ]
|
||||||
|
|
||||||
|
class RevokedTokenResponse(BaseModel):
|
||||||
|
"""Implements the response fields for token invalidation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
revoked: bool
|
0
app/models/__init__.py
Normal file
0
app/models/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
66
app/routers/auth.py
Normal file
66
app/routers/auth.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import os
|
||||||
|
from fastapi_paseto_auth import AuthPASETO
|
||||||
|
from fastapi import APIRouter, Request, Response, Depends, status, HTTPException, Header
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
|
||||||
|
if client.id == os.environ['CLIENT_ID'] and client.secret == os.environ['CLIENT_SECRET']:
|
||||||
|
authenticated: bool = True
|
||||||
|
|
||||||
|
if not authenticated:
|
||||||
|
raise HTTPException(status_code=401, detail={
|
||||||
|
"error": GeneralErrors.Unauthorized().error,
|
||||||
|
"message": GeneralErrors.Unauthorized().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
user_claims: dict[str, str] = {}
|
||||||
|
user_claims['discord_id_hash'] = client.discord_id_hash
|
||||||
|
access_token = Authorize.create_access_token(subject=client.id,
|
||||||
|
user_claims=user_claims,
|
||||||
|
fresh=True)
|
||||||
|
|
||||||
|
return {"access_token": access_token}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=401, detail={
|
||||||
|
"error": GeneralErrors.Unauthorized().error,
|
||||||
|
"message": GeneralErrors.Unauthorized().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete("/revoke", response_model=ResponseModels.RevokedTokenResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def revoke_token(request: Request, response: Response, Authorize: AuthPASETO = Depends(), Authorization: str = Header(None)) -> dict:
|
||||||
|
"""Revoke a token.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
revoked: bool
|
||||||
|
|
||||||
|
"""
|
||||||
|
Authorize.paseto_required()
|
||||||
|
|
||||||
|
if await clients.ban_token(Authorize.get_jti()):
|
||||||
|
return {"revoked": True}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=500, detail={
|
||||||
|
"error": GeneralErrors.InternalServerError().error,
|
||||||
|
"message": GeneralErrors.InternalServerError().message
|
||||||
|
}
|
||||||
|
)
|
0
app/routers/ballot.py
Normal file
0
app/routers/ballot.py
Normal file
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")
|
30
app/utils/Generators.py
Normal file
30
app/utils/Generators.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
class Generators:
|
||||||
|
"""Generates UUIDs and secrets"""
|
||||||
|
|
||||||
|
async def generate_secret(self) -> str:
|
||||||
|
"""Generate a random secret
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A random secret
|
||||||
|
"""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
async def generate_id(self) -> str:
|
||||||
|
"""Generate a random UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: A random UUID (str instead of UUID object)
|
||||||
|
"""
|
||||||
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
async def generate_timestamp(self) -> int:
|
||||||
|
"""Generate a timestamp
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: A timestamp
|
||||||
|
"""
|
||||||
|
return int(time.time())
|
78
app/utils/Logger.py
Normal file
78
app/utils/Logger.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
from loguru import logger
|
||||||
|
from redis import RedisError
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
|
||||||
|
class HTTPXLogger():
|
||||||
|
"""Logger adapter for HTTPX."""
|
||||||
|
|
||||||
|
async def log_request(self, request) -> None:
|
||||||
|
"""Logs HTTPX requests
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.info(f"[HTTPX] Request: {request.method} {request.url} - Waiting for response")
|
||||||
|
|
||||||
|
async def log_response(self, response) -> None:
|
||||||
|
"""Logs HTTPX responses
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None
|
||||||
|
"""
|
||||||
|
request = response.request
|
||||||
|
|
||||||
|
logger.info(f"[HTTPX] Response: {request.method} {request.url} - Status: {response.status_code} {response.reason_phrase}")
|
||||||
|
|
||||||
|
class InternalCacheLogger:
|
||||||
|
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"[InternalCache] REDIS {operation} - Failed with error: {result}")
|
||||||
|
else:
|
||||||
|
logger.info(f"[InternalCache] REDIS {operation} {key} - OK")
|
||||||
|
|
||||||
|
class UserLogger:
|
||||||
|
async def log(self, operation: str, result: RedisError | VerifyMismatchError | 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")
|
||||||
|
|
||||||
|
class AnnouncementsLogger:
|
||||||
|
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"[ANNOUNCEMENT] REDIS {operation} - Failed with error: {result}")
|
||||||
|
else:
|
||||||
|
logger.info(f"[ANNOUNCEMENT] REDIS {operation} {key} - OK")
|
||||||
|
|
||||||
|
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"[MIRRORS] REDIS {operation} - Failed with error: {result}")
|
||||||
|
else:
|
||||||
|
logger.info(f"[MIRRORS] REDIS {operation} {key} - OK")
|
23
app/utils/RedisConnector.py
Normal file
23
app/utils/RedisConnector.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import os
|
||||||
|
import toml
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
|
||||||
|
config: dict = toml.load("config.toml")
|
||||||
|
|
||||||
|
# Redis connection parameters
|
||||||
|
|
||||||
|
redis_config: dict[ str, str | int ] = {
|
||||||
|
"url": f"redis://{os.environ['REDIS_URL']}",
|
||||||
|
"port": os.environ['REDIS_PORT'],
|
||||||
|
}
|
||||||
|
|
||||||
|
class RedisConnector:
|
||||||
|
"""Implements the RedisConnector class for the ReVanced API"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def connect(database: str) -> aioredis.Redis:
|
||||||
|
"""Connect to Redis"""
|
||||||
|
redis_url = f"{redis_config['url']}:{redis_config['port']}/{database}"
|
||||||
|
return aioredis.from_url(redis_url, encoding="utf-8", decode_responses=True)
|
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
46
config.toml
Normal file
46
config.toml
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
[docs]
|
||||||
|
|
||||||
|
title = "ReVanced Polling API"
|
||||||
|
description = """We do a little polling ඞ
|
||||||
|
## The official JSON API for ReVanced polls 🚀
|
||||||
|
|
||||||
|
### Important Information
|
||||||
|
|
||||||
|
* Rate Limiting - 60 requests per minute
|
||||||
|
* Cache - 5 minutes
|
||||||
|
* Token duration - 5 minutes
|
||||||
|
|
||||||
|
### Additional Notes
|
||||||
|
|
||||||
|
1. Breaking changes are to be expected
|
||||||
|
2. Client side caching is advised to avoid unnecessary requests
|
||||||
|
3. Abuse of the API will result in IP blocks
|
||||||
|
|
||||||
|
"""
|
||||||
|
version = "0.0.1"
|
||||||
|
|
||||||
|
[license]
|
||||||
|
|
||||||
|
name = "AGPL-3.0"
|
||||||
|
url = "https://www.gnu.org/licenses/agpl-3.0.en.html"
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
level = "INFO"
|
||||||
|
json_logs = false
|
||||||
|
|
||||||
|
[cache]
|
||||||
|
expire = 300
|
||||||
|
database = 0
|
||||||
|
|
||||||
|
[slowapi]
|
||||||
|
limit = "60/minute"
|
||||||
|
database = 1
|
||||||
|
|
||||||
|
[tokens]
|
||||||
|
database = 2
|
||||||
|
|
||||||
|
[ballots]
|
||||||
|
database = 3
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
access_token_expires = 300
|
78
mypy.ini
Normal file
78
mypy.ini
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
[mypy]
|
||||||
|
python_version = 3.10
|
||||||
|
pretty = true
|
||||||
|
follow_imports = normal
|
||||||
|
namespace_packages = true
|
||||||
|
show_column_numbers = true
|
||||||
|
show_error_codes = true
|
||||||
|
allow_redefinition = false
|
||||||
|
check_untyped_defs = true
|
||||||
|
implicit_reexport = false
|
||||||
|
strict_optional = true
|
||||||
|
strict_equality = true
|
||||||
|
warn_no_return = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
warn_unused_ignores = true
|
||||||
|
warn_unreachable = true
|
||||||
|
plugins = pydantic.mypy
|
||||||
|
|
||||||
|
[mypy-toml.*]
|
||||||
|
# Current stubs are not compatible with python 3.10
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-uvicorn.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-aioredis.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-fastapi.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-slowapi.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-fastapi_cache.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-msgpack.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-orjson.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-httpx_cache.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-redis.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-toolz.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-fastapi_paseto_auth.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-aiofiles.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-gunicorn.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-asgiref.*]
|
||||||
|
# No stubs available
|
||||||
|
ignore_missing_imports = True
|
1686
poetry.lock
generated
Normal file
1686
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
pyproject.toml
Normal file
40
pyproject.toml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "revanced-polling-api"
|
||||||
|
version = "0.0.1"
|
||||||
|
description = "We do a little polling ඞ"
|
||||||
|
authors = ["Alexandre Teles <alexandre.teles@ufba.br>"]
|
||||||
|
license = "AGPLv3"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.10"
|
||||||
|
fastapi = ">=0.85.0"
|
||||||
|
httpx = {version = ">=0.23.0", extras = ["http2"]}
|
||||||
|
httpx-cache = ">=0.6.0"
|
||||||
|
toml = ">=0.10.2"
|
||||||
|
slowapi = ">=0.1.6"
|
||||||
|
orjson = ">=3.8.0"
|
||||||
|
fastapi-cache2 = ">=0.1.9"
|
||||||
|
redis = ">=4.3.4"
|
||||||
|
loguru = ">=0.6.0"
|
||||||
|
sentry-sdk = ">=1.9.8"
|
||||||
|
argon2-cffi = ">=21.3.0"
|
||||||
|
hypercorn = {extras = ["uvloop"], version = ">=0.14.3"}
|
||||||
|
cytoolz = ">=0.12.0"
|
||||||
|
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"
|
||||||
|
types-toml = ">=0.10.8"
|
||||||
|
types-redis = ">=4.3.21.1"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
|
[virtualenvs]
|
||||||
|
create = true
|
64
requirements.txt
Normal file
64
requirements.txt
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
aiofiles==22.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
aiorwlock==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
anyio==3.6.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
argon2-cffi-bindings==21.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
argon2-cffi==21.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
async-timeout==4.0.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
attrs==21.4.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
certifi==2022.9.24 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
cffi==1.15.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
click==8.1.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32" or python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows"
|
||||||
|
cryptography==37.0.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
cytoolz==0.12.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
deprecated==1.2.13 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
fastapi-cache2==0.1.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
fastapi-paseto-auth==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
fastapi==0.85.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
fasteners==0.17.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
gunicorn==20.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
h11==0.12.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
h2==4.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
hiredis==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
hpack==4.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
httpcore==0.15.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
httpx-cache==0.6.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
httpx==0.23.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
httpx[http2]==0.23.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
hypercorn[uvloop]==0.14.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
hyperframe==6.0.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
idna==3.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
iso8601==1.1.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
limits==1.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
loguru==0.6.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
msgpack==1.0.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
orjson==3.8.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
packaging==21.3 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
passlib[argon2]==1.7.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
pendulum==2.1.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
priority==2.0.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
pycparser==2.21 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
pycryptodomex==3.15.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
pydantic==1.10.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
pyparsing==3.0.9 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
pyseto==1.6.10 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
python-dateutil==2.8.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
pytzdata==2020.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
redis==4.3.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
rfc3986[idna2008]==1.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
sentry-sdk==1.10.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
setuptools==65.5.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
slowapi==0.1.6 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
sniffio==1.3.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
starlette==0.20.4 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
toml==0.10.2 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
toolz==0.12.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
typing-extensions==4.4.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
ujson==5.5.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
urllib3==1.26.12 ; python_version >= "3.10" and python_version < "4"
|
||||||
|
uvicorn==0.19.0 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
uvloop==0.17.0 ; platform_system != "Windows" and python_version >= "3.10" and python_version < "4.0"
|
||||||
|
win32-setctime==1.1.0 ; python_version >= "3.10" and python_version < "4.0" and sys_platform == "win32"
|
||||||
|
wrapt==1.14.1 ; python_version >= "3.10" and python_version < "4.0"
|
||||||
|
wsproto==1.2.0 ; python_version >= "3.10" and python_version < "4.0"
|
148
run.py
Normal file
148
run.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user