feat: move endpoints into custom routers, resolves #12 (#14)

* refactor: import routers from old branch

* refactor: import InternalCache removal

* refactor: move routes into dedicated routers

* fix: fixes entrypoint

* refactor: add documentation and bump libs
This commit is contained in:
Alexandre Teles
2022-10-11 00:10:56 -03:00
committed by GitHub
parent 6133b4f776
commit 0ce5780a4e
40 changed files with 859 additions and 734 deletions

View File

@ -0,0 +1,102 @@
import toml
from redis import asyncio as aioredis
import app.utils.Logger as Logger
from app.utils.Generators import Generators
from app.models.AnnouncementModels import AnnouncementCreateModel
from app.utils.RedisConnector import RedisConnector
config: dict = toml.load("config.toml")
class Announcements:
"""Implements the announcements class for the ReVanced API"""
redis = RedisConnector.connect(config['announcements']['database'])
AnnouncementsLogger = Logger.AnnouncementsLogger()
generators = Generators()
async def store(self, announcement: AnnouncementCreateModel, author: str) -> bool:
"""Store an announcement in the database
Args:
announcement (AnnouncementCreateModel): Pydantic model of the announcement
Returns:
str | bool: UUID of the announcement or False if the announcement wasn't stored successfully
"""
announcement_id: str = "announcement"
timestamp = await self.generators.generate_timestamp()
announcement_payload: dict[str, str | int] = {}
announcement_payload['created_at'] = timestamp
announcement_payload['author'] = author
announcement_payload['type'] = announcement.type
announcement_payload['title'] = announcement.title
announcement_payload['content'] = announcement.content
try:
await self.redis.json().set(announcement_id, '$', announcement_payload)
await self.AnnouncementsLogger.log("SET", None, announcement_id)
except aioredis.RedisError as e:
await self.AnnouncementsLogger.log("SET", e)
raise e
return True
async def exists(self) -> bool:
"""Check if an announcement exists in the database
Returns:
bool: True if the announcement exists, False otherwise
"""
try:
if await self.redis.exists("announcement"):
await self.AnnouncementsLogger.log("EXISTS", None, "announcement")
return True
else:
await self.AnnouncementsLogger.log("EXISTS", None, "announcement")
return False
except aioredis.RedisError as e:
await self.AnnouncementsLogger.log("EXISTS", e)
raise e
async def get(self) -> dict:
"""Get a announcement from the database
Returns:
dict: Dict of the announcement or an empty dict if the announcement doesn't exist
"""
if await self.exists():
try:
announcement: dict[str, str | int] = await self.redis.json().get("announcement")
await self.AnnouncementsLogger.log("GET", None, "announcement")
except aioredis.RedisError as e:
await self.AnnouncementsLogger.log("GET", e)
return {}
return announcement
else:
return {}
async def delete(self) -> bool:
"""Delete an announcement from the database
Returns:
bool: True if the announcement was deleted successfully, False otherwise
"""
if await self.exists():
try:
await self.redis.delete("announcement")
await self.AnnouncementsLogger.log("DELETE", None, "announcement")
except aioredis.RedisError as e:
await self.AnnouncementsLogger.log("DELETE", e)
return False
return True
else:
return False

7
app/controllers/Auth.py Normal file
View File

@ -0,0 +1,7 @@
import os
from pydantic import BaseModel
class PasetoSettings(BaseModel):
authpaseto_secret_key: str = os.environ['SECRET_KEY']
authpaseto_access_token_expires: int = 86400

351
app/controllers/Clients.py Normal file
View File

@ -0,0 +1,351 @@
from time import sleep
import toml
import orjson
from typing import Optional
import argon2
from redis import asyncio as aioredis
import aiofiles
import uvloop
import app.utils.Logger as Logger
from app.utils.Generators import Generators
from app.models.ClientModels import ClientModel
from app.utils.RedisConnector import RedisConnector
config: dict = toml.load("config.toml")
class Clients:
"""Implements a client for ReVanced Releases API."""
uvloop.install()
redis = RedisConnector.connect(config['clients']['database'])
redis_tokens = RedisConnector.connect(config['tokens']['database'])
UserLogger = Logger.UserLogger()
generators = Generators()
async def generate(self, admin: Optional[bool] = False) -> ClientModel:
"""Generate a new client
Args:
admin (Optional[bool], optional): Defines if the client should have admin access. Defaults to False.
Returns:
ClientModel: Pydantic model of the client
"""
client_id: str = await self.generators.generate_id()
client_secret: str = await self.generators.generate_secret()
client = ClientModel(id=client_id, secret=client_secret, admin=admin, active=True)
return client
async def store(self, client: ClientModel) -> bool:
"""Store a client in the database
Args:
client (ClientModel): Pydantic model of the client
Returns:
bool: True if the client was stored successfully, False otherwise
"""
client_payload: dict[str, str | bool] = {}
ph: argon2.PasswordHasher = argon2.PasswordHasher()
client_payload['secret'] = ph.hash(client.secret)
client_payload['admin'] = client.admin
client_payload['active'] = client.active
try:
await self.redis.json().set(client.id, '$', client_payload)
await self.UserLogger.log("SET", None, client.id)
except aioredis.RedisError as e:
await self.UserLogger.log("SET", e)
raise e
return True
async def exists(self, client_id: str) -> bool:
"""Check if a client exists in the database
Args:
client_id (str): UUID of the client
Returns:
bool: True if the client exists, False otherwise
"""
try:
if await self.redis.exists(client_id):
await self.UserLogger.log("EXISTS", None, client_id)
return True
else:
await self.UserLogger.log("EXISTS", None, client_id)
return False
except aioredis.RedisError as e:
await self.UserLogger.log("EXISTS", e)
raise e
async def get(self, client_id: str) -> ClientModel | bool:
"""Get a client from the database
Args:
client_id (str): UUID of the client
Returns:
ClientModel | bool: Pydantic model of the client or False if the client doesn't exist
"""
if await self.exists(client_id):
try:
client_payload: dict[str, str | bool] = await self.redis.json().get(client_id)
client = ClientModel(id=client_id, secret=client_payload['secret'], admin=client_payload['admin'], active=True)
await self.UserLogger.log("GET", None, client_id)
except aioredis.RedisError as e:
await self.UserLogger.log("GET", e)
raise e
return client
else:
return False
async def delete(self, client_id: str) -> bool:
"""Delete a client from the database
Args:
client_id (str): UUID of the client
Returns:
bool: True if the client was deleted successfully, False otherwise
"""
if await self.exists(client_id):
try:
await self.redis.delete(client_id)
await self.UserLogger.log("DELETE", None, client_id)
except aioredis.RedisError as e:
await self.UserLogger.log("DELETE", e)
raise e
return True
else:
return False
async def update_secret(self, client_id: str, new_secret: str) -> bool:
"""Update the secret of a client
Args:
client_id (str): UUID of the client
new_secret (str): New secret of the client
Returns:
bool: True if the secret was updated successfully, False otherwise
"""
ph: argon2.PasswordHasher = argon2.PasswordHasher()
updated: bool = False
try:
await self.redis.json().set(client_id, '.secret', ph.hash(new_secret))
await self.UserLogger.log("UPDATE_SECRET", None, client_id)
updated = True
except aioredis.RedisError as e:
await self.UserLogger.log("UPDATE_SECRET", e)
raise e
return updated
async def authenticate(self, client_id: str, secret: str) -> bool:
"""Check if the secret of a client is correct
Args:
client_id (str): UUID of the client
secret (str): Secret of the client
Returns:
bool: True if the secret is correct, False otherwise
"""
ph: argon2.PasswordHasher = argon2.PasswordHasher()
authenticated: bool = False
client_secret: str = await self.redis.json().get(client_id, '.secret')
try:
if ph.verify(client_secret, secret):
await self.UserLogger.log("CHECK_SECRET", None, client_id)
if ph.check_needs_rehash(client_secret):
await self.redis.json().set(client_id, '.secret', ph.hash(secret))
await self.UserLogger.log("REHASH SECRET", None, client_id)
authenticated = True
except argon2.exceptions.VerifyMismatchError as e:
await self.UserLogger.log("CHECK_SECRET", e)
return authenticated
return authenticated
async def is_admin(self, client_id: str) -> bool:
"""Check if a client has admin access
Args:
client_id (str): UUID of the client
Returns:
bool: True if the client has admin access, False otherwise
"""
client_admin: bool = False
try:
client_admin = await self.redis.json().get(client_id, '.admin')
await self.UserLogger.log("CHECK_ADMIN", None, client_id)
except aioredis.RedisError as e:
await self.UserLogger.log("CHECK_ADMIN", e)
raise e
return client_admin
async def is_active(self, client_id: str) -> bool:
"""Check if a client is active
Args:
client_id (str): UUID of the client
Returns:
bool: True if the client is active, False otherwise
"""
client_active: bool = False
try:
client_active = await self.redis.json().get(client_id, '.active')
await self.UserLogger.log("CHECK_ACTIVE", None, client_id)
except aioredis.RedisError as e:
await self.UserLogger.log("CHECK_ACTIVE", e)
raise e
return client_active
async def status(self, client_id: str, active: bool) -> bool:
"""Activate a client
Args:
client_id (str): UUID of the client
active (bool): True to activate the client, False to deactivate it
Returns:
bool: True if the client status was change successfully, False otherwise
"""
changed: bool = False
try:
await self.redis.json().set(client_id, '.active', active)
await self.UserLogger.log("ACTIVATE", None, client_id)
changed = True
except aioredis.RedisError as e:
await self.UserLogger.log("ACTIVATE", e)
raise e
return changed
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:
await self.redis_tokens.set(token, '')
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
async def is_token_banned(self, token: str) -> bool:
"""Check if a token is banned
Args:
token (str): Token to check
Returns:
bool: True if the token is banned, False otherwise
"""
banned: bool = True
try:
banned = await self.redis_tokens.exists(token)
await self.UserLogger.log("CHECK_TOKEN", None, token)
except aioredis.RedisError as e:
await self.UserLogger.log("CHECK_TOKEN", e)
raise e
return banned
async def auth_checks(self, client_id: str, token: str) -> bool:
"""Check if a client exists, is active and the token isn't banned
Args:
client_id (str): UUID of the client
secret (str): Secret of the client
Returns:
bool: True if the client exists, is active
and the token isn't banned, False otherwise
"""
if await self.exists(client_id):
if await self.is_active(client_id):
if not await self.is_token_banned(token):
return True
else:
return False
else:
if not await self.is_token_banned(token):
await self.ban_token(token)
return False
else:
await self.ban_token(token)
return False
return False
async def setup_admin(self) -> bool:
"""Create the admin user if it doesn't exist
Returns:
bool: True if the admin user was created successfully, False otherwise
"""
created: bool = False
if not await self.exists('admin'):
admin_info: ClientModel = await self.generate()
admin_info.id = 'admin'
admin_info.admin = True
try:
await self.store(admin_info)
await self.UserLogger.log("CREATE_ADMIN | ID |", None, admin_info.id)
await self.UserLogger.log("CREATE_ADMIN | SECRET |", None, admin_info.secret)
async with aiofiles.open("admin_info.json", "wb") as file:
await file.write(orjson.dumps(vars(admin_info)))
await self.UserLogger.log("CREATE_ADMIN | TO FILE", None, "admin_info.json")
created = True
except aioredis.RedisError as e:
await self.UserLogger.log("CREATE_ADMIN", e)
raise e
return created

189
app/controllers/Releases.py Normal file
View File

@ -0,0 +1,189 @@
from toolz.dicttoolz import keyfilter
import asyncio
import uvloop
import orjson
from base64 import b64decode
from app.utils.HTTPXClient import HTTPXClient
class Releases:
"""Implements the methods required to get the latest releases and patches from revanced repositories."""
uvloop.install()
httpx_client = HTTPXClient.create()
async def __get_release(self, repository: str) -> list:
# Get assets from latest release in a given repository.
#
# Args:
# repository (str): Github's standard username/repository notation
#
# Returns:
# dict: dictionary of filename and download url
assets: list = []
response = await self.httpx_client.get(f"https://api.github.com/repos/{repository}/releases/latest")
if response.status_code == 200:
release_assets: dict = response.json()['assets']
release_version: str = response.json()['tag_name']
release_tarball: str = response.json()['tarball_url']
release_timestamp: str = response.json()['published_at']
if release_assets:
for asset in release_assets:
assets.append({ 'repository': repository,
'version': release_version,
'timestamp': asset['updated_at'],
'name': asset['name'],
'size': asset['size'],
'browser_download_url': asset['browser_download_url'],
'content_type': asset['content_type']
})
else:
assets.append({ 'repository': repository,
'version': release_version,
'timestamp': release_timestamp,
'name': f"{repository.split('/')[1]}-{release_version}.tar.gz",
'browser_download_url': release_tarball,
'content_type': 'application/gzip'
})
return assets
async def get_latest_releases(self, repositories: list) -> dict:
"""Runs get_release() asynchronously for each repository.
Args:
repositories (list): List of repositories in Github's standard username/repository notation
Returns:
dict: A dictionary containing assets from each repository
"""
releases: dict[str, list] = {}
releases['tools'] = []
results: list = await asyncio.gather(*[self.__get_release(repository) for repository in repositories])
for result in results:
for asset in result:
releases['tools'].append(asset)
return releases
async def __get_patches_json(self) -> dict:
# Get revanced-patches repository's README.md.
#
# Returns:
# dict: JSON content
#
response = await self.httpx_client.get(f"https://api.github.com/repos/revanced/revanced-patches/contents/patches.json")
content = orjson.loads(b64decode(response.json()['content']).decode('utf-8'))
return content
async def get_patches_json(self) -> dict:
"""Get patches.json from revanced-patches repository.
Returns:
dict: Patches available for a given app
"""
patches: dict = await self.__get_patches_json()
return patches
async def __get_contributors(self, repository: str) -> list:
# Get contributors from a given repository.
#
# Args:
# repository (str): Github's standard username/repository notation
#
# Returns:
# list: a list of dictionaries containing the repository's contributors
keep: set = {'login', 'avatar_url', 'html_url'}
response = await self.httpx_client.get(f"https://api.github.com/repos/{repository}/contributors")
contributors: list = [keyfilter(lambda k: k in keep, contributor) for contributor in response.json()]
return contributors
async def get_contributors(self, repositories: list) -> dict:
"""Runs get_contributors() asynchronously for each repository.
Args:
repositories (list): List of repositories in Github's standard username/repository notation
Returns:
dict: A dictionary containing the contributors from each repository
"""
contributors: dict[str, list]
contributors = {}
contributors['repositories'] = []
revanced_repositories = [repository for repository in repositories if 'revanced' in repository]
results: list[dict] = await asyncio.gather(*[self.__get_contributors(repository) for repository in revanced_repositories])
for key, value in zip(revanced_repositories, results):
data = { 'name': key, 'contributors': value }
contributors['repositories'].append(data)
return contributors
async def get_commits(self, org: str, repository: str, path: str) -> dict:
"""Get commit history from a given repository.
Args:
org (str): Username of the organization | valid values: revanced or vancedapp
repository (str): Repository name
path (str): Path to the file
per_page (int): Number of commits to return
since (str): ISO 8601 timestamp
Raises:
Exception: Raise a generic exception if the organization is not revanced or vancedapp
Returns:
dict: a dictionary containing the repository's latest commits
"""
payload: dict = {}
payload["repository"] = f"{org}/{repository}"
payload["path"] = path
payload["commits"] = []
if org == 'revanced' or org == 'vancedapp':
_releases = await self.httpx_client.get(
f"https://api.github.com/repos/{org}/{repository}/releases?per_page=2"
)
releases = _releases.json()
since = releases[1]['created_at']
until = releases[0]['created_at']
_response = await self.httpx_client.get(
f"https://api.github.com/repos/{org}/{repository}/commits?path={path}&since={since}&until={until}"
)
response = _response.json()
for commit in response:
data: dict[str, str] = {}
data["sha"] = commit["sha"]
data["author"] = commit["commit"]["author"]["name"]
data["message"] = commit["commit"]["message"]
data["html_url"] = commit["html_url"]
payload['commits'].append(data)
return payload
else:
raise Exception("Invalid organization.")

View File