mirror of
https://github.com/revanced/revanced-releases-api.git
synced 2025-05-04 08:04:24 +02:00
* feat: add cdn mirror endpoints * refactor: change API version in docs * docs: fix titles on API docs page
This commit is contained in:
parent
24d78709fc
commit
4cb02c55ea
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
|
||||||
|
|
||||||
|
|
@ -31,6 +31,7 @@ from app.routers import auth
|
|||||||
from app.routers import tools
|
from app.routers import tools
|
||||||
from app.routers import clients
|
from app.routers import clients
|
||||||
from app.routers import patches
|
from app.routers import patches
|
||||||
|
from app.routers import mirrors
|
||||||
from app.routers import changelogs
|
from app.routers import changelogs
|
||||||
from app.routers import contributors
|
from app.routers import contributors
|
||||||
from app.routers import announcement
|
from app.routers import announcement
|
||||||
@ -74,6 +75,7 @@ app.include_router(changelogs.router)
|
|||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(clients.router)
|
app.include_router(clients.router)
|
||||||
app.include_router(announcement.router)
|
app.include_router(announcement.router)
|
||||||
|
app.include_router(mirrors.router)
|
||||||
app.include_router(ping.router)
|
app.include_router(ping.router)
|
||||||
|
|
||||||
# Setup cache
|
# Setup cache
|
||||||
|
@ -48,4 +48,24 @@ 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
|
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
|
||||||
|
}
|
||||||
|
)
|
@ -1,5 +1,3 @@
|
|||||||
import sys
|
|
||||||
import logging
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from redis import RedisError
|
from redis import RedisError
|
||||||
from argon2.exceptions import VerifyMismatchError
|
from argon2.exceptions import VerifyMismatchError
|
||||||
@ -66,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:
|
||||||
"""Setup logging for uvicorn and FastAPI."""
|
"""Logs internal cache operations
|
||||||
|
|
||||||
# intercept everything at the root logger
|
Args:
|
||||||
logging.root.handlers = [InterceptHandler()]
|
operation (str): Operation name
|
||||||
logging.root.setLevel(LOG_LEVEL)
|
key (str): Key used in the operation
|
||||||
|
"""
|
||||||
# remove every other logger's handlers
|
if type(result) is RedisError:
|
||||||
# and propagate to root logger
|
logger.error(f"[User] REDIS {operation} - Failed with error: {result}")
|
||||||
for name in logging.root.manager.loggerDict.keys():
|
else:
|
||||||
logging.getLogger(name).handlers = []
|
logger.info(f"[User] REDIS {operation} {key} - OK")
|
||||||
logging.getLogger(name).propagate = True
|
|
||||||
|
|
||||||
# configure loguru
|
|
||||||
logger.configure(handlers=[{"sink": sys.stdout, "serialize": JSON_LOGS}])
|
|
||||||
|
13
config.toml
13
config.toml
@ -2,28 +2,28 @@
|
|||||||
|
|
||||||
title = "ReVanced Releases API"
|
title = "ReVanced Releases API"
|
||||||
description = """
|
description = """
|
||||||
# The official JSON API for ReVanced Releases 🚀
|
## The official JSON API for ReVanced Releases 🚀
|
||||||
|
|
||||||
## Links
|
### Links
|
||||||
|
|
||||||
- [Changelogs](https://github.com/revanced/)
|
- [Changelogs](https://github.com/revanced/)
|
||||||
- [Official links to ReVanced](https://revanced.app)
|
- [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. Breaking changes are to be expected
|
1. Breaking changes are to be expected
|
||||||
2. Client side caching is adviced to avoid unnecessary requests
|
2. Client side caching is adviced to avoid unnecessary requests
|
||||||
3. Abuse of the API will result in IP blocks
|
3. Abuse of the API will result in IP blocks
|
||||||
|
|
||||||
"""
|
"""
|
||||||
version = "0.8.5 RC"
|
version = "0.9 RC2"
|
||||||
|
|
||||||
[license]
|
[license]
|
||||||
|
|
||||||
@ -51,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"]
|
||||||
|
Loading…
x
Reference in New Issue
Block a user