170 lines
6.7 KiB
Python
170 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import FastAPI, HTTPException, Query, Request
|
|
from fastapi.responses import Response
|
|
|
|
from app.config import get_settings
|
|
from app.models import RuleConfig, SourceConfig
|
|
from app.services.loader import load_app_config
|
|
from app.services.profiles import build_bundle_profile, build_thin_profile, dump_yaml
|
|
from app.services.rules import load_rule_text
|
|
from app.services.subscriptions import (
|
|
build_merged_provider_document,
|
|
build_provider_document,
|
|
build_source_snapshots,
|
|
dump_provider_yaml,
|
|
get_first_quota,
|
|
)
|
|
|
|
settings = get_settings()
|
|
app = FastAPI(title=settings.app_name)
|
|
app_config = load_app_config(settings.sources_file)
|
|
PUBLIC_PREFIX = "/" + (app_config.public_path or settings.public_path).strip("/")
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict[str, str]:
|
|
return {"status": "ok"}
|
|
|
|
|
|
def _base_url(request: Request) -> str:
|
|
if settings.public_base_url:
|
|
return settings.public_base_url.rstrip("/")
|
|
return str(request.base_url).rstrip("/")
|
|
|
|
|
|
def _resolve_sources(sources: str | None) -> list[tuple[str, SourceConfig]]:
|
|
enabled = [(name, src) for name, src in app_config.sources.items() if src.enabled]
|
|
if not sources:
|
|
return enabled
|
|
|
|
names = [item.strip() for item in sources.split(",") if item.strip()]
|
|
selected: list[tuple[str, SourceConfig]] = []
|
|
seen: set[str] = set()
|
|
for name in names:
|
|
if name in seen:
|
|
continue
|
|
source = app_config.sources.get(name)
|
|
if source is None or not source.enabled:
|
|
raise HTTPException(status_code=404, detail=f"source not found or disabled: {name}")
|
|
selected.append((name, source))
|
|
seen.add(name)
|
|
if not selected:
|
|
raise HTTPException(status_code=400, detail="no sources selected")
|
|
return selected
|
|
|
|
|
|
def _rule_path(rule: RuleConfig):
|
|
if not rule.file:
|
|
raise HTTPException(status_code=404, detail="rule file not available")
|
|
path = (settings.rules_dir / rule.file).resolve()
|
|
if not path.is_file() or settings.rules_dir.resolve() not in path.parents:
|
|
raise HTTPException(status_code=404, detail="rule file missing")
|
|
return path
|
|
|
|
|
|
async def _build_quota_headers(source_items: list[tuple[str, SourceConfig]]) -> dict[str, str]:
|
|
headers: dict[str, str] = {}
|
|
quota = await get_first_quota(source_items)
|
|
if quota and not quota.is_empty():
|
|
headers["Subscription-Userinfo"] = quota.to_header_value()
|
|
return headers
|
|
|
|
|
|
def _yaml_response(content: str, request: Request, headers: dict[str, str] | None = None, filename: str | None = None) -> Response:
|
|
final_headers = {
|
|
"Content-Type": "text/yaml; charset=utf-8",
|
|
"Cache-Control": "no-store",
|
|
}
|
|
if headers:
|
|
final_headers.update(headers)
|
|
if filename:
|
|
final_headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{filename}"
|
|
body = "" if request.method == "HEAD" else content
|
|
return Response(content=body, media_type="text/yaml; charset=utf-8", headers=final_headers)
|
|
|
|
|
|
@app.api_route(PUBLIC_PREFIX + "/providers/merged.yaml", methods=["GET", "HEAD"])
|
|
async def merged_provider(request: Request, sources: str | None = Query(default=None)) -> Response:
|
|
source_items = _resolve_sources(sources)
|
|
try:
|
|
document = await build_merged_provider_document(source_items)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=502, detail=f"failed to build merged provider: {exc}") from exc
|
|
|
|
content = dump_provider_yaml(document)
|
|
headers = await _build_quota_headers(source_items)
|
|
return _yaml_response(content, request, headers=headers, filename="merged.yaml")
|
|
|
|
|
|
@app.api_route(PUBLIC_PREFIX + "/providers/{name}.yaml", methods=["GET", "HEAD"])
|
|
async def provider(name: str, request: Request) -> Response:
|
|
source = app_config.sources.get(name)
|
|
if source is None or not source.enabled:
|
|
raise HTTPException(status_code=404, detail="provider not found")
|
|
|
|
try:
|
|
document = await build_provider_document(name, source)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=502, detail=f"failed to build provider: {exc}") from exc
|
|
|
|
content = dump_provider_yaml(document)
|
|
headers = await _build_quota_headers([(name, source)])
|
|
return _yaml_response(content, request, headers=headers, filename=f"{name}.yaml")
|
|
|
|
|
|
@app.api_route(PUBLIC_PREFIX + "/rules/{name}.yaml", methods=["GET", "HEAD"])
|
|
async def rule_file(name: str, request: Request) -> Response:
|
|
rule = app_config.rules.get(name)
|
|
if rule is None:
|
|
raise HTTPException(status_code=404, detail="rule not found")
|
|
content = load_rule_text(_rule_path(rule))
|
|
return _yaml_response(content, request, filename=f"{name}.yaml")
|
|
|
|
|
|
@app.api_route(PUBLIC_PREFIX + "/clients/{client_type}.yaml", methods=["GET", "HEAD"])
|
|
async def client_profile(client_type: str, request: Request, sources: str | None = Query(default=None)) -> Response:
|
|
client = app_config.clients.get(client_type)
|
|
if client is None:
|
|
raise HTTPException(status_code=404, detail="client config not found")
|
|
|
|
source_items = _resolve_sources(sources)
|
|
content = dump_yaml(
|
|
build_thin_profile(
|
|
client_type=client_type,
|
|
app_config=app_config,
|
|
client=client,
|
|
selected_source_names=[name for name, _ in source_items],
|
|
base_url=_base_url(request),
|
|
public_path=(app_config.public_path or settings.public_path).strip("/"),
|
|
)
|
|
)
|
|
headers = {"profile-update-interval": str(client.provider_interval)}
|
|
headers.update(await _build_quota_headers(source_items))
|
|
return _yaml_response(content, request, headers=headers, filename=f"{client_type}.yaml")
|
|
|
|
|
|
@app.api_route(PUBLIC_PREFIX + "/bundle/{client_type}.yaml", methods=["GET", "HEAD"])
|
|
async def bundle_profile(client_type: str, request: Request, sources: str | None = Query(default=None)) -> Response:
|
|
client = app_config.clients.get(client_type)
|
|
if client is None:
|
|
raise HTTPException(status_code=404, detail="client config not found")
|
|
|
|
source_items = _resolve_sources(sources)
|
|
try:
|
|
snapshots = await build_source_snapshots(source_items)
|
|
except Exception as exc: # noqa: BLE001
|
|
raise HTTPException(status_code=502, detail=f"failed to build bundle: {exc}") from exc
|
|
|
|
content = dump_yaml(
|
|
build_bundle_profile(
|
|
client_type=client_type,
|
|
app_config=app_config,
|
|
client=client,
|
|
snapshots=snapshots,
|
|
)
|
|
)
|
|
headers = {"profile-update-interval": str(client.provider_interval)}
|
|
headers.update(await _build_quota_headers(source_items))
|
|
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml")
|