diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..270bb52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3-slim + +ARG GITHUB_TOKEN +ENV GITHUB_TOKEN $GITHUB_TOKEN + +WORKDIR /usr/src/app + +COPY . . + +RUN apt update && \ + apt-get install gcc -y && \ + pip install --no-cache-dir -r requirements.txt + +CMD [ "python3", "./main.py" ] \ No newline at end of file diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..deb27af --- /dev/null +++ b/config.toml @@ -0,0 +1,42 @@ +[docs] + +title = "ReVanced Releases API" +description = """ +This website provides a JSON API for ReVanced Releases 🚀 + +Changelogs are not included but can be found on the [ReVanced Repositories](https://github.com/revanced/). + +The team also have a [Discord Server](https://revanced.app/discord) if you need help. + +### API Endpoints + +* [apps](/apps) - Returns all currently patchable apps +* [tools](/tools) - Returns the latest version of all ReVanced tools and Vanced MicroG +* [patches](/patches) - Returns the latest version of all ReVanced patches + +There is no cache on this API because we trust our fellow developers to implement a cache on their end. + +So please be kind and don't blow up the API with requests or we will have to block your IP address. + +Godspeed 💀 + +""" +version = "0.1 alpha" + +[license] + +name = "AGPL-3.0" +url = "https://www.gnu.org/licenses/agpl-3.0.en.html" + +[uvicorn] + +host = "0.0.0.0" +port = 8000 + +[slowapi] + +limit = "15/minute" + +[app] + +repositories = ["TeamVanced/VancedMicroG", "revanced/revanced-cli", "revanced/revanced-patches", "revanced/revanced-integrations"] \ No newline at end of file diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000..da71194 Binary files /dev/null and b/dump.rdb differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..65f5464 --- /dev/null +++ b/main.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +import toml +import uvicorn +from fastapi import FastAPI, Request, Response +from modules.Releases import Releases +from fastapi.responses import RedirectResponse +import modules.ResponseModels as ResponseModels +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + +"""Get latest ReVanced releases from GitHub API.""" + +# Load config + +config = toml.load("config.toml") + +# Create releases instance + +releases = Releases() + +# 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'] + }) + +# 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) + +# Routes + +@app.get("/", response_class=RedirectResponse, status_code=301) +@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.LatestTools) +@limiter.limit(config['slowapi']['limit']) +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('/apps', response_model=ResponseModels.SupportedApps) +@limiter.limit(config['slowapi']['limit']) +async def apps(request: Request, response: Response) -> dict: + """Get patchable apps. + + Returns: + json: list of supported apps + """ + return await releases.get_patchable_apps() + +@app.get('/patches', response_model=ResponseModels.LatestPatches) +@limiter.limit(config['slowapi']['limit']) +async def patches(request: Request, response: Response) -> dict: + """Get latest patches. + + Returns: + json: list of latest patches + """ + return await releases.get_latest_patches() + +# Run app +if __name__ == '__main__': + uvicorn.run(app, host=config['uvicorn']['host'], port=config['uvicorn']['port']) \ No newline at end of file diff --git a/modules/Releases.py b/modules/Releases.py new file mode 100644 index 0000000..262446f --- /dev/null +++ b/modules/Releases.py @@ -0,0 +1,116 @@ +import os +import pycmarkgfm +import httpx_cache +from typing import Dict, List +from base64 import b64decode +from bs4 import BeautifulSoup + +class Releases: + + """Implements the methods required to get the latest releases and patches from revanced repositories.""" + + headers = {'Accept': "application/vnd.github+json", + 'Authorization': "token " + os.environ['GITHUB_TOKEN'] + } + + async def get_release(self, client: httpx_cache.AsyncClient, repository: str) -> list: + """Get assets from latest release in a given repository. + + Args: + client (httpx_cache.AsyncClient): httpx_cache reusable async client + repository (str): Github's standard username/repository notation + + Returns: + dict: dictionary of filename and download url + """ + assets = [] + response = await client.get(f"https://api.github.com/repos/{repository}/releases/latest") + + release_assets = response.json()['assets'] + + for asset in release_assets: + assets.append({ 'repository': repository, + 'name': asset['name'], + 'size': asset['size'], + 'browser_download_url': asset['browser_download_url'], + 'content_type': asset['content_type'] + }) + + return assets + + async def get_latest_releases(self, repositories: list) -> dict: + """Runs get_release() in parallel 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'] = [] + + async with httpx_cache.AsyncClient(headers=self.headers, http2=True) as client: + for repository in repositories: + files = await self.get_release(client, repository) + for file in files: + releases['tools'].append(file) + + return releases + + async def get_patches_readme(self, client: httpx_cache.AsyncClient) -> str: + """Get revanced-patches repository's README.md. + + Returns: + str: README.md content + """ + response = await client.get(f"https://api.github.com/repos/revanced/revanced-patches/contents/README.md") + + return b64decode(response.json()['content']).decode('utf-8') + + async def get_patchable_apps(self) -> dict: + """Get patchable apps from revanced-patches repository. + + Returns: + dict: Apps available for patching + """ + packages: Dict[str, List] = {} + packages['apps'] = [] + + async with httpx_cache.AsyncClient(headers=self.headers, http2=True) as client: + content = await self.get_patches_readme(client) + + for line in content.splitlines(): + if line.startswith(u'###'): + packages['apps'].append(line.split('`')[1]) + + return packages + + async def get_latest_patches(self) -> dict: + """Get latest patches from revanced-patches repository. + + Returns: + dict: Patches available for a given app + """ + patches: Dict[str, List] = {} + patches['patches'] = [] + + async with httpx_cache.AsyncClient(headers=self.headers, http2=True) as client: + content = await self.get_patches_readme(client) + + html = pycmarkgfm.gfm_to_html(content) + soup = BeautifulSoup(html, 'lxml') + + headings = soup.find_all('h3') + + for heading in headings: + app_name = heading.text.split(' ')[1] + for patch in heading.find_next_sibling().find_all('tr')[1:]: + app_patches = patch.find_all('td') + patches['patches'].append({"target_app": app_name, + "patch_name" : app_patches[0].text, + "description": app_patches[1].text, + "target_version": app_patches[2].text + }) + + return patches diff --git a/modules/ResponseFields.py b/modules/ResponseFields.py new file mode 100644 index 0000000..2c8f03d --- /dev/null +++ b/modules/ResponseFields.py @@ -0,0 +1,28 @@ +from enum import Enum + +class LatestToolsFields(str, Enum): + """Implements the fields for the /tools endpoint. + + Args: + str (str): String + Enum (Enum): Enum from pydantic + """ + + repository = 'repository' + name = 'name' + size = 'size' + browser_download_url = 'browser_download_url' + content_type = 'content_type' + +class LatestPatchesFields(str, Enum): + """Implements the fields for the /patches endpoint. + + Args: + str (str): String + Enum (Enum): Enum from pydantic + """ + + target_app = "target_app" + patch_name = "patch_name" + description = "description" + target_version = "target_version" \ No newline at end of file diff --git a/modules/ResponseModels.py b/modules/ResponseModels.py new file mode 100644 index 0000000..f4c8266 --- /dev/null +++ b/modules/ResponseModels.py @@ -0,0 +1,69 @@ +import modules.ResponseFields as ResponseFields +from typing import Dict, Union, List +from pydantic import BaseModel, create_model + +"""Implements pydantic models and model generator for the API's responses.""" + +class ModelGenerator(): + + """Generates a pydantic model from a dictionary.""" + + def __make_model(self, v, name): + # Parses a dictionary and creates a pydantic model from it. + # + # Args: + # v: key value + # name: key name + # + # Returns: + # pydantic.BaseModel: Generated pydantic model + + if type(v) is dict: + return create_model(name, **{k: self.__make_model(v, k) for k, v in v.items()}), ... + return None, v + + def generate(self, v: Dict, name: str): + + """Returns a pydantic model from a dictionary. + + Args: + v (Dict): JSON dictionary + name (str): Model name + + Returns: + pydantic.BaseModel: Generated pydantic model + """ + return self.__make_model(v, name)[0] + +class SupportedApps(BaseModel): + """Implements the JSON response model for the /apps endpoint. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + apps: List[str] + +class LatestTools(BaseModel): + """Implements the JSON response model for the /tools endpoint. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + tools: List[ Dict[ ResponseFields.LatestToolsFields, str ] ] + + class Config: + use_enum_values = True + +class LatestPatches(BaseModel): + """Implements the JSON response model for the /patches endpoint. + + Args: + BaseModel (pydantic.BaseModel): BaseModel from pydantic + """ + + patches: List[ Dict[ ResponseFields.LatestPatchesFields, str ] ] + + class Config: + use_enum_values = True \ No newline at end of file diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..15c788c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.81.0 +uvicorn[standard]>=0.18.3 +httpx[http2]>=0.23.0 +httpx-cache>=0.6.0 +pycmarkgfm>=1.1.0 +beautifulsoup4>=4.11.1 +lxml>=4.9.1 +toml>=0.10.2 +slowapi>=0.1.6 \ No newline at end of file