From d30018ed71c4a819b86d0fe4235484288112eaf2 Mon Sep 17 00:00:00 2001 From: riglen Date: Mon, 20 Apr 2026 14:42:19 +0800 Subject: [PATCH] =?UTF-8?q?Revert=20"=E9=9D=A2=E6=9D=BF"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 564042a8cc05cf2a95b3978e58371d3c0d12cfac. --- .env.example | 5 - README.md | 31 --- app/config.py | 3 - app/main.py | 461 +-------------------------------- app/services/config_store.py | 423 +++++------------------------- requirements.txt | 3 - templates/base.html | 136 ---------- templates/groups.html | 126 --------- templates/login.html | 73 ------ templates/preview.html | 31 --- templates/profile_sources.html | 40 --- templates/profiles.html | 60 ----- templates/rules.html | 40 --- templates/sources.html | 55 ---- 14 files changed, 86 insertions(+), 1401 deletions(-) delete mode 100644 templates/base.html delete mode 100644 templates/groups.html delete mode 100644 templates/login.html delete mode 100644 templates/preview.html delete mode 100644 templates/profile_sources.html delete mode 100644 templates/profiles.html delete mode 100644 templates/rules.html delete mode 100644 templates/sources.html diff --git a/.env.example b/.env.example index 9ea48c2..33782ea 100644 --- a/.env.example +++ b/.env.example @@ -8,11 +8,6 @@ DATABASE_URL=sqlite:////app/data/app.db # Docker 运行时数据目录,建议放在仓库外 APP_DATA_DIR=../sub-provider-data -# 管理面板鉴权;留空则不启用登录校验 -ADMIN_TOKEN= -ADMIN_SESSION_SECRET=change-this-admin-session-secret -ADMIN_SESSION_MAX_AGE=86400 - # 对外访问前缀,尽量改成足够长的随机字符串 PUBLIC_PATH=change-me-random-hash-path diff --git a/README.md b/README.md index b5424ff..6c45f3f 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,6 @@ cp .env.example .env - `APP_DATA_DIR` 建议指向仓库外目录,例如 `../sub-provider-data` - `PUBLIC_PATH` 改成足够长的随机字符串 - `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com` -- `ADMIN_TOKEN` 可选;配置后管理面板会启用登录鉴权,不配置则不校验登录 -- `ADMIN_SESSION_SECRET` 用于签发管理面板 session,部署时应改成随机长字符串 - `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅 - 也可以直接填本地文件路径,例如 `/app/data/sources/airport-b.txt` 或 `file:///app/data/sources/airport-b.txt` - 允许把其中一个留空;留空时这个机场会自动跳过 @@ -114,7 +112,6 @@ docker compose up -d --build 6. 访问检查: - 健康检查:`http://YOUR_HOST:18080/healthz` -- 管理面板:`http://YOUR_HOST:18080/admin` - 单 provider: `https://YOUR_DOMAIN//providers/airport-a.yaml` - merged provider: @@ -128,34 +125,6 @@ docker compose up -d --build - Stash bundle: `https://YOUR_DOMAIN//bundle/stash.yaml?sources=airport-a,airport-b,airport-c` -### 管理面板(最小版本) - -当前已提供一版服务端渲染的最小管理面板: - -- `/admin/sources` -- `/admin/profiles` -- `/admin/profiles/{profile_key}/sources` -- `/admin/profiles/{profile_key}/groups` -- `/admin/profiles/{profile_key}/rules` -- `/admin/profiles/{profile_key}/preview` - -当前能力范围: - -- Source 增删改 -- Profile 基础字段编辑 -- Profile 默认 source 绑定与顺序编辑 -- Profile group 新增、删除、基础字段编辑 -- Profile 规则启停、顺序、目标策略组编辑 -- 预览最终 bundle YAML - -鉴权方式: - -- 在 `.env` 中设置 `ADMIN_TOKEN` -- `docker compose` 会自动把 `.env` 注入容器 -- 访问 `/admin` 时先进入登录页,提交 token 后写入 session cookie -- session 使用 `ADMIN_SESSION_SECRET` 签名,默认有效期由 `ADMIN_SESSION_MAX_AGE` 控制 -- 如果 `ADMIN_TOKEN` 留空,管理面板默认不启用登录拦截 - --- ## 接口说明 diff --git a/app/config.py b/app/config.py index b1633c5..b568d57 100644 --- a/app/config.py +++ b/app/config.py @@ -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" diff --git a/app/main.py b/app/main.py index 8d43ce6..14b177f 100644 --- a/app/main.py +++ b/app/main.py @@ -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, - ), - ) diff --git a/app/services/config_store.py b/app/services/config_store.py index a69244c..ab06d3a 100644 --- a/app/services/config_store.py +++ b/app/services/config_store.py @@ -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: diff --git a/requirements.txt b/requirements.txt index 41f5456..214c77b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,3 @@ PyYAML>=6.0,<7.0 pydantic-settings>=2.3,<3.0 SQLAlchemy>=2.0,<3.0 alembic>=1.13,<2.0 -Jinja2>=3.1,<4.0 -python-multipart>=0.0.9,<1.0 -itsdangerous>=2.2,<3.0 diff --git a/templates/base.html b/templates/base.html deleted file mode 100644 index 4d927a7..0000000 --- a/templates/base.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - sub-provider admin - - - -
- -
- {% if flash %} -
{{ flash.message }}
- {% endif %} - {% block content %}{% endblock %} -
-
- - diff --git a/templates/groups.html b/templates/groups.html deleted file mode 100644 index 3923250..0000000 --- a/templates/groups.html +++ /dev/null @@ -1,126 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-
-

