mirror of
https://github.com/revanced/revanced-releases-api.git
synced 2025-06-12 12:47:37 +02:00
* refactor: import routers from old branch * refactor: import InternalCache removal * refactor: move routes into dedicated routers * fix: fixes entrypoint * refactor: add documentation and bump libs
This commit is contained in:
102
app/controllers/Announcements.py
Normal file
102
app/controllers/Announcements.py
Normal 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
7
app/controllers/Auth.py
Normal 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
351
app/controllers/Clients.py
Normal 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
189
app/controllers/Releases.py
Normal 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.")
|
0
app/controllers/__init__.py
Normal file
0
app/controllers/__init__.py
Normal file
Reference in New Issue
Block a user