from __future__ import annotations import json from sqlalchemy import delete, select from sqlalchemy.orm import Session, selectinload from app.db import get_session, init_db from app.db_models import ( AppSettingORM, PolicyGroupORM, ProfileORM, ProfileRuleModuleORM, ProfileSourceORM, RuleModuleORM, SourceORM, ) from app.models import AppConfig, ClientConfig, ProxyGroupConfig, RegionConfig, RuleConfig, SourceConfig from app.services.loader import load_yaml_app_config def _json_dumps(value) -> str: return json.dumps(value, ensure_ascii=False) def _json_loads(value: str | None, default): if not value: return default return json.loads(value) def import_yaml_config_to_db(config_path, *, replace_existing: bool = False) -> None: app_config = load_yaml_app_config(config_path) init_db() with get_session() as session: if replace_existing: _clear_config_tables(session) _upsert_setting(session, "public_path", app_config.public_path or "") source_rows: list[SourceORM] = [] for key, source in app_config.sources.items(): row = _get_or_create_source(session, key) row.enabled = source.enabled row.kind = source.kind row.url = source.url row.display_name = source.display_name row.headers_json = _json_dumps(source.headers) row.include_regex = source.include_regex row.exclude_regex = source.exclude_regex row.prefix = source.prefix row.suffix = source.suffix row.cache_ttl_seconds = source.cache_ttl_seconds source_rows.append(row) module_rows: list[RuleModuleORM] = [] ordered_rule_keys = list(app_config.rules.keys()) for key, rule in app_config.rules.items(): row = _get_or_create_rule_module(session, key) row.file_path = rule.file row.behavior = rule.behavior row.format = rule.format row.policy = rule.policy row.no_resolve = rule.no_resolve row.payload_json = _json_dumps(rule.payload) module_rows.append(row) session.flush() for client_key, client in app_config.clients.items(): profile = _get_or_create_profile(session, client_key) _populate_profile(profile, client) session.flush() _replace_profile_sources(session, profile.id, source_rows) _replace_profile_groups(session, profile.id, app_config) _replace_profile_rule_links(session, profile.id, module_rows, ordered_rule_keys) session.commit() 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) 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) 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)): 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, } ) 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"]), ) ) 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) ) ) 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: session.execute(delete(ProfileRuleModuleORM)) session.execute(delete(PolicyGroupORM)) session.execute(delete(ProfileSourceORM)) session.execute(delete(ProfileORM)) session.execute(delete(RuleModuleORM)) session.execute(delete(SourceORM)) session.execute(delete(AppSettingORM)) session.flush() def _upsert_setting(session: Session, key: str, value: str) -> None: row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key)) if row is None: row = AppSettingORM(key=key, value=value) session.add(row) else: row.value = value def _setting_value(session: Session, key: str) -> str | None: row = session.scalar(select(AppSettingORM).where(AppSettingORM.key == key)) if row is None: return None return row.value def _get_or_create_source(session: Session, key: str) -> SourceORM: row = session.scalar(select(SourceORM).where(SourceORM.key == key)) if row is None: row = SourceORM(key=key, kind="auto", url="") session.add(row) return row def _get_or_create_rule_module(session: Session, key: str) -> RuleModuleORM: row = session.scalar(select(RuleModuleORM).where(RuleModuleORM.key == key)) if row is None: row = RuleModuleORM(key=key, behavior="classical", format="text", policy="DIRECT") session.add(row) return row def _get_or_create_profile(session: Session, key: str) -> ProfileORM: row = session.scalar(select(ProfileORM).where(ProfileORM.key == key)) if row is None: row = ProfileORM( key=key, title=key, test_url="https://www.gstatic.com/generate_204", main_policy="πŸš€ θŠ‚η‚Ήι€‰ζ‹©", source_policy="☁️ ζœΊεœΊι€‰ζ‹©", mixed_auto_policy="♻️ θ‡ͺεŠ¨ι€‰ζ‹©", manual_policy="πŸš€ ζ‰‹εŠ¨εˆ‡ζ’", direct_policy="DIRECT", ) session.add(row) return row def _populate_profile(profile: ProfileORM, client: ClientConfig) -> None: profile.title = client.title profile.provider_interval = client.provider_interval profile.rule_interval = client.rule_interval profile.test_url = str(client.test_url) profile.test_interval = client.test_interval profile.main_policy = client.main_policy profile.source_policy = client.source_policy profile.mixed_auto_policy = client.mixed_auto_policy profile.manual_policy = client.manual_policy profile.direct_policy = client.direct_policy profile.mode = client.mode profile.allow_lan = client.allow_lan profile.ipv6 = client.ipv6 profile.mixed_port = client.mixed_port profile.socks_port = client.socks_port profile.log_level = client.log_level def _replace_profile_sources(session: Session, profile_id: int, source_rows: list[SourceORM]) -> None: session.execute(delete(ProfileSourceORM).where(ProfileSourceORM.profile_id == profile_id)) for order_index, source in enumerate(source_rows): session.add( ProfileSourceORM( profile_id=profile_id, source_id=source.id, order_index=order_index, enabled=True, ) ) def _replace_profile_groups(session: Session, profile_id: int, app_config: AppConfig) -> None: session.execute(delete(PolicyGroupORM).where(PolicyGroupORM.profile_id == profile_id)) order_index = 0 for region in app_config.regions.values(): session.add( PolicyGroupORM( profile_id=profile_id, group_kind="region", name=region.name, type="url-test", order_index=order_index, filter_regex=region.filter, tolerance=region.tolerance, enabled=True, ) ) order_index += 1 for group in app_config.selector_groups: session.add( PolicyGroupORM( profile_id=profile_id, group_kind="selector", name=group.name, type=group.type, order_index=order_index, proxies_json=_json_dumps(group.proxies), filter_regex=group.filter, tolerance=group.tolerance, url=str(group.url) if group.url else None, interval=group.interval, enabled=True, ) ) order_index += 1 for group in app_config.policy_groups: session.add( PolicyGroupORM( profile_id=profile_id, group_kind="policy", name=group.name, type=group.type, order_index=order_index, proxies_json=_json_dumps(group.proxies), filter_regex=group.filter, tolerance=group.tolerance, url=str(group.url) if group.url else None, interval=group.interval, enabled=True, ) ) order_index += 1 def _replace_profile_rule_links( session: Session, profile_id: int, module_rows: list[RuleModuleORM], ordered_rule_keys: list[str], ) -> None: session.execute(delete(ProfileRuleModuleORM).where(ProfileRuleModuleORM.profile_id == profile_id)) module_by_key = {module.key: module for module in module_rows} for order_index, key in enumerate(ordered_rule_keys): module = module_by_key[key] session.add( ProfileRuleModuleORM( profile_id=profile_id, rule_module_id=module.id, enabled=True, order_index=order_index, ) ) def _profile_to_client_config(profile: ProfileORM) -> ClientConfig: return ClientConfig( title=profile.title, provider_interval=profile.provider_interval, rule_interval=profile.rule_interval, test_url=profile.test_url, test_interval=profile.test_interval, main_policy=profile.main_policy, source_policy=profile.source_policy, mixed_auto_policy=profile.mixed_auto_policy, manual_policy=profile.manual_policy, direct_policy=profile.direct_policy, mode=profile.mode, allow_lan=profile.allow_lan, ipv6=profile.ipv6, mixed_port=profile.mixed_port, socks_port=profile.socks_port, log_level=profile.log_level, ) def _slugify(name: str) -> str: text = "".join(char.lower() if char.isalnum() else "-" for char in name).strip("-") while "--" in text: text = text.replace("--", "-") return text or "group"