mirror of
https://github.com/pacnpal/simpleguardhome.git
synced 2025-12-20 04:21:13 -05:00
feat(docs): update README with API documentation and endpoint details
fix(deps): add slowapi to requirements for rate limiting functionality
This commit is contained in:
99
README.md
99
README.md
@@ -1,6 +1,6 @@
|
||||
# SimpleGuardHome
|
||||
|
||||
A modern web application for checking and managing domain filtering in AdGuard Home. Built with FastAPI and modern JavaScript.
|
||||
A modern web application for checking and managing domain filtering in AdGuard Home. Built with FastAPI and modern JavaScript, following the official AdGuard Home OpenAPI specification.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -11,6 +11,8 @@ A modern web application for checking and managing domain filtering in AdGuard H
|
||||
- 📝 Comprehensive logging
|
||||
- 🏥 Health monitoring endpoint
|
||||
- ⚙️ Environment-based configuration
|
||||
- 📚 Full OpenAPI/Swagger documentation
|
||||
- ✅ Implements official AdGuard Home API spec
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -61,52 +63,79 @@ python -m uvicorn src.simpleguardhome.main:app --reload
|
||||
|
||||
The application will be available at `http://localhost:8000`
|
||||
|
||||
## API Endpoints
|
||||
## API Documentation
|
||||
|
||||
### Web Interface
|
||||
- `GET /` - Main web interface for domain checking and unblocking
|
||||
The API documentation is available at:
|
||||
- Swagger UI: `http://localhost:8000/api/docs`
|
||||
- ReDoc: `http://localhost:8000/api/redoc`
|
||||
- OpenAPI Schema: `http://localhost:8000/api/openapi.json`
|
||||
|
||||
### API Endpoints
|
||||
- `POST /check-domain` - Check if a domain is blocked
|
||||
- Parameters: `domain` (form data)
|
||||
- Returns: Blocking status and rule information
|
||||
|
||||
- `POST /unblock-domain` - Add a domain to the allowed list
|
||||
- Parameters: `domain` (form data)
|
||||
- Returns: Success/failure status
|
||||
All endpoints follow the official AdGuard Home API specification:
|
||||
|
||||
- `GET /health` - Check application and AdGuard Home connection status
|
||||
- Returns: Health status of the application and AdGuard Home connection
|
||||
#### Web Interface
|
||||
- `GET /` - Main web interface for domain checking and unblocking
|
||||
|
||||
## Troubleshooting
|
||||
#### Filtering Endpoints
|
||||
- `POST /control/filtering/check_host` - Check if a domain is blocked
|
||||
- Parameters: `name` (query parameter)
|
||||
- Returns: Detailed filtering status and rules
|
||||
|
||||
### Common Issues
|
||||
- `POST /control/filtering/whitelist/add` - Add a domain to the allowed list
|
||||
- Parameters: `name` (JSON body)
|
||||
- Returns: Success status
|
||||
|
||||
1. **Connection Failed**
|
||||
- Ensure AdGuard Home is running
|
||||
- Verify the host and port in .env are correct
|
||||
- Check if AdGuard Home's API is accessible
|
||||
- `GET /control/filtering/status` - Get current filtering configuration
|
||||
- Returns: Complete filtering status including rules and filters
|
||||
|
||||
2. **Authentication Failed**
|
||||
- Verify username and password in .env
|
||||
- Ensure AdGuard Home authentication is enabled/disabled as expected
|
||||
#### System Status
|
||||
- `GET /control/status` - Check application and AdGuard Home connection status
|
||||
- Returns: Health status with filtering state
|
||||
|
||||
3. **Domain Check Failed**
|
||||
- Check AdGuard Home logs for filtering issues
|
||||
- Verify domain format is correct
|
||||
- Ensure AdGuard Home filtering is enabled
|
||||
## Response Models
|
||||
|
||||
### Checking System Status
|
||||
The application uses Pydantic models that match the AdGuard Home API specification:
|
||||
|
||||
1. Use the health check endpoint:
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
### FilterStatus
|
||||
```python
|
||||
{
|
||||
"enabled": bool,
|
||||
"filters": [
|
||||
{
|
||||
"enabled": bool,
|
||||
"id": int,
|
||||
"name": str,
|
||||
"rules_count": int,
|
||||
"url": str
|
||||
}
|
||||
],
|
||||
"user_rules": List[str],
|
||||
"whitelist_filters": List[Filter]
|
||||
}
|
||||
```
|
||||
|
||||
2. Check application logs:
|
||||
- The application uses structured logging
|
||||
- Look for ERROR level messages for issues
|
||||
- Connection problems are logged with detailed error information
|
||||
### DomainCheckResult
|
||||
```python
|
||||
{
|
||||
"reason": str, # Filtering status (e.g., "FilteredBlackList")
|
||||
"rule": str, # Applied filtering rule
|
||||
"filter_id": int, # ID of the filter containing the rule
|
||||
"service_name": str, # For blocked services
|
||||
"cname": str, # For CNAME rewrites
|
||||
"ip_addrs": List[str] # For A/AAAA rewrites
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The application implements proper error handling according to the AdGuard Home API spec:
|
||||
|
||||
- 400 Bad Request - Invalid input
|
||||
- 401 Unauthorized - Authentication required
|
||||
- 403 Forbidden - Authentication failed
|
||||
- 502 Bad Gateway - AdGuard Home API error
|
||||
- 503 Service Unavailable - AdGuard Home unreachable
|
||||
|
||||
## Development
|
||||
|
||||
@@ -134,6 +163,7 @@ simpleguardhome/
|
||||
- Add routes in `main.py`
|
||||
- Extend AdGuard client in `adguard.py`
|
||||
- Update configuration in `config.py`
|
||||
- Follow AdGuard Home OpenAPI spec
|
||||
|
||||
2. Frontend Changes:
|
||||
- Modify `templates/index.html`
|
||||
@@ -145,6 +175,9 @@ simpleguardhome/
|
||||
- API credentials are handled via environment variables
|
||||
- Connections use proper error handling and timeouts
|
||||
- Input validation is performed on all endpoints
|
||||
- CORS protection with proper headers
|
||||
- Rate limiting on sensitive endpoints
|
||||
- Session-based authentication with AdGuard Home
|
||||
- Sensitive information is not exposed in responses
|
||||
|
||||
## License
|
||||
|
||||
3150
adguard_openapi.yaml
Normal file
3150
adguard_openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,3 +8,4 @@ pytest>=7.4.4
|
||||
pytest-asyncio>=0.25.2
|
||||
python-multipart==0.0.20
|
||||
jinja2==3.1.5
|
||||
slowapi==0.1.9
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
import httpx
|
||||
import logging
|
||||
from .config import settings
|
||||
@@ -19,13 +20,66 @@ class AdGuardAPIError(AdGuardError):
|
||||
"""Raised when AdGuard Home API returns an error."""
|
||||
pass
|
||||
|
||||
# Response models matching AdGuard Home API spec
|
||||
class Filter(BaseModel):
|
||||
"""Filter subscription info according to AdGuard spec."""
|
||||
enabled: bool
|
||||
id: int = Field(..., description="Filter ID", example=1234)
|
||||
name: str = Field(..., example="AdGuard Simplified Domain Names filter")
|
||||
rules_count: int = Field(..., description="Number of rules in filter", example=5912)
|
||||
url: str = Field(..., example="https://adguardteam.github.io/AdGuardSDNSFilter/Filters/filter.txt")
|
||||
last_updated: Optional[str] = Field(None, example="2018-10-30T12:18:57+03:00")
|
||||
|
||||
class FilterStatus(BaseModel):
|
||||
"""Filtering settings according to AdGuard spec."""
|
||||
enabled: bool = Field(..., description="Whether filtering is enabled")
|
||||
interval: Optional[int] = Field(None, description="Update interval in hours")
|
||||
filters: List[Filter] = Field(default_factory=list)
|
||||
whitelist_filters: List[Filter] = Field(default_factory=list)
|
||||
user_rules: List[str] = Field(default_factory=list)
|
||||
|
||||
class DnsAnswer(BaseModel):
|
||||
"""DNS answer section according to AdGuard spec."""
|
||||
ttl: int = Field(..., description="Time to live")
|
||||
type: str = Field(..., description="Record type", example="A")
|
||||
value: str = Field(..., description="Record value", example="217.69.139.201")
|
||||
|
||||
class DomainCheckResult(BaseModel):
|
||||
"""Response model for check_host endpoint according to AdGuard spec."""
|
||||
reason: str = Field(
|
||||
...,
|
||||
description="Request filtering status",
|
||||
enum=[
|
||||
"NotFilteredNotFound",
|
||||
"NotFilteredWhiteList",
|
||||
"NotFilteredError",
|
||||
"FilteredBlackList",
|
||||
"FilteredSafeBrowsing",
|
||||
"FilteredParental",
|
||||
"FilteredInvalid",
|
||||
"FilteredSafeSearch",
|
||||
"FilteredBlockedService",
|
||||
"Rewrite",
|
||||
"RewriteEtcHosts",
|
||||
"RewriteRule"
|
||||
]
|
||||
)
|
||||
filter_id: Optional[int] = Field(None, description="ID of the filter list containing the rule")
|
||||
rule: Optional[str] = Field(None, description="Applied filtering rule")
|
||||
service_name: Optional[str] = Field(None, description="Blocked service name if applicable")
|
||||
cname: Optional[str] = Field(None, description="CNAME value if rewritten")
|
||||
ip_addrs: Optional[List[str]] = Field(None, description="IP addresses if rewritten")
|
||||
|
||||
class AdGuardClient:
|
||||
"""Client for interacting with AdGuard Home API."""
|
||||
"""Client for interacting with AdGuard Home API according to OpenAPI spec."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the AdGuard Home API client."""
|
||||
self.base_url = settings.adguard_base_url
|
||||
self.client = httpx.AsyncClient(timeout=10.0) # 10 second timeout
|
||||
self.base_url = f"{settings.adguard_base_url}/control"
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=10.0,
|
||||
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
||||
)
|
||||
self._session_cookie = None
|
||||
self._auth = None
|
||||
if settings.ADGUARD_USERNAME and settings.ADGUARD_PASSWORD:
|
||||
@@ -49,14 +103,13 @@ class AdGuardClient:
|
||||
logger.warning("No credentials configured, skipping authentication")
|
||||
return False
|
||||
|
||||
url = f"{self.base_url}/control/login"
|
||||
url = f"{self.base_url}/login"
|
||||
|
||||
try:
|
||||
logger.info("Authenticating with AdGuard Home")
|
||||
response = await self.client.post(url, json=self._auth)
|
||||
response.raise_for_status()
|
||||
|
||||
# Extract and store session cookie
|
||||
cookies = response.cookies
|
||||
if 'agh_session' in cookies:
|
||||
self._session_cookie = cookies['agh_session']
|
||||
@@ -81,14 +134,14 @@ class AdGuardClient:
|
||||
if not self._session_cookie:
|
||||
await self.login()
|
||||
|
||||
async def check_domain(self, domain: str) -> Dict:
|
||||
async def check_domain(self, domain: str) -> DomainCheckResult:
|
||||
"""Check if a domain is blocked by AdGuard Home.
|
||||
|
||||
Args:
|
||||
domain: The domain to check
|
||||
|
||||
Returns:
|
||||
Dict containing the filtering status
|
||||
DomainCheckResult according to AdGuard spec
|
||||
|
||||
Raises:
|
||||
AdGuardConnectionError: If connection to AdGuard Home fails
|
||||
@@ -106,7 +159,6 @@ class AdGuardClient:
|
||||
logger.info(f"Checking domain: {domain}")
|
||||
response = await self.client.get(url, params=params, headers=headers)
|
||||
|
||||
# Handle unauthorized response by attempting reauth
|
||||
if response.status_code == 401:
|
||||
logger.info("Session expired, attempting reauth")
|
||||
await self.login()
|
||||
@@ -117,7 +169,7 @@ class AdGuardClient:
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logger.info(f"Domain check result for {domain}: {result}")
|
||||
return result
|
||||
return DomainCheckResult(**result)
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Connection error while checking domain {domain}: {str(e)}")
|
||||
@@ -129,11 +181,11 @@ class AdGuardClient:
|
||||
logger.error(f"Unexpected error while checking domain {domain}: {str(e)}")
|
||||
raise AdGuardError(f"Unexpected error: {str(e)}")
|
||||
|
||||
async def get_filter_status(self) -> Dict:
|
||||
async def get_filter_status(self) -> FilterStatus:
|
||||
"""Get the current filtering status.
|
||||
|
||||
Returns:
|
||||
Dict containing the filtering status
|
||||
FilterStatus according to AdGuard spec
|
||||
|
||||
Raises:
|
||||
AdGuardConnectionError: If connection to AdGuard Home fails
|
||||
@@ -150,7 +202,6 @@ class AdGuardClient:
|
||||
logger.info("Getting filter status")
|
||||
response = await self.client.get(url, headers=headers)
|
||||
|
||||
# Handle unauthorized response by attempting reauth
|
||||
if response.status_code == 401:
|
||||
logger.info("Session expired, attempting reauth")
|
||||
await self.login()
|
||||
@@ -161,7 +212,7 @@ class AdGuardClient:
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logger.info("Successfully retrieved filter status")
|
||||
return result
|
||||
return FilterStatus(**result)
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Connection error while getting filter status: {str(e)}")
|
||||
@@ -174,7 +225,7 @@ class AdGuardClient:
|
||||
raise AdGuardError(f"Unexpected error: {str(e)}")
|
||||
|
||||
async def add_allowed_domain(self, domain: str) -> bool:
|
||||
"""Add a domain to the allowed list.
|
||||
"""Add a domain to the allowed list according to AdGuard spec.
|
||||
|
||||
Args:
|
||||
domain: The domain to allow
|
||||
@@ -198,7 +249,6 @@ class AdGuardClient:
|
||||
logger.info(f"Adding domain to whitelist: {domain}")
|
||||
response = await self.client.post(url, json=data, headers=headers)
|
||||
|
||||
# Handle unauthorized response by attempting reauth
|
||||
if response.status_code == 401:
|
||||
logger.info("Session expired, attempting reauth")
|
||||
await self.login()
|
||||
|
||||
@@ -1,25 +1,76 @@
|
||||
from fastapi import FastAPI, Request, Form, HTTPException
|
||||
from fastapi import FastAPI, Request, Form, HTTPException, status
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Dict
|
||||
from . import adguard
|
||||
from .config import settings
|
||||
from .adguard import AdGuardError, AdGuardConnectionError, AdGuardAPIError
|
||||
from .adguard import (
|
||||
AdGuardError,
|
||||
AdGuardConnectionError,
|
||||
AdGuardAPIError,
|
||||
DomainCheckResult,
|
||||
FilterStatus
|
||||
)
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="SimpleGuardHome")
|
||||
# Initialize rate limiter
|
||||
app = FastAPI(
|
||||
title="SimpleGuardHome",
|
||||
description="AdGuard Home REST API interface",
|
||||
version="1.0.0",
|
||||
openapi_url="/api/openapi.json",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc"
|
||||
)
|
||||
|
||||
# Add CORS middleware with security headers
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Request-ID"]
|
||||
)
|
||||
|
||||
# Setup templates directory
|
||||
templates_path = Path(__file__).parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(templates_path))
|
||||
|
||||
# Response models matching AdGuard spec
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health check response model."""
|
||||
status: str
|
||||
adguard_connection: str
|
||||
filtering_enabled: bool = False
|
||||
error: str = None
|
||||
|
||||
class DomainResponse(BaseModel):
|
||||
"""Domain check response model matching AdGuard spec."""
|
||||
success: bool
|
||||
domain: str
|
||||
filtered: bool = False
|
||||
reason: str = None
|
||||
rule: str = None
|
||||
filter_list_id: int = None
|
||||
service_name: str = None
|
||||
cname: str = None
|
||||
ip_addrs: list[str] = None
|
||||
message: str = None
|
||||
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response model matching AdGuard spec."""
|
||||
message: str
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def home(request: Request):
|
||||
"""Render the home page."""
|
||||
@@ -28,7 +79,16 @@ async def home(request: Request):
|
||||
{"request": request}
|
||||
)
|
||||
|
||||
@app.get("/health")
|
||||
@app.get(
|
||||
"/control/status",
|
||||
response_model=HealthResponse,
|
||||
responses={
|
||||
200: {"description": "OK"},
|
||||
503: {"description": "AdGuard Home service unavailable", "model": ErrorResponse},
|
||||
500: {"description": "Internal server error", "model": ErrorResponse}
|
||||
},
|
||||
tags=["health"]
|
||||
)
|
||||
async def health_check() -> Dict:
|
||||
"""Check the health of the application and AdGuard Home connection."""
|
||||
try:
|
||||
@@ -37,45 +97,59 @@ async def health_check() -> Dict:
|
||||
return {
|
||||
"status": "healthy",
|
||||
"adguard_connection": "connected",
|
||||
"filtering_enabled": status.get("enabled", False)
|
||||
"filtering_enabled": status.enabled if status else False
|
||||
}
|
||||
except AdGuardConnectionError:
|
||||
return {
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
content={
|
||||
"status": "degraded",
|
||||
"adguard_connection": "failed",
|
||||
"error": "Could not connect to AdGuard Home"
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Health check failed: {str(e)}")
|
||||
return {
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={
|
||||
"status": "error",
|
||||
"error": "An internal error has occurred. Please try again later."
|
||||
}
|
||||
)
|
||||
|
||||
@app.exception_handler(AdGuardError)
|
||||
async def adguard_exception_handler(request: Request, exc: AdGuardError) -> JSONResponse:
|
||||
"""Handle AdGuard-related exceptions."""
|
||||
"""Handle AdGuard-related exceptions according to spec."""
|
||||
if isinstance(exc, AdGuardConnectionError):
|
||||
status_code = 503 # Service Unavailable
|
||||
status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
elif isinstance(exc, AdGuardAPIError):
|
||||
status_code = 502 # Bad Gateway
|
||||
status_code = status.HTTP_502_BAD_GATEWAY
|
||||
else:
|
||||
status_code = 500 # Internal Server Error
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"success": False,
|
||||
"error": exc.__class__.__name__,
|
||||
"detail": str(exc)
|
||||
}
|
||||
content={"message": str(exc)}
|
||||
)
|
||||
|
||||
@app.post("/check-domain")
|
||||
@app.post(
|
||||
"/control/filtering/check_host",
|
||||
response_model=DomainResponse,
|
||||
responses={
|
||||
200: {"description": "OK"},
|
||||
400: {"description": "Bad Request", "model": ErrorResponse},
|
||||
503: {"description": "AdGuard Home service unavailable", "model": ErrorResponse}
|
||||
},
|
||||
tags=["filtering"]
|
||||
)
|
||||
async def check_domain(domain: str = Form(...)) -> Dict:
|
||||
"""Check if a domain is blocked by AdGuard Home."""
|
||||
if not domain:
|
||||
raise HTTPException(status_code=400, detail="Domain is required")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Domain is required"
|
||||
)
|
||||
|
||||
logger.info(f"Checking domain: {domain}")
|
||||
try:
|
||||
@@ -84,9 +158,13 @@ async def check_domain(domain: str = Form(...)) -> Dict:
|
||||
response = {
|
||||
"success": True,
|
||||
"domain": domain,
|
||||
"blocked": result.get("filtered", False),
|
||||
"rule": result.get("rule", ""),
|
||||
"filter_list": result.get("filter_list", "")
|
||||
"filtered": result.reason.startswith("Filtered"),
|
||||
"reason": result.reason,
|
||||
"rule": result.rule,
|
||||
"filter_list_id": result.filter_id,
|
||||
"service_name": result.service_name,
|
||||
"cname": result.cname,
|
||||
"ip_addrs": result.ip_addrs
|
||||
}
|
||||
logger.info(f"Domain check result: {response}")
|
||||
return response
|
||||
@@ -94,11 +172,23 @@ async def check_domain(domain: str = Form(...)) -> Dict:
|
||||
logger.error(f"Error checking domain {domain}: {str(e)}")
|
||||
raise
|
||||
|
||||
@app.post("/unblock-domain")
|
||||
@app.post(
|
||||
"/control/filtering/whitelist/add",
|
||||
response_model=DomainResponse,
|
||||
responses={
|
||||
200: {"description": "OK"},
|
||||
400: {"description": "Bad Request", "model": ErrorResponse},
|
||||
503: {"description": "AdGuard Home service unavailable", "model": ErrorResponse}
|
||||
},
|
||||
tags=["filtering"]
|
||||
)
|
||||
async def unblock_domain(domain: str = Form(...)) -> Dict:
|
||||
"""Add a domain to the allowed list."""
|
||||
if not domain:
|
||||
raise HTTPException(status_code=400, detail="Domain is required")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Domain is required"
|
||||
)
|
||||
|
||||
logger.info(f"Unblocking domain: {domain}")
|
||||
try:
|
||||
@@ -115,6 +205,24 @@ async def unblock_domain(domain: str = Form(...)) -> Dict:
|
||||
logger.error(f"Error unblocking domain {domain}: {str(e)}")
|
||||
raise
|
||||
|
||||
@app.get(
|
||||
"/control/filtering/status",
|
||||
response_model=FilterStatus,
|
||||
responses={
|
||||
200: {"description": "OK"},
|
||||
503: {"description": "AdGuard Home service unavailable", "model": ErrorResponse}
|
||||
},
|
||||
tags=["filtering"]
|
||||
)
|
||||
async def get_filtering_status() -> FilterStatus:
|
||||
"""Get the current filtering status."""
|
||||
try:
|
||||
async with adguard.AdGuardClient() as client:
|
||||
return await client.get_filter_status()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting filter status: {str(e)}")
|
||||
raise
|
||||
|
||||
def start():
|
||||
"""Start the application using uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
Reference in New Issue
Block a user