Groups: {{ profile_key }}

-

管理 region / selector / policy groups。当前版本以整表提交为主。

-
-
- Back - Preview -
-
- -
- {% for group in groups %} -
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- {% endfor %} - -
-

New Group

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
- -
-
- -
-
-
-{% endblock %} diff --git a/templates/login.html b/templates/login.html deleted file mode 100644 index 76937d5..0000000 --- a/templates/login.html +++ /dev/null @@ -1,73 +0,0 @@ - - - - - - sub-provider login - - - -
-

Admin Login

-

输入 `.env` 中配置的 `ADMIN_TOKEN`。

- {% if flash %} -
{{ flash.message }}
- {% endif %} -
- - - - -
-
- - diff --git a/templates/preview.html b/templates/preview.html deleted file mode 100644 index 62c6083..0000000 --- a/templates/preview.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-
-

Preview: {{ profile_key }}

-

预览 profile 当前生成的完整 bundle YAML。

-
-
- Rules - Profiles -
-
- -
-
-
- - -
-
- -
-
-
- -
- -
-
-{% endblock %} diff --git a/templates/profile_sources.html b/templates/profile_sources.html deleted file mode 100644 index 4f1d129..0000000 --- a/templates/profile_sources.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-
-

Profile Sources: {{ profile_key }}

-

配置 profile 默认会使用哪些源,以及它们的顺序。

-
-
- Back - Preview -
-
- -
- - - - - - {% for binding in bindings %} - - - - - - - - {% endfor %} - -
EnabledKeyOrderTypeURL
- - - {{ binding.key }}
{{ binding.display_name }}
{{ binding.kind }}{{ binding.url }}
-
- -
-
-
-{% endblock %} diff --git a/templates/profiles.html b/templates/profiles.html deleted file mode 100644 index 47f1360..0000000 --- a/templates/profiles.html +++ /dev/null @@ -1,60 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-

Profiles

-

管理客户端模板,并跳转到规则和预览页。

-
- -
-

New / Edit Profile

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- - -
-
-
- -
- {% for profile in profiles %} -
-
-
-

{{ profile.key }}

-
{{ profile.title }}
-
- -
-
-
Main: {{ profile.main_policy }}
-
Source: {{ profile.source_policy }}
-
Test URL: {{ profile.test_url }}
-
Mode: {{ profile.mode }}
-
-
- {% endfor %} -
-
-{% endblock %} diff --git a/templates/rules.html b/templates/rules.html deleted file mode 100644 index 8a1846e..0000000 --- a/templates/rules.html +++ /dev/null @@ -1,40 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-
-

Rules: {{ profile_key }}

-

控制规则模块启用状态、顺序和目标策略组。

-
-
- Back - Preview -
-
- -
- - - - - - {% for binding in bindings %} - - - - - - - - {% endfor %} - -
EnabledKeyOrderPolicyRule File
- - - {{ binding.key }}
{{ binding.behavior }} / {{ binding.format }}
{{ binding.file_path or "inline payload" }}
-
- -
-
-
-{% endblock %} diff --git a/templates/sources.html b/templates/sources.html deleted file mode 100644 index fc002f1..0000000 --- a/templates/sources.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "base.html" %} -{% block content %} -
-
-

Sources

-

管理订阅源。先做最小可用版本:增改删和启停。

-
- -
-

New / Edit Source

-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- -
-

Existing Sources

- - - - - - {% for source in sources %} - - - - - - - - - {% endfor %} - -
KeyTypeURLPrefixStatus
{{ source.key }}{{ source.kind }}{{ source.url }}{{ source.prefix }}{{ "enabled" if source.enabled else "disabled" }} -
- -
-
-
-
-{% endblock %}