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 and str(src.url).strip()] 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 or not str(source.url).strip(): 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")