mirror of
https://github.com/revanced/revanced-releases-api.git
synced 2025-05-03 15:44:26 +02:00
feat: implements cdn mirrors endpoints, fix docs, move endpoints to custom routers (#18)
* feat: resolves #5, resolves #4 (#7) * Implements client generation and management * fix announcements endpoints * change annoucements model * bump deps * sync with main * refactor: adopt some functional standards in Releases.py * feat: add new workflows * chore: remove unused files * refactor: update build badge * refactor: move files around and delete unused ones * feat: add authentication endpoints * refactor: clean up code on Clients.py controller * fix: fix the client secret update endpoint * refactor: clean up authentication code * feat: add authentication to client endpoints * chore: bump deps * feat: add admin user generation * feature: add /changelogs endpoint (#10) * feat: move endpoints into custom routers, resolves #12 (#14) * refactor: import routers from old branch * refactor: import InternalCache removal * refactor: move routes into dedicated routers * fix: fixes entrypoint * refactor: add documentation and bump libs * docs: update description (#16) * feat: implement cdn mirrors endpoints, closes #15 (#17) * feat: add cdn mirror endpoints * refactor: change API version in docs * docs: fix titles on API docs page Co-authored-by: oSumAtrIX <johan.melkonyan1@web.de>
This commit is contained in:
parent
ca49a3b31a
commit
3b418197c2
@ -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")
|
||||||
|
|
111
app/controllers/Mirrors.py
Normal file
111
app/controllers/Mirrors.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import toml
|
||||||
|
from redis import asyncio as aioredis
|
||||||
|
import app.utils.Logger as Logger
|
||||||
|
from app.models.MirrorModels import MirrorModel, MirrorStoreModel
|
||||||
|
from app.utils.RedisConnector import RedisConnector
|
||||||
|
|
||||||
|
config: dict = toml.load("config.toml")
|
||||||
|
|
||||||
|
class Mirrors:
|
||||||
|
"""Implements the Mirror class for the ReVanced API"""
|
||||||
|
|
||||||
|
redis = RedisConnector.connect(config['mirrors']['database'])
|
||||||
|
|
||||||
|
MirrorsLogger = Logger.MirrorsLogger()
|
||||||
|
|
||||||
|
async def assemble_key(self, org: str, repo: str, version: str) -> str:
|
||||||
|
"""Assemble the Redis key for the cdn
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The Redis key
|
||||||
|
"""
|
||||||
|
|
||||||
|
return f"{org}/{repo}/{version}"
|
||||||
|
|
||||||
|
async def store(self, org: str, repo: str, version: str, mirror: MirrorStoreModel) -> bool:
|
||||||
|
"""Store mirrors in the database
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mirror (MirrorStoreModel): Pydantic model of the mirror information
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if data was stored successfully, False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = await self.assemble_key(org, repo, version)
|
||||||
|
mirror_payload: dict[str, str | list[str]] = {}
|
||||||
|
|
||||||
|
mirror_payload['cid'] = mirror.cid
|
||||||
|
mirror_payload['filenames'] = mirror.filenames
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.redis.json().set(key, '$', mirror_payload)
|
||||||
|
await self.MirrorsLogger.log("SET", None, key)
|
||||||
|
except aioredis.RedisError as e:
|
||||||
|
await self.MirrorsLogger.log("SET", e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def exists(self, org: str, repo: str, version: str) -> bool:
|
||||||
|
"""Check if a cdn exists in the database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the cdn exists, False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = await self.assemble_key(org, repo, version)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if await self.redis.exists(key):
|
||||||
|
await self.MirrorsLogger.log("EXISTS", None, key)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except aioredis.RedisError as e:
|
||||||
|
await self.MirrorsLogger.log("EXISTS", e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def get(self, org: str, repo: str, version: str) -> MirrorModel:
|
||||||
|
"""Get the mirror information from the database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict[str, str | int]: The mirror information
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = await self.assemble_key(org, repo, version)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload: dict[str, str | list[str]] = await self.redis.json().get(key)
|
||||||
|
|
||||||
|
mirror = MirrorModel(
|
||||||
|
repository=f"{org}/{repo}",
|
||||||
|
version=version,
|
||||||
|
cid=payload['cid'],
|
||||||
|
filenames=payload['filenames']
|
||||||
|
)
|
||||||
|
|
||||||
|
await self.MirrorsLogger.log("GET", None, key)
|
||||||
|
return mirror
|
||||||
|
except aioredis.RedisError as e:
|
||||||
|
await self.MirrorsLogger.log("GET", e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
async def delete(self, org: str, repo: str, version: str) -> bool:
|
||||||
|
"""Delete the cdn from the database
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the cdn was deleted successfully, False otherwise
|
||||||
|
"""
|
||||||
|
|
||||||
|
key = await self.assemble_key(org, repo, version)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self.redis.delete(key)
|
||||||
|
await self.MirrorsLogger.log("DELETE", None, key)
|
||||||
|
return True
|
||||||
|
except aioredis.RedisError as e:
|
||||||
|
await self.MirrorsLogger.log("DELETE", e)
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
@ -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,12 +62,7 @@ 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] = {}
|
||||||
|
|
||||||
if await self.InternalCache.exists('releases'):
|
|
||||||
releases = await self.InternalCache.get('releases')
|
|
||||||
else:
|
|
||||||
releases = {}
|
|
||||||
releases['tools'] = []
|
releases['tools'] = []
|
||||||
|
|
||||||
results: list = await asyncio.gather(*[self.__get_release(repository) for repository in repositories])
|
results: list = await asyncio.gather(*[self.__get_release(repository) for repository in repositories])
|
||||||
@ -79,8 +71,6 @@ class Releases:
|
|||||||
for asset in result:
|
for asset in result:
|
||||||
releases['tools'].append(asset)
|
releases['tools'].append(asset)
|
||||||
|
|
||||||
await self.InternalCache.store('releases', releases)
|
|
||||||
|
|
||||||
return releases
|
return releases
|
||||||
|
|
||||||
async def __get_patches_json(self) -> dict:
|
async def __get_patches_json(self) -> dict:
|
||||||
@ -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,9 +125,6 @@ class Releases:
|
|||||||
|
|
||||||
contributors: dict[str, list]
|
contributors: dict[str, list]
|
||||||
|
|
||||||
if await self.InternalCache.exists('contributors'):
|
|
||||||
contributors = await self.InternalCache.get('contributors')
|
|
||||||
else:
|
|
||||||
contributors = {}
|
contributors = {}
|
||||||
contributors['repositories'] = []
|
contributors['repositories'] = []
|
||||||
|
|
||||||
@ -153,8 +136,6 @@ class Releases:
|
|||||||
data = { 'name': key, 'contributors': value }
|
data = { 'name': key, 'contributors': value }
|
||||||
contributors['repositories'].append(data)
|
contributors['repositories'].append(data)
|
||||||
|
|
||||||
await self.InternalCache.store('contributors', contributors)
|
|
||||||
|
|
||||||
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,11 +161,6 @@ class Releases:
|
|||||||
payload["commits"] = []
|
payload["commits"] = []
|
||||||
|
|
||||||
if org == 'revanced' or org == 'vancedapp':
|
if org == 'revanced' or org == 'vancedapp':
|
||||||
key: str = f"{org}/{repository}/{path}"
|
|
||||||
if await self.InternalCache.exists(key):
|
|
||||||
return await self.InternalCache.get(key)
|
|
||||||
else:
|
|
||||||
|
|
||||||
_releases = await self.httpx_client.get(
|
_releases = await self.httpx_client.get(
|
||||||
f"https://api.github.com/repos/{org}/{repository}/releases?per_page=2"
|
f"https://api.github.com/repos/{org}/{repository}/releases?per_page=2"
|
||||||
)
|
)
|
||||||
@ -208,8 +184,6 @@ class Releases:
|
|||||||
data["html_url"] = commit["html_url"]
|
data["html_url"] = commit["html_url"]
|
||||||
payload['commits'].append(data)
|
payload['commits'].append(data)
|
||||||
|
|
||||||
await self.InternalCache.store(key, payload)
|
|
||||||
|
|
||||||
return 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")
|
158
app/main.py
Executable file
158
app/main.py
Executable file
@ -0,0 +1,158 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import os
|
||||||
|
import toml
|
||||||
|
import binascii
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.responses import JSONResponse, UJSONResponse
|
||||||
|
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||||
|
|
||||||
|
from fastapi_cache import FastAPICache
|
||||||
|
from fastapi_cache.decorator import cache
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from fastapi_cache.backends.redis import RedisBackend
|
||||||
|
|
||||||
|
from fastapi_paseto_auth import AuthPASETO
|
||||||
|
from fastapi_paseto_auth.exceptions import AuthPASETOException
|
||||||
|
|
||||||
|
import app.controllers.Auth as Auth
|
||||||
|
from app.controllers.Clients import Clients
|
||||||
|
from app.utils.RedisConnector import RedisConnector
|
||||||
|
|
||||||
|
import app.models.GeneralErrors as GeneralErrors
|
||||||
|
|
||||||
|
from app.routers import root
|
||||||
|
from app.routers import ping
|
||||||
|
from app.routers import auth
|
||||||
|
from app.routers import tools
|
||||||
|
from app.routers import clients
|
||||||
|
from app.routers import patches
|
||||||
|
from app.routers import mirrors
|
||||||
|
from app.routers import changelogs
|
||||||
|
from app.routers import contributors
|
||||||
|
from app.routers import announcement
|
||||||
|
|
||||||
|
"""Get latest ReVanced releases from GitHub API."""
|
||||||
|
|
||||||
|
# Load config
|
||||||
|
|
||||||
|
config: dict = toml.load("config.toml")
|
||||||
|
|
||||||
|
# Create FastAPI instance
|
||||||
|
|
||||||
|
app = FastAPI(title=config['docs']['title'],
|
||||||
|
description=config['docs']['description'],
|
||||||
|
version=config['docs']['version'],
|
||||||
|
license_info={"name": config['license']['name'],
|
||||||
|
"url": config['license']['url']
|
||||||
|
},
|
||||||
|
default_response_class=UJSONResponse
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hook up rate limiter
|
||||||
|
limiter = Limiter(key_func=get_remote_address,
|
||||||
|
default_limits=[
|
||||||
|
config['slowapi']['limit']
|
||||||
|
],
|
||||||
|
headers_enabled=True,
|
||||||
|
storage_uri=f"redis://{os.environ['REDIS_URL']}:{os.environ['REDIS_PORT']}/{config['slowapi']['database']}"
|
||||||
|
)
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
|
# Setup routes
|
||||||
|
|
||||||
|
app.include_router(root.router)
|
||||||
|
app.include_router(tools.router)
|
||||||
|
app.include_router(patches.router)
|
||||||
|
app.include_router(contributors.router)
|
||||||
|
app.include_router(changelogs.router)
|
||||||
|
app.include_router(auth.router)
|
||||||
|
app.include_router(clients.router)
|
||||||
|
app.include_router(announcement.router)
|
||||||
|
app.include_router(mirrors.router)
|
||||||
|
app.include_router(ping.router)
|
||||||
|
|
||||||
|
# Setup cache
|
||||||
|
|
||||||
|
@cache()
|
||||||
|
async def get_cache() -> int:
|
||||||
|
"""Get cache TTL from config.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
int: Cache TTL
|
||||||
|
"""
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Setup PASETO
|
||||||
|
|
||||||
|
@AuthPASETO.load_config
|
||||||
|
def get_config() -> Auth.PasetoSettings:
|
||||||
|
"""Get PASETO config from Auth module
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PasetoSettings: PASETO config
|
||||||
|
"""
|
||||||
|
return Auth.PasetoSettings()
|
||||||
|
|
||||||
|
# Setup custom error handlers
|
||||||
|
|
||||||
|
@app.exception_handler(AuthPASETOException)
|
||||||
|
async def authpaseto_exception_handler(request: Request, exc: AuthPASETOException) -> JSONResponse:
|
||||||
|
"""Handle AuthPASETOException
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): Request
|
||||||
|
exc (AuthPASETOException): Exception
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Response
|
||||||
|
"""
|
||||||
|
return JSONResponse(status_code=exc.status_code, content={"detail": exc.message})
|
||||||
|
|
||||||
|
@app.exception_handler(AttributeError)
|
||||||
|
async def validation_exception_handler(request, exc) -> JSONResponse:
|
||||||
|
"""Handle AttributeError
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): Request
|
||||||
|
exc (AttributeError): Exception
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Response
|
||||||
|
"""
|
||||||
|
return JSONResponse(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={
|
||||||
|
"error": "Unprocessable Entity"
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.exception_handler(binascii.Error)
|
||||||
|
async def invalid_token_exception_handler(request, exc) -> JSONResponse:
|
||||||
|
"""Handle binascii.Error
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request (Request): Request
|
||||||
|
exc (binascii.Error): Exception
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Response
|
||||||
|
"""
|
||||||
|
return JSONResponse(status_code=status.HTTP_401_UNAUTHORIZED, content={
|
||||||
|
"error": GeneralErrors.Unauthorized().error,
|
||||||
|
"message": GeneralErrors.Unauthorized().message
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def startup() -> None:
|
||||||
|
"""Startup event handler"""
|
||||||
|
|
||||||
|
clients = Clients()
|
||||||
|
await clients.setup_admin()
|
||||||
|
FastAPICache.init(RedisBackend(RedisConnector.connect(config['cache']['database'])),
|
||||||
|
prefix="fastapi-cache")
|
||||||
|
|
||||||
|
return None
|
@ -49,3 +49,23 @@ class Unauthorized(BaseModel):
|
|||||||
|
|
||||||
error: str = "Unauthorized"
|
error: str = "Unauthorized"
|
||||||
message: str = "The client is unauthorized to access this resource"
|
message: str = "The client is unauthorized to access this resource"
|
||||||
|
|
||||||
|
class MirrorNotFoundError(BaseModel):
|
||||||
|
"""Implements the response fields for when a mirror is not found.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str = "Not Found"
|
||||||
|
message: str = "No mirror was found for the organization, repository, and version provided."
|
||||||
|
|
||||||
|
class MirrorAlreadyExistsError(BaseModel):
|
||||||
|
"""Implements the response fields for when a mirror already exists.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
error: str = "Conflict"
|
||||||
|
message: str = "A mirror already exists for the organization, repository, and version provided. Please use the PUT method to update the mirror."
|
52
app/models/MirrorModels.py
Normal file
52
app/models/MirrorModels.py
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class MirrorModel(BaseModel):
|
||||||
|
"""Implements the response fields for the CDN mirror.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
repository: str
|
||||||
|
version: str
|
||||||
|
cid: str
|
||||||
|
filenames: list[str]
|
||||||
|
|
||||||
|
class MirrorStoreModel(BaseModel):
|
||||||
|
"""Implements the fields for storing CDN mirror information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
cid: str
|
||||||
|
filenames: list[str]
|
||||||
|
|
||||||
|
class MirrorCreatedResponseModel(BaseModel):
|
||||||
|
"""Implements the response fields for stored CDN mirrors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
created: bool
|
||||||
|
key: str
|
||||||
|
|
||||||
|
class MirrorUpdatedResponseModel(BaseModel):
|
||||||
|
"""Implements the response fields for updated CDN mirrors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
|
||||||
|
updated: bool
|
||||||
|
key: str
|
||||||
|
|
||||||
|
class MirrorDeletedResponseModel(BaseModel):
|
||||||
|
"""Implements the response fields for deleted CDN mirrors.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
BaseModel (pydantic.BaseModel): BaseModel from pydantic
|
||||||
|
"""
|
||||||
|
deleted: bool
|
||||||
|
key: str
|
@ -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'])
|
139
app/routers/mirrors.py
Normal file
139
app/routers/mirrors.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
from fastapi_paseto_auth import AuthPASETO
|
||||||
|
from fastapi import APIRouter, Request, Response, Depends, status, HTTPException
|
||||||
|
from app.dependencies import load_config
|
||||||
|
from fastapi_cache.decorator import cache
|
||||||
|
from app.controllers.Clients import Clients
|
||||||
|
from app.controllers.Mirrors import Mirrors
|
||||||
|
import app.models.MirrorModels as MirrorModels
|
||||||
|
import app.models.GeneralErrors as GeneralErrors
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/mirrors",
|
||||||
|
tags=['CDN Mirrors']
|
||||||
|
)
|
||||||
|
|
||||||
|
clients = Clients()
|
||||||
|
mirrors = Mirrors()
|
||||||
|
|
||||||
|
config: dict = load_config()
|
||||||
|
|
||||||
|
@router.get('/{org}/{repo}/{version}', status_code=status.HTTP_200_OK, response_model=MirrorModels.MirrorModel)
|
||||||
|
async def get_mirrors(request: Request, response: Response, org: str, repo: str, version: str) -> MirrorModels.MirrorModel:
|
||||||
|
"""Get CDN mirror information for a given release.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
json: mirror information
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not await mirrors.exists(org, repo, version):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={
|
||||||
|
"error": GeneralErrors.MirrorNotFoundError().error,
|
||||||
|
"message": GeneralErrors.MirrorNotFoundError().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return await mirrors.get(org, repo, version)
|
||||||
|
|
||||||
|
@router.post('/{org}/{repo}/{version}', status_code=status.HTTP_201_CREATED, response_model=MirrorModels.MirrorCreatedResponseModel)
|
||||||
|
async def create_mirror(request: Request, response: Response, org: str, repo: str, version: str,
|
||||||
|
mirror: MirrorModels.MirrorStoreModel, Authorize: AuthPASETO = Depends()) -> dict:
|
||||||
|
"""Stores information about a new CDN mirror for a given release.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
Authorize.paseto_required()
|
||||||
|
|
||||||
|
if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()):
|
||||||
|
if await mirrors.exists(org, repo, version):
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail={
|
||||||
|
"error": GeneralErrors.MirrorAlreadyExistsError().error,
|
||||||
|
"message": GeneralErrors.MirrorAlreadyExistsError().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key = await mirrors.assemble_key(org, repo, version)
|
||||||
|
created: bool = await mirrors.store(org, repo, version, mirror)
|
||||||
|
if created:
|
||||||
|
return {"created": created, "key": key}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={
|
||||||
|
"error": GeneralErrors.InternalServerError().error,
|
||||||
|
"message": GeneralErrors.InternalServerError().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=401, detail={
|
||||||
|
"error": GeneralErrors.Unauthorized().error,
|
||||||
|
"message": GeneralErrors.Unauthorized().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.put('/{org}/{repo}/{version}', status_code=status.HTTP_200_OK, response_model=MirrorModels.MirrorUpdatedResponseModel)
|
||||||
|
async def update_mirror(request: Request, response: Response, org: str, repo: str, version: str,
|
||||||
|
mirror: MirrorModels.MirrorStoreModel,Authorize: AuthPASETO = Depends()) -> dict:
|
||||||
|
"""Updates a stored information about CDN mirrors for a given release
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
Authorize.paseto_required()
|
||||||
|
|
||||||
|
if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()):
|
||||||
|
if not await mirrors.exists(org, repo, version):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={
|
||||||
|
"error": GeneralErrors.MirrorNotFoundError().error,
|
||||||
|
"message": GeneralErrors.MirrorNotFoundError().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key = await mirrors.assemble_key(org, repo, version)
|
||||||
|
updated: bool = await mirrors.store(org, repo, version, mirror)
|
||||||
|
if updated:
|
||||||
|
return {"updated": updated, "key": key}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={
|
||||||
|
"error": GeneralErrors.InternalServerError().error,
|
||||||
|
"message": GeneralErrors.InternalServerError().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=401, detail={
|
||||||
|
"error": GeneralErrors.Unauthorized().error,
|
||||||
|
"message": GeneralErrors.Unauthorized().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.delete('/{org}/{repo}/{version}', status_code=status.HTTP_200_OK, response_model=MirrorModels.MirrorDeletedResponseModel)
|
||||||
|
async def delete_mirror(request: Request, response: Response, org: str, repo: str, version: str, Authorize: AuthPASETO = Depends()) -> dict:
|
||||||
|
"""Deletes a stored information about CDN mirrors for a given release
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
json: _description_
|
||||||
|
"""
|
||||||
|
Authorize.paseto_required()
|
||||||
|
|
||||||
|
if await clients.auth_checks(Authorize.get_subject(), Authorize.get_jti()):
|
||||||
|
if not await mirrors.exists(org, repo, version):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail={
|
||||||
|
"error": GeneralErrors.MirrorNotFoundError().error,
|
||||||
|
"message": GeneralErrors.MirrorNotFoundError().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
key = await mirrors.assemble_key(org, repo, version)
|
||||||
|
deleted: bool = await mirrors.delete(org, repo, version)
|
||||||
|
if deleted:
|
||||||
|
return {"deleted": deleted, "key": key}
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={
|
||||||
|
"error": GeneralErrors.InternalServerError().error,
|
||||||
|
"message": GeneralErrors.InternalServerError().message
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=401, detail={
|
||||||
|
"error": GeneralErrors.Unauthorized().error,
|
||||||
|
"message": GeneralErrors.Unauthorized().message
|
||||||
|
}
|
||||||
|
)
|
22
app/routers/patches.py
Normal file
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,7 @@
|
|||||||
import sys
|
|
||||||
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."""
|
||||||
|
|
||||||
@ -109,19 +64,15 @@ class AnnouncementsLogger:
|
|||||||
else:
|
else:
|
||||||
logger.info(f"[User] REDIS {operation} {key} - OK")
|
logger.info(f"[User] REDIS {operation} {key} - OK")
|
||||||
|
|
||||||
def setup_logging(LOG_LEVEL: str, JSON_LOGS: bool) -> None:
|
class MirrorsLogger:
|
||||||
|
async def log(self, operation: str, result: RedisError | None = None, key: str = "") -> None:
|
||||||
|
"""Logs internal cache operations
|
||||||
|
|
||||||
"""Setup logging for uvicorn and FastAPI."""
|
Args:
|
||||||
|
operation (str): Operation name
|
||||||
# intercept everything at the root logger
|
key (str): Key used in the operation
|
||||||
logging.root.handlers = [InterceptHandler()]
|
"""
|
||||||
logging.root.setLevel(LOG_LEVEL)
|
if type(result) is RedisError:
|
||||||
|
logger.error(f"[User] REDIS {operation} - Failed with error: {result}")
|
||||||
# remove every other logger's handlers
|
else:
|
||||||
# and propagate to root logger
|
logger.info(f"[User] REDIS {operation} {key} - OK")
|
||||||
for name in logging.root.manager.loggerDict.keys():
|
|
||||||
logging.getLogger(name).handlers = []
|
|
||||||
logging.getLogger(name).propagate = True
|
|
||||||
|
|
||||||
# configure loguru
|
|
||||||
logger.configure(handlers=[{"sink": sys.stdout, "serialize": JSON_LOGS}])
|
|
0
app/utils/__init__.py
Normal file
0
app/utils/__init__.py
Normal file
35
config.toml
35
config.toml
@ -2,50 +2,44 @@
|
|||||||
|
|
||||||
title = "ReVanced Releases API"
|
title = "ReVanced Releases API"
|
||||||
description = """
|
description = """
|
||||||
This website provides a JSON API for ReVanced Releases 🚀
|
## The official JSON API for ReVanced Releases 🚀
|
||||||
|
|
||||||
Changelogs are not included but can be found on the [ReVanced Repositories](https://github.com/revanced/).
|
### Links
|
||||||
|
|
||||||
The team also have a [Discord Server](https://revanced.app/discord) if you need help.
|
- [Changelogs](https://github.com/revanced/)
|
||||||
|
- [Official links to ReVanced](https://revanced.app)
|
||||||
|
|
||||||
## Important Information
|
### Important Information
|
||||||
|
|
||||||
* Rate Limiting - 60 requests per minute
|
* Rate Limiting - 60 requests per minute
|
||||||
* Cache - 5 minutes
|
* Cache - 5 minutes
|
||||||
* Token duration - 1 hour
|
* Token duration - 1 hour
|
||||||
* Token refresh - 30 days
|
* Token refresh - 30 days
|
||||||
|
|
||||||
## Additional Notes
|
### Additional Notes
|
||||||
|
|
||||||
1. Although we will try to avoid breaking changes, we can't guarantee that it won't happen.
|
1. Breaking changes are to be expected
|
||||||
2. Make sure to implement a cache system on your end to avoid unnecessary requests.
|
2. Client side caching is adviced to avoid unnecessary requests
|
||||||
3. API abuse will result in IP blocks.
|
3. Abuse of the API will result in IP blocks
|
||||||
|
|
||||||
Godspeed 💀
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
version = "0.8 RC"
|
version = "0.9 RC2"
|
||||||
|
|
||||||
[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]
|
||||||
@ -57,6 +51,9 @@ database = 3
|
|||||||
[announcements]
|
[announcements]
|
||||||
database = 4
|
database = 4
|
||||||
|
|
||||||
|
[mirrors]
|
||||||
|
database = 5
|
||||||
|
|
||||||
[app]
|
[app]
|
||||||
|
|
||||||
repositories = ["TeamVanced/VancedMicroG", "revanced/revanced-cli", "revanced/revanced-patcher", "revanced/revanced-patches", "revanced/revanced-integrations", "revanced/revanced-manager"]
|
repositories = ["TeamVanced/VancedMicroG", "revanced/revanced-cli", "revanced/revanced-patcher", "revanced/revanced-patches", "revanced/revanced-integrations", "revanced/revanced-manager"]
|
||||||
|
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
@ -68,3 +68,7 @@ ignore_missing_imports = True
|
|||||||
[mypy-aiofiles.*]
|
[mypy-aiofiles.*]
|
||||||
# No stubs available
|
# No stubs available
|
||||||
ignore_missing_imports = True
|
ignore_missing_imports = True
|
||||||
|
|
||||||
|
[mypy-gunicorn.*]
|
||||||
|
# No stubs available
|
||||||
|
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