Files
sub-provider/app/main.py
2026-04-21 16:56:06 +08:00

360 lines
15 KiB
Python

from __future__ import annotations
import logging
from fastapi import FastAPI, HTTPException, Query, Request
from fastapi.responses import Response
from app.config import get_settings
from app.models import RuleConfig, SourceConfig, SourceSnapshot
from app.services.bundle_cache import build_bundle_cache_key, load_bundle_cache, save_bundle_cache
from app.services.conf_config_store import load_conf_store
from app.services.conf_loader import ConfConfigError
from app.services.conf_profiles import build_conf_bundle_profile, build_conf_thin_profile, conf_source_to_source_config
from app.services.conf_runtime import build_conf_rule_lines, resolve_conf_runtime_plan
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()
logger = logging.getLogger(__name__)
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:
logger.info("resolve_sources default: selected=%s", [name for name, _ in enabled])
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")
logger.info("resolve_sources explicit: requested=%s selected=%s", sources, [name for name, _ in 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
def _load_conf_profile_runtime(profile_key: str):
try:
base_config, profiles = load_conf_store()
except ConfConfigError as exc:
raise HTTPException(status_code=500, detail=f"invalid conf config: {exc}") from exc
profile = profiles.get(profile_key)
if profile is None:
raise HTTPException(status_code=404, detail="conf profile not found")
try:
plan = resolve_conf_runtime_plan(
profile_key=profile_key,
base_config=base_config,
profile_config=profile,
base_path=settings.conf_base_file,
)
except ConfConfigError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return base_config, profile, plan
def _conf_source_items(plan) -> list[tuple[str, SourceConfig]]:
items: list[tuple[str, SourceConfig]] = []
for source in plan.selected_sources:
try:
items.append((source.key, conf_source_to_source_config(source, base_path=settings.conf_base_file)))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return items
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 _quota_headers_from_snapshots(snapshots: list[SourceSnapshot]) -> dict[str, str]:
if not snapshots:
return {}
quota = snapshots[0].quota
if quota and not quota.is_empty():
return {"Subscription-Userinfo": quota.to_header_value()}
return {}
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
logger.exception("merged_provider failed: sources=%s", [name for name, _ in source_items])
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
logger.exception("provider failed: source=%s", name)
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 + "/conf/rules/{profile_key}/{name}.yaml", methods=["GET", "HEAD"])
async def conf_rule_file(profile_key: str, name: str, request: Request) -> Response:
_, _, plan = _load_conf_profile_runtime(profile_key)
module = next((item for item in plan.selected_modules if item.key == name), None)
if module is None:
raise HTTPException(status_code=404, detail="conf rule not found")
path = (settings.conf_base_file.resolve().parent / module.path).resolve()
if not path.is_file():
raise HTTPException(status_code=404, detail="conf rule file missing")
content = load_rule_text(path)
return _yaml_response(content, request, filename=f"{profile_key}-{name}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/providers/{profile_key}/{name}.yaml", methods=["GET", "HEAD"])
async def conf_provider(profile_key: str, name: str, request: Request) -> Response:
_, _, plan = _load_conf_profile_runtime(profile_key)
source_items = _conf_source_items(plan)
source = next((item for item in source_items if item[0] == name), None)
if source is None:
raise HTTPException(status_code=404, detail="conf provider not found")
try:
document = await build_provider_document(source[0], source[1])
except Exception as exc: # noqa: BLE001
logger.exception("conf provider failed: profile=%s source=%s", profile_key, name)
raise HTTPException(status_code=502, detail=f"failed to build conf provider: {exc}") from exc
content = dump_provider_yaml(document)
headers = await _build_quota_headers([source])
return _yaml_response(content, request, headers=headers, filename=f"{profile_key}-{name}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/clients/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"])
async def conf_client_profile(client_type: str, profile_key: str, request: Request) -> Response:
client = app_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
_, _, plan = _load_conf_profile_runtime(profile_key)
source_items = _conf_source_items(plan)
content = dump_yaml(
build_conf_thin_profile(
client_type=client_type,
client=client,
plan=plan,
base_path=settings.conf_base_file,
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}-{profile_key}.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),
force_refresh: bool = Query(default=False),
) -> 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)
cache_key = build_bundle_cache_key(client_type=client_type, source_names=[name for name, _ in source_items])
if not force_refresh:
cached = load_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
ttl_seconds=settings.bundle_cache_ttl_seconds,
)
if cached is not None:
logger.info("bundle cache hit: client=%s sources=%s", client_type, [name for name, _ in source_items])
headers = {
"profile-update-interval": str(client.provider_interval),
"X-Sub-Provider-Bundle-Cache": "HIT",
}
headers.update(cached.headers)
return _yaml_response(content=cached.content, request=request, headers=headers, filename=f"bundle-{client_type}.yaml")
try:
snapshots = await build_source_snapshots(source_items)
except Exception as exc: # noqa: BLE001
logger.exception("bundle_profile failed: client=%s sources=%s", client_type, [name for name, _ in source_items])
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),
"X-Sub-Provider-Bundle-Cache": "BYPASS" if force_refresh else "MISS",
}
headers.update(_quota_headers_from_snapshots(snapshots))
save_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
content=content,
headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"},
)
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}.yaml")
@app.api_route(PUBLIC_PREFIX + "/conf/bundle/{client_type}/{profile_key}.yaml", methods=["GET", "HEAD"])
async def conf_bundle_profile(client_type: str, profile_key: str, request: Request, force_refresh: bool = Query(default=False)) -> Response:
client = app_config.clients.get(client_type)
if client is None:
raise HTTPException(status_code=404, detail="client config not found")
_, _, plan = _load_conf_profile_runtime(profile_key)
source_items = _conf_source_items(plan)
cache_key = build_bundle_cache_key(
client_type=f"conf-{client_type}-{profile_key}",
source_names=[name for name, _ in source_items],
)
if not force_refresh:
cached = load_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
ttl_seconds=settings.bundle_cache_ttl_seconds,
)
if cached is not None:
headers = {
"profile-update-interval": str(client.provider_interval),
"X-Sub-Provider-Bundle-Cache": "HIT",
}
headers.update(cached.headers)
return _yaml_response(content=cached.content, request=request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml")
try:
snapshots = await build_source_snapshots(source_items)
rules = build_conf_rule_lines(plan, base_path=settings.conf_base_file)
except Exception as exc: # noqa: BLE001
logger.exception("conf_bundle_profile failed: client=%s profile=%s", client_type, profile_key)
raise HTTPException(status_code=502, detail=f"failed to build conf bundle: {exc}") from exc
content = dump_yaml(
build_conf_bundle_profile(
client_type=client_type,
client=client,
plan=plan,
snapshots=snapshots,
rules=rules,
)
)
headers = {
"profile-update-interval": str(client.provider_interval),
"X-Sub-Provider-Bundle-Cache": "BYPASS" if force_refresh else "MISS",
}
headers.update(_quota_headers_from_snapshots(snapshots))
save_bundle_cache(
cache_dir=settings.bundle_cache_dir,
cache_key=cache_key,
content=content,
headers={key: value for key, value in headers.items() if key != "X-Sub-Provider-Bundle-Cache"},
)
return _yaml_response(content, request, headers=headers, filename=f"bundle-{client_type}-{profile_key}.yaml")