diff --git a/.env.example b/.env.example index b97d29e..33782ea 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ APP_ENV=prod HOST=0.0.0.0 PORT=18080 LOG_LEVEL=info +DATABASE_URL=sqlite:////app/data/app.db + +# Docker 运行时数据目录,建议放在仓库外 +APP_DATA_DIR=../sub-provider-data # 对外访问前缀,尽量改成足够长的随机字符串 PUBLIC_PATH=change-me-random-hash-path diff --git a/.gitignore b/.gitignore index 927d6c7..7aba0ae 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,8 @@ cover/ local_settings.py db.sqlite3 db.sqlite3-journal +data/ +output/bundle-cache/ # Flask stuff: instance/ diff --git a/README.md b/README.md index 2100db4..6c45f3f 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,8 @@ cp .env.example .env 2. 编辑 `.env`: +- `DATABASE_URL` 默认可用 `sqlite:////app/data/app.db` +- `APP_DATA_DIR` 建议指向仓库外目录,例如 `../sub-provider-data` - `PUBLIC_PATH` 改成足够长的随机字符串 - `PUBLIC_BASE_URL` 建议填写你反代后的最终访问地址,例如 `https://sub.example.com` - `AIRPORT_A_URL` / `AIRPORT_B_URL` / `AIRPORT_C_URL` 都可以直接填订阅地址,项目会自动判断是 YAML 还是 URI 订阅 @@ -87,13 +89,27 @@ cp .env.example .env - `rules.yaml`:规则绑定 - `clients.yaml`:客户端模板 -4. 启动: +4. 如需切换到数据库配置源,先执行一次导入: + +```bash +python -m app.import_config --replace +``` + +导入完成后,运行时会优先读取数据库;当数据库内没有配置时,仍会回退到 `config/` 下的 YAML 文件。 + +说明: + +- `app.db`、抓取缓存等运行态数据默认写入 `/app/data` +- `docker-compose` 已将 `/app/data` 挂载到 `APP_DATA_DIR` +- 建议把这个目录放在仓库外,避免 `git pull`、切分支时碰到本地状态文件 + +5. 启动: ```bash docker compose up -d --build ``` -5. 访问检查: +6. 访问检查: - 健康检查:`http://YOUR_HOST:18080/healthz` - 单 provider: diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..185dd84 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./data/app.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..992846f --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import get_settings +from app.db_models import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..ca3c201 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/.gitkeep b/alembic/versions/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/alembic/versions/.gitkeep @@ -0,0 +1 @@ + diff --git a/app/config.py b/app/config.py index ecb0087..b568d57 100644 --- a/app/config.py +++ b/app/config.py @@ -26,6 +26,8 @@ class Settings(BaseSettings): bundle_cache_ttl_seconds: int = 600 max_proxy_name_length: int = 80 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 config_dir: Path = CONFIG_DIR sources_file: Path = CONFIG_DIR / "sources.yaml" diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..a20dd75 --- /dev/null +++ b/app/db.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from functools import lru_cache + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.config import DATA_DIR, get_settings +from app.db_models import Base + + +def _sqlite_connect_args(database_url: str) -> dict[str, bool]: + if database_url.startswith("sqlite"): + return {"check_same_thread": False} + return {} + + +@lru_cache(maxsize=1) +def get_engine(): + settings = get_settings() + DATA_DIR.mkdir(parents=True, exist_ok=True) + return create_engine( + settings.database_url, + echo=settings.database_echo, + future=True, + connect_args=_sqlite_connect_args(settings.database_url), + ) + + +@lru_cache(maxsize=1) +def get_session_factory(): + return sessionmaker(bind=get_engine(), autoflush=False, autocommit=False, future=True) + + +def init_db() -> None: + Base.metadata.create_all(bind=get_engine()) + + +def get_session() -> Session: + return get_session_factory()() diff --git a/app/db_models.py b/app/db_models.py new file mode 100644 index 0000000..6f4fa19 --- /dev/null +++ b/app/db_models.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + pass + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + default=datetime.utcnow, + onupdate=datetime.utcnow, + nullable=False, + ) + + +class AppSettingORM(Base, TimestampMixin): + __tablename__ = "app_settings" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + value: Mapped[str | None] = mapped_column(Text, nullable=True) + + +class SourceORM(Base, TimestampMixin): + __tablename__ = "sources" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + kind: Mapped[str] = mapped_column(String(50), nullable=False) + url: Mapped[str] = mapped_column(Text, nullable=False) + display_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + headers_json: Mapped[str] = mapped_column(Text, default="{}", nullable=False) + include_regex: Mapped[str | None] = mapped_column(Text, nullable=True) + exclude_regex: Mapped[str | None] = mapped_column(Text, nullable=True) + prefix: Mapped[str] = mapped_column(String(255), default="", nullable=False) + suffix: Mapped[str] = mapped_column(String(255), default="", nullable=False) + cache_ttl_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True) + + +class ProfileORM(Base, TimestampMixin): + __tablename__ = "profiles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + title: Mapped[str] = mapped_column(String(255), nullable=False) + provider_interval: Mapped[int] = mapped_column(Integer, default=21600, nullable=False) + rule_interval: Mapped[int] = mapped_column(Integer, default=86400, nullable=False) + test_url: Mapped[str] = mapped_column(Text, nullable=False) + test_interval: Mapped[int] = mapped_column(Integer, default=300, nullable=False) + main_policy: Mapped[str] = mapped_column(String(255), nullable=False) + source_policy: Mapped[str] = mapped_column(String(255), nullable=False) + mixed_auto_policy: Mapped[str] = mapped_column(String(255), nullable=False) + manual_policy: Mapped[str] = mapped_column(String(255), nullable=False) + direct_policy: Mapped[str] = mapped_column(String(255), nullable=False) + mode: Mapped[str] = mapped_column(String(50), default="rule", nullable=False) + allow_lan: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + ipv6: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + mixed_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + socks_port: Mapped[int | None] = mapped_column(Integer, nullable=True) + log_level: Mapped[str | None] = mapped_column(String(50), nullable=True) + + source_links: Mapped[list["ProfileSourceORM"]] = relationship( + back_populates="profile", + cascade="all, delete-orphan", + ) + rule_links: Mapped[list["ProfileRuleModuleORM"]] = relationship( + back_populates="profile", + cascade="all, delete-orphan", + ) + groups: Mapped[list["PolicyGroupORM"]] = relationship( + back_populates="profile", + cascade="all, delete-orphan", + ) + + +class ProfileSourceORM(Base): + __tablename__ = "profile_sources" + __table_args__ = (UniqueConstraint("profile_id", "source_id", name="uq_profile_source"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False) + source_id: Mapped[int] = mapped_column(ForeignKey("sources.id"), nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + profile: Mapped[ProfileORM] = relationship(back_populates="source_links") + source: Mapped[SourceORM] = relationship() + + +class RuleModuleORM(Base, TimestampMixin): + __tablename__ = "rule_modules" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + file_path: Mapped[str | None] = mapped_column(Text, nullable=True) + behavior: Mapped[str] = mapped_column(String(50), nullable=False) + format: Mapped[str] = mapped_column(String(50), nullable=False) + policy: Mapped[str] = mapped_column(String(255), nullable=False) + no_resolve: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + payload_json: Mapped[str] = mapped_column(Text, default="[]", nullable=False) + + profile_links: Mapped[list["ProfileRuleModuleORM"]] = relationship( + back_populates="rule_module", + cascade="all, delete-orphan", + ) + + +class ProfileRuleModuleORM(Base): + __tablename__ = "profile_rule_modules" + __table_args__ = (UniqueConstraint("profile_id", "rule_module_id", name="uq_profile_rule_module"),) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False) + rule_module_id: Mapped[int] = mapped_column(ForeignKey("rule_modules.id"), nullable=False) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + policy_override: Mapped[str | None] = mapped_column(String(255), nullable=True) + payload_override_json: Mapped[str | None] = mapped_column(Text, nullable=True) + no_resolve_override: Mapped[bool | None] = mapped_column(Boolean, nullable=True) + + profile: Mapped[ProfileORM] = relationship(back_populates="rule_links") + rule_module: Mapped[RuleModuleORM] = relationship(back_populates="profile_links") + + +class PolicyGroupORM(Base, TimestampMixin): + __tablename__ = "policy_groups" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + profile_id: Mapped[int] = mapped_column(ForeignKey("profiles.id"), nullable=False) + group_kind: Mapped[str] = mapped_column(String(50), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + type: Mapped[str] = mapped_column(String(50), nullable=False) + order_index: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + proxies_json: Mapped[str | None] = mapped_column(Text, nullable=True) + filter_regex: Mapped[str | None] = mapped_column(Text, nullable=True) + tolerance: Mapped[int | None] = mapped_column(Integer, nullable=True) + url: Mapped[str | None] = mapped_column(Text, nullable=True) + interval: Mapped[int | None] = mapped_column(Integer, nullable=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + profile: Mapped[ProfileORM] = relationship(back_populates="groups") diff --git a/app/import_config.py b/app/import_config.py new file mode 100644 index 0000000..a6dfb54 --- /dev/null +++ b/app/import_config.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import argparse + +from app.config import get_settings +from app.services.config_store import import_yaml_config_to_db + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import YAML config into the sub-provider database") + parser.add_argument( + "--replace", + action="store_true", + help="Clear existing config rows before import", + ) + args = parser.parse_args() + + settings = get_settings() + import_yaml_config_to_db(settings.sources_file, replace_existing=args.replace) + print(f"Imported config into {settings.database_url}") + + +if __name__ == "__main__": + main() diff --git a/app/services/config_store.py b/app/services/config_store.py new file mode 100644 index 0000000..ab06d3a --- /dev/null +++ b/app/services/config_store.py @@ -0,0 +1,361 @@ +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: + 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] + + 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, + ) + ) + + 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 + 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 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, + ) + 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 _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" diff --git a/app/services/loader.py b/app/services/loader.py index edac2f5..9340bb2 100644 --- a/app/services/loader.py +++ b/app/services/loader.py @@ -7,6 +7,7 @@ from typing import Any import yaml +from app.db import init_db from app.models import AppConfig _ENV_PATTERN = re.compile(r"\$\{([A-Z0-9_]+)\}") @@ -61,5 +62,15 @@ def _load_split_config(path: Path) -> dict[str, Any]: def load_app_config(path: Path) -> AppConfig: + init_db() + from app.services.config_store import load_app_config_from_db + + db_config = load_app_config_from_db() + if db_config is not None: + return db_config + return load_yaml_app_config(path) + + +def load_yaml_app_config(path: Path) -> AppConfig: expanded = _expand_env(_load_split_config(path)) return AppConfig.model_validate(expanded) diff --git a/docker-compose.yaml b/docker-compose.yaml index 7719bce..0ed5075 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,5 +10,5 @@ services: - .env volumes: - ./config:/app/config:ro - - ./data:/app/data + - ${APP_DATA_DIR:-../sub-provider-data}:/app/data - ./output:/app/output diff --git a/requirements.txt b/requirements.txt index 44efd9e..214c77b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ uvicorn[standard]>=0.30,<1.0 httpx>=0.27,<1.0 PyYAML>=6.0,<7.0 pydantic-settings>=2.3,<3.0 +SQLAlchemy>=2.0,<3.0 +alembic>=1.13,<2.0