Revert "面板"

This reverts commit 564042a8cc.
This commit is contained in:
riglen
2026-04-20 14:42:19 +08:00
parent 7c7c233a2a
commit d30018ed71
14 changed files with 86 additions and 1401 deletions

View File

@@ -28,9 +28,6 @@ class Settings(BaseSettings):
default_user_agent: str = "sub-provider/0.2"
database_url: str = Field(default=f"sqlite:///{(DATA_DIR / 'app.db').resolve().as_posix()}")
database_echo: bool = False
admin_token: str | None = None
admin_session_secret: str = "change-this-admin-session-secret"
admin_session_max_age: int = 86400
config_dir: Path = CONFIG_DIR
sources_file: Path = CONFIG_DIR / "sources.yaml"

View File

@@ -1,32 +1,13 @@
from __future__ import annotations
import hmac
import logging
from urllib.parse import quote
from fastapi import FastAPI, Form, HTTPException, Query, Request
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
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.config_store import (
delete_source,
get_profile_default_source_keys,
list_profile_groups,
list_profile_rule_bindings,
list_profile_source_bindings,
list_profiles,
list_sources,
load_profile_app_config_from_db,
replace_profile_groups,
save_profile,
save_source,
update_profile_source_bindings,
update_profile_rule_bindings,
)
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
@@ -41,17 +22,8 @@ from app.services.subscriptions import (
settings = get_settings()
logger = logging.getLogger(__name__)
app = FastAPI(title=settings.app_name)
app.add_middleware(
SessionMiddleware,
secret_key=settings.admin_session_secret,
session_cookie="sub_provider_admin",
max_age=settings.admin_session_max_age,
same_site="lax",
https_only=False,
)
app_config = load_app_config(settings.sources_file)
PUBLIC_PREFIX = "/" + (app_config.public_path or settings.public_path).strip("/")
templates = Jinja2Templates(directory=str(settings.config_dir.parent / "templates"))
@app.get("/healthz")
@@ -65,58 +37,9 @@ def _base_url(request: Request) -> str:
return str(request.base_url).rstrip("/")
def _admin_auth_enabled() -> bool:
return bool((settings.admin_token or "").strip())
def _is_admin_authenticated(request: Request) -> bool:
return not _admin_auth_enabled() or request.session.get("admin_authenticated") is True
def _set_flash(request: Request, message: str, level: str = "info") -> None:
request.session["_flash"] = {"message": message, "level": level}
def _pop_flash(request: Request):
return request.session.pop("_flash", None)
def _admin_context(request: Request, *, active: str, **extra):
return {
"request": request,
"active": active,
"flash": _pop_flash(request),
"auth_enabled": _admin_auth_enabled(),
"authenticated": _is_admin_authenticated(request),
**extra,
}
def _admin_guard(request: Request) -> RedirectResponse | None:
if _is_admin_authenticated(request):
return None
next_path = quote(str(request.url.path))
return _redirect(f"/admin/login?next={next_path}")
def _current_app_config(profile_key: str | None = None):
if profile_key:
profile_config = load_profile_app_config_from_db(profile_key)
if profile_config is not None:
return profile_config
return load_app_config(settings.sources_file)
def _resolve_sources(sources: str | None, profile_key: str | None = None) -> list[tuple[str, SourceConfig]]:
current_config = _current_app_config(profile_key=profile_key)
enabled = [(name, src) for name, src in current_config.sources.items() if src.enabled and str(src.url).strip()]
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:
if profile_key:
default_keys = get_profile_default_source_keys(profile_key)
if default_keys:
selected = [(name, current_config.sources[name]) for name in default_keys if name in current_config.sources]
logger.info("resolve_sources profile default: profile=%s selected=%s", profile_key, [name for name, _ in selected])
return selected
logger.info("resolve_sources default: selected=%s", [name for name, _ in enabled])
return enabled
@@ -126,7 +49,7 @@ def _resolve_sources(sources: str | None, profile_key: str | None = None) -> lis
for name in names:
if name in seen:
continue
source = current_config.sources.get(name)
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))
@@ -192,8 +115,7 @@ async def merged_provider(request: Request, sources: str | None = Query(default=
@app.api_route(PUBLIC_PREFIX + "/providers/{name}.yaml", methods=["GET", "HEAD"])
async def provider(name: str, request: Request) -> Response:
current_config = _current_app_config()
source = current_config.sources.get(name)
source = app_config.sources.get(name)
if source is None or not source.enabled:
raise HTTPException(status_code=404, detail="provider not found")
@@ -210,8 +132,7 @@ async def provider(name: str, request: Request) -> Response:
@app.api_route(PUBLIC_PREFIX + "/rules/{name}.yaml", methods=["GET", "HEAD"])
async def rule_file(name: str, request: Request) -> Response:
current_config = _current_app_config()
rule = current_config.rules.get(name)
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))
@@ -220,19 +141,18 @@ async def rule_file(name: str, request: Request) -> Response:
@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:
current_config = _current_app_config(profile_key=client_type)
client = current_config.clients.get(client_type)
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, profile_key=client_type)
source_items = _resolve_sources(sources)
content = dump_yaml(
build_thin_profile(
client_type=client_type,
app_config=current_config,
app_config=app_config,
selected_source_names=[name for name, _ in source_items],
base_url=_base_url(request),
public_path=(current_config.public_path or settings.public_path).strip("/"),
public_path=(app_config.public_path or settings.public_path).strip("/"),
)
)
headers = {"profile-update-interval": str(client.provider_interval)}
@@ -247,12 +167,11 @@ async def bundle_profile(
sources: str | None = Query(default=None),
force_refresh: bool = Query(default=False),
) -> Response:
current_config = _current_app_config(profile_key=client_type)
client = current_config.clients.get(client_type)
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, profile_key=client_type)
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(
@@ -278,7 +197,7 @@ async def bundle_profile(
content = dump_yaml(
build_bundle_profile(
client_type=client_type,
app_config=current_config,
app_config=app_config,
snapshots=snapshots,
)
)
@@ -294,355 +213,3 @@ async def bundle_profile(
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")
def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url=url, status_code=303)
@app.get("/admin/login", response_class=HTMLResponse)
async def admin_login(request: Request, next: str = Query(default="/admin/sources")) -> HTMLResponse:
if _is_admin_authenticated(request):
return _redirect(next)
return templates.TemplateResponse(
request,
"login.html",
_admin_context(request, active="", next=next),
)
@app.post("/admin/login")
async def admin_login_submit(
request: Request,
token: str = Form(default=""),
next: str = Form(default="/admin/sources"),
) -> Response:
if not _admin_auth_enabled():
request.session["admin_authenticated"] = True
return _redirect(next)
if hmac.compare_digest((settings.admin_token or "").strip(), token.strip()):
request.session["admin_authenticated"] = True
_set_flash(request, "登录成功", "success")
return _redirect(next)
_set_flash(request, "Token 不正确", "error")
return _redirect(f"/admin/login?next={quote(next)}")
@app.post("/admin/logout")
async def admin_logout(request: Request) -> Response:
request.session.clear()
return _redirect("/admin/login")
@app.get("/admin", response_class=HTMLResponse)
async def admin_index(request: Request) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
return _redirect("/admin/sources")
@app.get("/admin/sources", response_class=HTMLResponse)
async def admin_sources(request: Request) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"sources.html",
_admin_context(
request,
active="sources",
sources=list_sources(),
profiles=list_profiles(),
),
)
@app.post("/admin/sources")
async def admin_save_source(
request: Request,
key: str = Form(...),
url: str = Form(...),
kind: str = Form(default="auto"),
display_name: str = Form(default=""),
prefix: str = Form(default=""),
suffix: str = Form(default=""),
include_regex: str = Form(default=""),
exclude_regex: str = Form(default=""),
cache_ttl_seconds: str = Form(default=""),
enabled: str | None = Form(default=None),
) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
save_source(
key=key.strip(),
enabled=enabled is not None,
kind=kind,
url=url.strip(),
display_name=display_name.strip() or None,
headers={},
include_regex=include_regex.strip() or None,
exclude_regex=exclude_regex.strip() or None,
prefix=prefix,
suffix=suffix,
cache_ttl_seconds=int(cache_ttl_seconds) if cache_ttl_seconds.strip() else None,
)
_set_flash(request, f"Source {key.strip()} 已保存", "success")
return _redirect("/admin/sources")
@app.post("/admin/sources/{key}/delete")
async def admin_delete_source(request: Request, key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
delete_source(key)
_set_flash(request, f"Source {key} 已删除", "success")
return _redirect("/admin/sources")
@app.get("/admin/profiles", response_class=HTMLResponse)
async def admin_profiles(request: Request) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"profiles.html",
_admin_context(
request,
active="profiles",
profiles=list_profiles(),
),
)
@app.post("/admin/profiles")
async def admin_save_profile(
request: Request,
key: str = Form(...),
title: str = Form(...),
provider_interval: int = Form(default=21600),
rule_interval: int = Form(default=86400),
test_url: str = Form(...),
test_interval: int = Form(default=300),
main_policy: str = Form(...),
source_policy: str = Form(...),
mixed_auto_policy: str = Form(...),
manual_policy: str = Form(...),
direct_policy: str = Form(...),
mode: str = Form(default="rule"),
allow_lan: str | None = Form(default=None),
ipv6: str | None = Form(default=None),
mixed_port: str = Form(default=""),
socks_port: str = Form(default=""),
log_level: str = Form(default="info"),
) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
save_profile(
key=key.strip(),
title=title.strip(),
provider_interval=provider_interval,
rule_interval=rule_interval,
test_url=test_url.strip(),
test_interval=test_interval,
main_policy=main_policy.strip(),
source_policy=source_policy.strip(),
mixed_auto_policy=mixed_auto_policy.strip(),
manual_policy=manual_policy.strip(),
direct_policy=direct_policy.strip(),
mode=mode.strip(),
allow_lan=allow_lan is not None,
ipv6=ipv6 is not None,
mixed_port=int(mixed_port) if mixed_port.strip() else None,
socks_port=int(socks_port) if socks_port.strip() else None,
log_level=log_level.strip() or None,
)
_set_flash(request, f"Profile {key.strip()} 已保存", "success")
return _redirect("/admin/profiles")
@app.get("/admin/profiles/{profile_key}/sources", response_class=HTMLResponse)
async def admin_profile_sources(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"profile_sources.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
bindings=list_profile_source_bindings(profile_key),
),
)
@app.post("/admin/profiles/{profile_key}/sources")
async def admin_update_profile_sources(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
keys = form.getlist("source_key")
rows: list[dict] = []
for index, key in enumerate(keys):
rows.append(
{
"key": key,
"enabled": form.get(f"enabled_{key}") is not None,
"order_index": form.get(f"order_index_{key}", str(index)),
}
)
update_profile_source_bindings(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的默认源已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/sources")
@app.get("/admin/profiles/{profile_key}/rules", response_class=HTMLResponse)
async def admin_profile_rules(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"rules.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
bindings=list_profile_rule_bindings(profile_key),
),
)
@app.get("/admin/profiles/{profile_key}/groups", response_class=HTMLResponse)
async def admin_profile_groups(request: Request, profile_key: str) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
return templates.TemplateResponse(
request,
"groups.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
groups=list_profile_groups(profile_key),
),
)
@app.post("/admin/profiles/{profile_key}/groups")
async def admin_update_profile_groups(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
row_ids = form.getlist("group_id")
rows: list[dict] = []
for index, group_id in enumerate(row_ids):
group_key = str(index)
name = str(form.get(f"group_name_{group_key}", "")).strip()
if not str(name).strip():
continue
proxies_raw = str(form.get(f"proxies_{group_key}", "")).strip()
if form.get(f"delete_{group_key}") is not None:
continue
rows.append(
{
"id": int(group_id) if str(group_id).strip() else None,
"name": str(name).strip(),
"group_kind": str(form.get(f"group_kind_{group_key}", "policy")).strip(),
"type": str(form.get(f"type_{group_key}", "select")).strip(),
"order_index": int(str(form.get(f"order_index_{group_key}", index))),
"proxies": [item.strip() for item in proxies_raw.splitlines() if item.strip()],
"filter_regex": str(form.get(f"filter_regex_{group_key}", "")).strip(),
"tolerance": int(str(form.get(f"tolerance_{group_key}", "")).strip()) if str(form.get(f"tolerance_{group_key}", "")).strip() else None,
"url": str(form.get(f"url_{group_key}", "")).strip(),
"interval": int(str(form.get(f"interval_{group_key}", "")).strip()) if str(form.get(f"interval_{group_key}", "")).strip() else None,
"enabled": form.get(f"enabled_{group_key}") is not None,
}
)
new_name = str(form.get("new_group_name", "")).strip()
if new_name:
new_proxies_raw = str(form.get("new_group_proxies", "")).strip()
rows.append(
{
"id": None,
"name": new_name,
"group_kind": str(form.get("new_group_kind", "policy")).strip(),
"type": str(form.get("new_group_type", "select")).strip(),
"order_index": int(str(form.get("new_group_order_index", len(rows))).strip() or len(rows)),
"proxies": [item.strip() for item in new_proxies_raw.splitlines() if item.strip()],
"filter_regex": str(form.get("new_group_filter_regex", "")).strip(),
"tolerance": int(str(form.get("new_group_tolerance", "")).strip()) if str(form.get("new_group_tolerance", "")).strip() else None,
"url": str(form.get("new_group_url", "")).strip(),
"interval": int(str(form.get("new_group_interval", "")).strip()) if str(form.get("new_group_interval", "")).strip() else None,
"enabled": form.get("new_group_enabled") is not None,
}
)
replace_profile_groups(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的策略组已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/groups")
@app.post("/admin/profiles/{profile_key}/rules")
async def admin_update_profile_rules(request: Request, profile_key: str) -> Response:
redirect = _admin_guard(request)
if redirect:
return redirect
form = await request.form()
keys = form.getlist("rule_key")
rows: list[dict] = []
for index, key in enumerate(keys):
rows.append(
{
"key": key,
"enabled": form.get(f"enabled_{key}") is not None,
"order_index": form.get(f"order_index_{key}", str(index)),
"policy": form.get(f"policy_{key}", ""),
}
)
update_profile_rule_bindings(profile_key, rows)
_set_flash(request, f"Profile {profile_key} 的规则绑定已更新", "success")
return _redirect(f"/admin/profiles/{profile_key}/rules")
@app.get("/admin/profiles/{profile_key}/preview", response_class=HTMLResponse)
async def admin_profile_preview(
request: Request,
profile_key: str,
sources: str | None = Query(default=None),
) -> HTMLResponse:
redirect = _admin_guard(request)
if redirect:
return redirect
profile_config = _current_app_config(profile_key=profile_key)
source_items = _resolve_sources(sources, profile_key=profile_key)
selected_names = [name for name, _ in source_items]
snapshots = await build_source_snapshots([(name, profile_config.sources[name]) for name in selected_names])
yaml_text = dump_yaml(
build_bundle_profile(
client_type=profile_key,
app_config=profile_config,
snapshots=snapshots,
)
)
return templates.TemplateResponse(
request,
"preview.html",
_admin_context(
request,
active="profiles",
profile_key=profile_key,
yaml_text=yaml_text,
available_sources=list(profile_config.sources.keys()),
selected_sources=selected_names,
),
)

View File

@@ -82,366 +82,87 @@ def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) ->
def load_app_config_from_db() -> AppConfig | None:
init_db()
with get_session() as session:
return _load_app_config_from_session(session, profile_key=None)
profiles = list(
session.scalars(
select(ProfileORM)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
)
.order_by(ProfileORM.key)
)
)
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
if not profiles or not sources:
return None
public_path = _setting_value(session, "public_path")
primary_profile = profiles[0]
def load_profile_app_config_from_db(profile_key: str) -> AppConfig | None:
init_db()
with get_session() as session:
return _load_app_config_from_session(session, profile_key=profile_key)
regions: dict[str, RegionConfig] = {}
selector_groups: list[ProxyGroupConfig] = []
policy_groups: list[ProxyGroupConfig] = []
for group in sorted(primary_profile.groups, key=lambda item: (item.order_index, item.id)):
if not group.enabled:
continue
if group.group_kind == "region":
regions[_slugify(group.name)] = RegionConfig(
name=group.name,
filter=group.filter_regex or "",
tolerance=group.tolerance or 50,
)
continue
target = selector_groups if group.group_kind == "selector" else policy_groups
target.append(
ProxyGroupConfig(
name=group.name,
type=group.type,
proxies=_json_loads(group.proxies_json, []),
filter=group.filter_regex,
tolerance=group.tolerance,
url=group.url,
interval=group.interval,
)
)
def list_sources() -> list[SourceORM]:
init_db()
with get_session() as session:
return list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
def save_source(
*,
key: str,
enabled: bool,
kind: str,
url: str,
display_name: str | None,
headers: dict[str, str],
include_regex: str | None,
exclude_regex: str | None,
prefix: str,
suffix: str,
cache_ttl_seconds: int | None,
) -> None:
init_db()
with get_session() as session:
source = _get_or_create_source(session, key)
source.enabled = enabled
source.kind = kind
source.url = url
source.display_name = display_name
source.headers_json = _json_dumps(headers)
source.include_regex = include_regex
source.exclude_regex = exclude_regex
source.prefix = prefix
source.suffix = suffix
source.cache_ttl_seconds = cache_ttl_seconds
session.commit()
def delete_source(key: str) -> None:
init_db()
with get_session() as session:
source = session.scalar(select(SourceORM).where(SourceORM.key == key))
if source is None:
return
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.source_id == source.id))
session.delete(source)
session.commit()
def list_profiles() -> list[ProfileORM]:
init_db()
with get_session() as session:
return list(session.scalars(select(ProfileORM).order_by(ProfileORM.key)))
def save_profile(
*,
key: str,
title: str,
provider_interval: int,
rule_interval: int,
test_url: str,
test_interval: int,
main_policy: str,
source_policy: str,
mixed_auto_policy: str,
manual_policy: str,
direct_policy: str,
mode: str,
allow_lan: bool,
ipv6: bool,
mixed_port: int | None,
socks_port: int | None,
log_level: str | None,
) -> None:
init_db()
with get_session() as session:
profile = _get_or_create_profile(session, key)
profile.title = title
profile.provider_interval = provider_interval
profile.rule_interval = rule_interval
profile.test_url = test_url
profile.test_interval = test_interval
profile.main_policy = main_policy
profile.source_policy = source_policy
profile.mixed_auto_policy = mixed_auto_policy
profile.manual_policy = manual_policy
profile.direct_policy = direct_policy
profile.mode = mode
profile.allow_lan = allow_lan
profile.ipv6 = ipv6
profile.mixed_port = mixed_port
profile.socks_port = socks_port
profile.log_level = log_level
session.commit()
def list_profile_rule_bindings(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
bindings: list[dict] = []
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
rules: dict[str, RuleConfig] = {}
for link in sorted(primary_profile.rule_links, key=lambda item: (item.order_index, item.id)):
if not link.enabled:
continue
module = link.rule_module
bindings.append(
{
"key": module.key,
"enabled": link.enabled,
"order_index": link.order_index,
"policy": link.policy_override or module.policy,
"file_path": module.file_path,
"behavior": module.behavior,
"format": module.format,
}
rules[module.key] = RuleConfig(
file=module.file_path,
behavior=module.behavior,
format=module.format,
policy=link.policy_override or module.policy,
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
)
return bindings
def list_profile_source_bindings(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
bindings: list[dict] = []
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id)):
bindings.append(
{
"key": link.source.key,
"enabled": link.enabled,
"order_index": link.order_index,
"display_name": link.source.display_name or link.source.key,
"kind": link.source.kind,
"url": link.source.url,
}
)
return bindings
def update_profile_source_bindings(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
source_keys = [row["key"] for row in rows]
sources = list(session.scalars(select(SourceORM).where(SourceORM.key.in_(source_keys))))
source_by_key = {source.key: source for source in sources}
session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile.id))
for row in rows:
source = source_by_key.get(row["key"])
if source is None:
continue
session.add(
ProfileSourceORM(
profile_id=profile.id,
source_id=source.id,
order_index=int(row["order_index"]),
enabled=bool(row["enabled"]),
return AppConfig(
public_path=public_path or None,
sources={
source.key: SourceConfig(
enabled=source.enabled,
kind=source.kind,
url=source.url,
display_name=source.display_name,
headers=_json_loads(source.headers_json, {}),
include_regex=source.include_regex,
exclude_regex=source.exclude_regex,
prefix=source.prefix,
suffix=source.suffix,
cache_ttl_seconds=source.cache_ttl_seconds,
)
)
session.commit()
def get_profile_default_source_keys(profile_key: str) -> list[str]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
keys = [
link.source.key
for link in sorted(profile.source_links, key=lambda item: (item.order_index, item.id))
if link.enabled and link.source.enabled and str(link.source.url).strip()
]
return keys
def list_profile_groups(profile_key: str) -> list[dict]:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
return []
rows: list[dict] = []
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
rows.append(
{
"id": group.id,
"group_kind": group.group_kind,
"name": group.name,
"type": group.type,
"order_index": group.order_index,
"proxies": _json_loads(group.proxies_json, []),
"filter_regex": group.filter_regex or "",
"tolerance": group.tolerance,
"url": group.url or "",
"interval": group.interval,
"enabled": group.enabled,
}
)
return rows
def replace_profile_groups(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile.id))
for row in rows:
session.add(
PolicyGroupORM(
profile_id=profile.id,
group_kind=row["group_kind"],
name=row["name"],
type=row["type"],
order_index=int(row["order_index"]),
proxies_json=_json_dumps(row["proxies"]) if row["proxies"] else None,
filter_regex=row["filter_regex"] or None,
tolerance=row["tolerance"],
url=row["url"] or None,
interval=row["interval"],
enabled=bool(row["enabled"]),
)
)
session.commit()
def update_profile_rule_bindings(profile_key: str, rows: list[dict]) -> None:
init_db()
with get_session() as session:
profile = _load_profile_with_relationships(session, profile_key)
if profile is None:
raise KeyError(f"profile not found: {profile_key}")
module_by_key = {link.rule_module.key: link for link in profile.rule_links}
for row in rows:
link = module_by_key.get(row["key"])
if link is None:
continue
link.enabled = bool(row["enabled"])
link.order_index = int(row["order_index"])
policy = str(row["policy"]).strip()
link.policy_override = policy or None
session.commit()
def _load_app_config_from_session(session: Session, profile_key: str | None) -> AppConfig | None:
profiles = list(
session.scalars(
select(ProfileORM)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
)
.order_by(ProfileORM.key)
for source in sources
},
rules=rules,
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
regions=regions,
selector_groups=selector_groups,
policy_groups=policy_groups,
)
)
sources = list(session.scalars(select(SourceORM).order_by(SourceORM.key)))
if not profiles or not sources:
return None
target_profile = next((profile for profile in profiles if profile.key == profile_key), profiles[0])
public_path = _setting_value(session, "public_path")
regions, selector_groups, policy_groups = _build_group_configs(target_profile)
rules = _build_rule_configs(target_profile)
return AppConfig(
public_path=public_path or None,
sources={source.key: _source_to_config(source) for source in sources},
rules=rules,
clients={profile.key: _profile_to_client_config(profile) for profile in profiles},
regions=regions,
selector_groups=selector_groups,
policy_groups=policy_groups,
)
def _source_to_config(source: SourceORM) -> SourceConfig:
return SourceConfig(
enabled=source.enabled,
kind=source.kind,
url=source.url,
display_name=source.display_name,
headers=_json_loads(source.headers_json, {}),
include_regex=source.include_regex,
exclude_regex=source.exclude_regex,
prefix=source.prefix,
suffix=source.suffix,
cache_ttl_seconds=source.cache_ttl_seconds,
)
def _build_group_configs(profile: ProfileORM) -> tuple[dict[str, RegionConfig], list[ProxyGroupConfig], list[ProxyGroupConfig]]:
regions: dict[str, RegionConfig] = {}
selector_groups: list[ProxyGroupConfig] = []
policy_groups: list[ProxyGroupConfig] = []
for group in sorted(profile.groups, key=lambda item: (item.order_index, item.id)):
if not group.enabled:
continue
if group.group_kind == "region":
regions[_slugify(group.name)] = RegionConfig(
name=group.name,
filter=group.filter_regex or "",
tolerance=group.tolerance or 50,
)
continue
target = selector_groups if group.group_kind == "selector" else policy_groups
target.append(
ProxyGroupConfig(
name=group.name,
type=group.type,
proxies=_json_loads(group.proxies_json, []),
filter=group.filter_regex,
tolerance=group.tolerance,
url=group.url,
interval=group.interval,
)
)
return regions, selector_groups, policy_groups
def _build_rule_configs(profile: ProfileORM) -> dict[str, RuleConfig]:
rules: dict[str, RuleConfig] = {}
for link in sorted(profile.rule_links, key=lambda item: (item.order_index, item.id)):
if not link.enabled:
continue
module = link.rule_module
rules[module.key] = RuleConfig(
file=module.file_path,
behavior=module.behavior,
format=module.format,
policy=link.policy_override or module.policy,
no_resolve=module.no_resolve if link.no_resolve_override is None else link.no_resolve_override,
payload=_json_loads(link.payload_override_json, _json_loads(module.payload_json, [])),
)
return rules
def _load_profile_with_relationships(session: Session, profile_key: str) -> ProfileORM | None:
return session.scalar(
select(ProfileORM)
.where(ProfileORM.key == profile_key)
.options(
selectinload(ProfileORM.groups),
selectinload(ProfileORM.rule_links).selectinload(ProfileRuleModuleORM.rule_module),
selectinload(ProfileORM.source_links).selectinload(ProfileSourceORM.source),
)
)
def _clear_config_tables(session: Session) -> None: