From 1ff7fa9502a944dc3b4ec623295bf9c6b51be2f6 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 20 Feb 2026 16:30:51 +0330 Subject: [PATCH 01/10] feat: implement core template management system - Removed legacy XRAY subscription template file and related configurations. - Introduced a new CoreTemplate model with CRUD operations for managing core templates. - Added API endpoints for creating, retrieving, modifying, and deleting core templates. - Implemented validation for template content based on type (e.g., clash, xray, singbox). - Created default templates for clash, xray, singbox, user-agent, and gRPC user-agent. - Added unit tests for core template creation, retrieval, and deletion logic. --- .env.example | 5 +- app/db/crud/__init__.py | 2 + app/db/crud/core_template.py | 251 ++++++++++++++ .../e8c6a4f1d2b7_add_core_templates_table.py | 307 ++++++++++++++++++ app/db/models.py | 16 + app/models/core_template.py | 94 ++++++ app/operation/__init__.py | 9 +- app/operation/core_template.py | 171 ++++++++++ app/operation/subscription.py | 10 +- app/routers/__init__.py | 3 +- app/routers/core_template.py | 96 ++++++ app/subscription/base.py | 20 +- app/subscription/clash.py | 37 ++- app/subscription/default_templates.py | 228 +++++++++++++ app/subscription/links.py | 11 +- app/subscription/share.py | 62 +++- app/subscription/singbox.py | 20 +- app/subscription/xray.py | 20 +- app/templates/__init__.py | 4 + app/templates/clash/default.yml | 50 --- app/templates/singbox/default.json | 85 ----- app/templates/user_agent/default.json | 104 ------ app/templates/user_agent/grpc.json | 20 -- app/templates/xray/default.json | 58 ---- config.py | 9 - tests/api/helpers.py | 24 ++ tests/api/test_core_template.py | 88 +++++ 27 files changed, 1432 insertions(+), 372 deletions(-) create mode 100644 app/db/crud/core_template.py create mode 100644 app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py create mode 100644 app/models/core_template.py create mode 100644 app/operation/core_template.py create mode 100644 app/routers/core_template.py create mode 100644 app/subscription/default_templates.py delete mode 100644 app/templates/clash/default.yml delete mode 100644 app/templates/singbox/default.json delete mode 100644 app/templates/user_agent/default.json delete mode 100644 app/templates/user_agent/grpc.json delete mode 100644 app/templates/xray/default.json create mode 100644 tests/api/test_core_template.py diff --git a/.env.example b/.env.example index b66c2a0b8..a492521b6 100644 --- a/.env.example +++ b/.env.example @@ -19,11 +19,10 @@ UVICORN_PORT = 8000 # USER_SUBSCRIPTION_CLIENTS_LIMIT = 10 # CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/" -# CLASH_SUBSCRIPTION_TEMPLATE="clash/my-custom-template.yml" # SUBSCRIPTION_PAGE_TEMPLATE="subscription/index.html" # HOME_PAGE_TEMPLATE="home/index.html" -# XRAY_SUBSCRIPTION_TEMPLATE="xray/default.json" -# SINGBOX_SUBSCRIPTION_TEMPLATE="singbox/default.json" +# Core subscription templates are stored in DB table `core_templates` +# and managed via `/api/core_template`. ## External config to import into v2ray format subscription # EXTERNAL_CONFIG = "config://..." diff --git a/app/db/crud/__init__.py b/app/db/crud/__init__.py index 9197113af..73db98080 100644 --- a/app/db/crud/__init__.py +++ b/app/db/crud/__init__.py @@ -1,5 +1,6 @@ from .admin import get_admin from .core import get_core_config_by_id +from .core_template import get_core_template_by_id from .group import get_group_by_id from .host import get_host_by_id from .node import get_node_by_id @@ -10,6 +11,7 @@ __all__ = [ "get_admin", "get_core_config_by_id", + "get_core_template_by_id", "get_group_by_id", "get_host_by_id", "get_node_by_id", diff --git a/app/db/crud/core_template.py b/app/db/crud/core_template.py new file mode 100644 index 000000000..4aac750a4 --- /dev/null +++ b/app/db/crud/core_template.py @@ -0,0 +1,251 @@ +from collections import defaultdict +from collections.abc import Mapping +from enum import Enum + +from sqlalchemy import func, select, update +from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.models import CoreTemplate +from app.models.core_template import CoreTemplateCreate, CoreTemplateModify, CoreTemplateType +from app.subscription.default_templates import DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY + +TEMPLATE_TYPE_TO_LEGACY_KEY: dict[CoreTemplateType, str] = { + CoreTemplateType.clash_subscription: "CLASH_SUBSCRIPTION_TEMPLATE", + CoreTemplateType.xray_subscription: "XRAY_SUBSCRIPTION_TEMPLATE", + CoreTemplateType.singbox_subscription: "SINGBOX_SUBSCRIPTION_TEMPLATE", + CoreTemplateType.user_agent: "USER_AGENT_TEMPLATE", + CoreTemplateType.grpc_user_agent: "GRPC_USER_AGENT_TEMPLATE", +} + +CoreTemplateSortingOptionsSimple = Enum( + "CoreTemplateSortingOptionsSimple", + { + "id": CoreTemplate.id.asc(), + "-id": CoreTemplate.id.desc(), + "name": CoreTemplate.name.asc(), + "-name": CoreTemplate.name.desc(), + "type": CoreTemplate.template_type.asc(), + "-type": CoreTemplate.template_type.desc(), + }, +) + + +def get_default_core_template_contents() -> dict[str, str]: + return DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY.copy() + + +def merge_core_template_values(values: Mapping[str, str] | None = None) -> dict[str, str]: + merged = get_default_core_template_contents() + if not values: + return merged + + for key, value in values.items(): + if key in merged and value: + merged[key] = value + + return merged + + +async def get_core_template_values(db: AsyncSession) -> dict[str, str]: + defaults = get_default_core_template_contents() + try: + rows = ( + await db.execute( + select( + CoreTemplate.id, + CoreTemplate.template_type, + CoreTemplate.content, + CoreTemplate.is_default, + ).order_by(CoreTemplate.template_type.asc(), CoreTemplate.id.asc()) + ) + ).all() + except SQLAlchemyError: + return defaults + + by_type: dict[str, list[tuple[int, str, bool]]] = defaultdict(list) + for row in rows: + by_type[row.template_type].append((row.id, row.content, row.is_default)) + + values: dict[str, str] = {} + for template_type, legacy_key in TEMPLATE_TYPE_TO_LEGACY_KEY.items(): + type_rows = by_type.get(template_type.value, []) + if not type_rows: + continue + + selected_content = "" + for _, content, is_default in type_rows: + if is_default: + selected_content = content + break + + if not selected_content: + selected_content = type_rows[0][1] + + if selected_content: + values[legacy_key] = selected_content + + return merge_core_template_values(values) + + +async def get_core_template_by_id(db: AsyncSession, template_id: int) -> CoreTemplate | None: + return (await db.execute(select(CoreTemplate).where(CoreTemplate.id == template_id))).unique().scalar_one_or_none() + + +async def get_core_templates( + db: AsyncSession, + template_type: CoreTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, +) -> tuple[list[CoreTemplate], int]: + query = select(CoreTemplate) + if template_type is not None: + query = query.where(CoreTemplate.template_type == template_type.value) + + total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0 + + query = query.order_by(CoreTemplate.template_type.asc(), CoreTemplate.id.asc()) + if offset: + query = query.offset(offset) + if limit: + query = query.limit(limit) + + rows = (await db.execute(query)).scalars().all() + return rows, total + + +async def get_core_templates_simple( + db: AsyncSession, + offset: int | None = None, + limit: int | None = None, + search: str | None = None, + template_type: CoreTemplateType | None = None, + sort: list[CoreTemplateSortingOptionsSimple] | None = None, + skip_pagination: bool = False, +) -> tuple[list[tuple[int, str, str, bool]], int]: + stmt = select(CoreTemplate.id, CoreTemplate.name, CoreTemplate.template_type, CoreTemplate.is_default) + + if search: + stmt = stmt.where(CoreTemplate.name.ilike(f"%{search.strip()}%")) + + if template_type is not None: + stmt = stmt.where(CoreTemplate.template_type == template_type.value) + + if sort: + sort_list = [] + for s in sort: + if isinstance(s.value, tuple): + sort_list.extend(s.value) + else: + sort_list.append(s.value) + stmt = stmt.order_by(*sort_list) + else: + stmt = stmt.order_by(CoreTemplate.template_type.asc(), CoreTemplate.id.asc()) + + total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 + + if not skip_pagination: + if offset: + stmt = stmt.offset(offset) + if limit: + stmt = stmt.limit(limit) + else: + stmt = stmt.limit(10000) + + rows = (await db.execute(stmt)).all() + return rows, total + + +async def count_core_templates_by_type(db: AsyncSession, template_type: CoreTemplateType) -> int: + count_stmt = select(func.count()).select_from(CoreTemplate).where(CoreTemplate.template_type == template_type.value) + return (await db.execute(count_stmt)).scalar() or 0 + + +async def get_first_template_by_type( + db: AsyncSession, + template_type: CoreTemplateType, + exclude_id: int | None = None, +) -> CoreTemplate | None: + stmt = ( + select(CoreTemplate) + .where(CoreTemplate.template_type == template_type.value) + .order_by(CoreTemplate.id.asc()) + ) + if exclude_id is not None: + stmt = stmt.where(CoreTemplate.id != exclude_id) + return (await db.execute(stmt)).scalars().first() + + +async def set_default_template(db: AsyncSession, db_template: CoreTemplate) -> CoreTemplate: + await db.execute( + update(CoreTemplate) + .where(CoreTemplate.template_type == db_template.template_type) + .values(is_default=False) + ) + db_template.is_default = True + await db.commit() + await db.refresh(db_template) + return db_template + + +async def create_core_template(db: AsyncSession, core_template: CoreTemplateCreate) -> CoreTemplate: + type_count = await count_core_templates_by_type(db, core_template.template_type) + is_first_for_type = type_count == 0 + should_be_default = core_template.is_default or is_first_for_type + + if should_be_default: + await db.execute( + update(CoreTemplate) + .where(CoreTemplate.template_type == core_template.template_type.value) + .values(is_default=False) + ) + + db_template = CoreTemplate( + name=core_template.name, + template_type=core_template.template_type.value, + content=core_template.content, + is_default=should_be_default, + is_system=is_first_for_type, + ) + db.add(db_template) + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise + await db.refresh(db_template) + return db_template + + +async def modify_core_template( + db: AsyncSession, + db_template: CoreTemplate, + modified_template: CoreTemplateModify, +) -> CoreTemplate: + template_data = modified_template.model_dump(exclude_none=True) + + if modified_template.is_default is True: + await db.execute( + update(CoreTemplate) + .where(CoreTemplate.template_type == db_template.template_type) + .values(is_default=False) + ) + db_template.is_default = True + + if "name" in template_data: + db_template.name = template_data["name"] + if "content" in template_data: + db_template.content = template_data["content"] + + try: + await db.commit() + except IntegrityError: + await db.rollback() + raise + await db.refresh(db_template) + return db_template + + +async def remove_core_template(db: AsyncSession, db_template: CoreTemplate) -> None: + await db.delete(db_template) + await db.commit() diff --git a/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py b/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py new file mode 100644 index 000000000..0e714e9e7 --- /dev/null +++ b/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py @@ -0,0 +1,307 @@ +"""add core_templates table + +Revision ID: e8c6a4f1d2b7 +Revises: 20e2a5cf1e40 +Create Date: 2026-02-20 15:45:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "e8c6a4f1d2b7" +down_revision = "20e2a5cf1e40" +branch_labels = None +depends_on = None + + +DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE = """mode: rule +mixed-port: 7890 +ipv6: true + +tun: + enable: true + stack: mixed + dns-hijack: + - "any:53" + auto-route: true + auto-detect-interface: true + strict-route: true + +dns: + enable: true + listen: :1053 + ipv6: true + nameserver: + - 'https://1.1.1.1/dns-query#PROXY' + proxy-server-nameserver: + - '178.22.122.100' + - '78.157.42.100' + +sniffer: + enable: true + override-destination: true + sniff: + HTTP: + ports: [80, 8080-8880] + TLS: + ports: [443, 8443] + QUIC: + ports: [443, 8443] + +{{ conf | except("proxy-groups", "port", "mode", "rules") | yaml }} + +proxy-groups: +- name: 'PROXY' + type: 'select' + proxies: + - 'Fastest' + {{ proxy_remarks | yaml | indent(2) }} + +- name: 'Fastest' + type: 'url-test' + proxies: + {{ proxy_remarks | yaml | indent(2) }} + +rules: + - MATCH,PROXY +""" + +DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE = """{ + "log": { + "access": "", + "error": "", + "loglevel": "warning" + }, + "inbounds": [ + { + "tag": "socks", + "port": 10808, + "listen": "0.0.0.0", + "protocol": "socks", + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ], + "routeOnly": false + }, + "settings": { + "auth": "noauth", + "udp": true, + "allowTransparent": false + } + }, + { + "tag": "http", + "port": 10809, + "listen": "0.0.0.0", + "protocol": "http", + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ], + "routeOnly": false + }, + "settings": { + "auth": "noauth", + "udp": true, + "allowTransparent": false + } + } + ], + "outbounds": [], + "dns": { + "servers": [ + "1.1.1.1", + "8.8.8.8" + ] + }, + "routing": { + "domainStrategy": "AsIs", + "rules": [] + } +} +""" + +DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE = """{ + "log": { + "level": "warn", + "timestamp": false + }, + "dns": { + "servers": [ + { + "tag": "dns-remote", + "address": "1.1.1.2", + "detour": "proxy" + }, + { + "tag": "dns-local", + "address": "local", + "detour": "direct" + } + ], + "rules": [ + { + "outbound": "any", + "server": "dns-local" + } + ], + "final": "dns-remote" + }, + "inbounds": [ + { + "type": "tun", + "tag": "tun-in", + "interface_name": "sing-tun", + "address": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ], + "auto_route": true, + "route_exclude_address": [ + "192.168.0.0/16", + "10.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "fe80::/10", + "fc00::/7" + ] + } + ], + "outbounds": [ + { + "type": "selector", + "tag": "proxy", + "outbounds": null, + "interrupt_exist_connections": true + }, + { + "type": "urltest", + "tag": "Best Latency", + "outbounds": null + }, + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "rules": [ + { + "inbound": "tun-in", + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "proxy", + "auto_detect_interface": true, + "override_android_vpn": true + }, + "experimental": { + "cache_file": { + "enabled": true, + "store_rdrc": true + } + } +} +""" + +DEFAULT_USER_AGENT_TEMPLATE = """{ + "list": [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1" + ] +} +""" + +DEFAULT_GRPC_USER_AGENT_TEMPLATE = """{ + "list": [ + "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)", + "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)", + "grpc-go/1.58.1", + "grpc-java-okhttp/1.55.1", + "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)" + ] +} +""" + + +def upgrade() -> None: + op.create_table( + "core_templates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=64), nullable=False), + sa.Column("template_type", sa.String(length=32), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("is_default", sa.Boolean(), server_default="0", nullable=False), + sa.Column("is_system", sa.Boolean(), server_default="0", nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("template_type", "name"), + ) + op.create_index("ix_core_templates_template_type", "core_templates", ["template_type"], unique=False) + + op.bulk_insert( + sa.table( + "core_templates", + sa.Column("name", sa.String), + sa.Column("template_type", sa.String), + sa.Column("content", sa.Text), + sa.Column("is_default", sa.Boolean), + sa.Column("is_system", sa.Boolean), + ), + [ + { + "name": "Default Clash Subscription", + "template_type": "clash_subscription", + "content": DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE, + "is_default": True, + "is_system": True, + }, + { + "name": "Default Xray Subscription", + "template_type": "xray_subscription", + "content": DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE, + "is_default": True, + "is_system": True, + }, + { + "name": "Default Singbox Subscription", + "template_type": "singbox_subscription", + "content": DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE, + "is_default": True, + "is_system": True, + }, + { + "name": "Default User-Agent Template", + "template_type": "user_agent", + "content": DEFAULT_USER_AGENT_TEMPLATE, + "is_default": True, + "is_system": True, + }, + { + "name": "Default gRPC User-Agent Template", + "template_type": "grpc_user_agent", + "content": DEFAULT_GRPC_USER_AGENT_TEMPLATE, + "is_default": True, + "is_system": True, + }, + ], + ) + + +def downgrade() -> None: + op.drop_index("ix_core_templates_template_type", table_name="core_templates") + op.drop_table("core_templates") diff --git a/app/db/models.py b/app/db/models.py index eabe15bb1..0e84f2b31 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -13,6 +13,7 @@ ForeignKey, Index, String, + Text, Table, UniqueConstraint, and_, @@ -713,6 +714,21 @@ class CoreConfig(Base): fallbacks_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set) +class CoreTemplate(Base): + __tablename__ = "core_templates" + __table_args__ = ( + UniqueConstraint("template_type", "name"), + Index("ix_core_templates_template_type", "template_type"), + ) + + id: Mapped[int] = mapped_column(primary_key=True, init=False, autoincrement=True) + name: Mapped[str] = mapped_column(String(64), nullable=False) + template_type: Mapped[str] = mapped_column(String(32), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False) + is_default: Mapped[bool] = mapped_column(default=False, server_default="0") + is_system: Mapped[bool] = mapped_column(default=False, server_default="0") + + class NodeStat(Base): __tablename__ = "node_stats" diff --git a/app/models/core_template.py b/app/models/core_template.py new file mode 100644 index 000000000..4d3d4e8ac --- /dev/null +++ b/app/models/core_template.py @@ -0,0 +1,94 @@ +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class CoreTemplateType(StrEnum): + clash_subscription = "clash_subscription" + xray_subscription = "xray_subscription" + singbox_subscription = "singbox_subscription" + user_agent = "user_agent" + grpc_user_agent = "grpc_user_agent" + + +class CoreTemplateBase(BaseModel): + name: str = Field(max_length=64) + template_type: CoreTemplateType + content: str + is_default: bool = Field(default=False) + + @field_validator("name") + @classmethod + def validate_name(cls, value: str) -> str: + stripped = value.strip() + if not stripped: + raise ValueError("name can't be empty") + return stripped + + @field_validator("content") + @classmethod + def validate_content(cls, value: str) -> str: + if not value or not value.strip(): + raise ValueError("content can't be empty") + return value + + +class CoreTemplateCreate(CoreTemplateBase): + pass + + +class CoreTemplateModify(BaseModel): + name: str | None = Field(default=None, max_length=64) + content: str | None = None + is_default: bool | None = None + + @field_validator("name") + @classmethod + def validate_name(cls, value: str | None) -> str | None: + if value is None: + return value + stripped = value.strip() + if not stripped: + raise ValueError("name can't be empty") + return stripped + + @field_validator("content") + @classmethod + def validate_content(cls, value: str | None) -> str | None: + if value is None: + return value + if not value.strip(): + raise ValueError("content can't be empty") + return value + + +class CoreTemplateResponse(BaseModel): + id: int + name: str + template_type: CoreTemplateType + content: str + is_default: bool + is_system: bool + + model_config = ConfigDict(from_attributes=True) + + +class CoreTemplateResponseList(BaseModel): + count: int + templates: list[CoreTemplateResponse] = [] + + model_config = ConfigDict(from_attributes=True) + + +class CoreTemplateSimple(BaseModel): + id: int + name: str + template_type: CoreTemplateType + is_default: bool + + model_config = ConfigDict(from_attributes=True) + + +class CoreTemplatesSimpleResponse(BaseModel): + templates: list[CoreTemplateSimple] + total: int diff --git a/app/operation/__init__.py b/app/operation/__init__.py index 51550fa04..adccb5c5f 100644 --- a/app/operation/__init__.py +++ b/app/operation/__init__.py @@ -8,6 +8,7 @@ from app.db.crud import ( get_admin, get_core_config_by_id, + get_core_template_by_id, get_group_by_id, get_host_by_id, get_node_by_id, @@ -17,7 +18,7 @@ from app.db.crud.admin import get_admin_by_id from app.db.crud.group import get_groups_by_ids from app.db.crud.user import get_user_by_id -from app.db.models import Admin as DBAdmin, CoreConfig, Group, Node, ProxyHost, User, UserTemplate +from app.db.models import Admin as DBAdmin, CoreConfig, CoreTemplate, Group, Node, ProxyHost, User, UserTemplate from app.models.admin import AdminDetails from app.models.group import BulkGroup from app.models.user import UserCreate, UserModify @@ -225,3 +226,9 @@ async def get_validated_core_config(self, db: AsyncSession, core_id) -> CoreConf if not db_core_config: await self.raise_error(message="Core config not found", code=404) return db_core_config + + async def get_validated_core_template(self, db: AsyncSession, template_id: int) -> CoreTemplate: + db_core_template = await get_core_template_by_id(db, template_id) + if not db_core_template: + await self.raise_error(message="Core template not found", code=404) + return db_core_template diff --git a/app/operation/core_template.py b/app/operation/core_template.py new file mode 100644 index 000000000..371556c42 --- /dev/null +++ b/app/operation/core_template.py @@ -0,0 +1,171 @@ +import json + +import yaml +from sqlalchemy.exc import IntegrityError + +from app.db import AsyncSession +from app.db.crud.core_template import ( + CoreTemplateSortingOptionsSimple, + count_core_templates_by_type, + create_core_template, + get_core_templates, + get_core_templates_simple, + get_first_template_by_type, + modify_core_template, + remove_core_template, + set_default_template, +) +from app.models.admin import AdminDetails +from app.models.core_template import ( + CoreTemplateCreate, + CoreTemplateModify, + CoreTemplateResponse, + CoreTemplateResponseList, + CoreTemplateSimple, + CoreTemplatesSimpleResponse, + CoreTemplateType, +) +from app.templates import render_template_string +from app.utils.logger import get_logger + +from . import BaseOperation + + +logger = get_logger("core-template-operation") + + +class CoreTemplateOperation(BaseOperation): + async def _validate_template_content(self, template_type: CoreTemplateType, content: str) -> None: + try: + if template_type == CoreTemplateType.clash_subscription: + rendered = render_template_string( + content, + { + "conf": {"proxies": [], "proxy-groups": [], "rules": []}, + "proxy_remarks": [], + }, + ) + yaml.safe_load(rendered) + return + + rendered = render_template_string(content) + parsed = json.loads(rendered) + if template_type in (CoreTemplateType.user_agent, CoreTemplateType.grpc_user_agent) and not isinstance( + parsed, dict + ): + raise ValueError("User-Agent template content must render to a JSON object") + except Exception as exc: + await self.raise_error(message=f"Invalid template content: {str(exc)}", code=400) + + async def create_core_template( + self, + db: AsyncSession, + new_template: CoreTemplateCreate, + admin: AdminDetails, + ) -> CoreTemplateResponse: + await self._validate_template_content(new_template.template_type, new_template.content) + + try: + db_template = await create_core_template(db, new_template) + except IntegrityError: + await self.raise_error("Template with this name already exists for this type", 409, db=db) + + logger.info( + f'Core template "{db_template.name}" ({db_template.template_type}) created by admin "{admin.username}"' + ) + return CoreTemplateResponse.model_validate(db_template) + + async def get_core_templates( + self, + db: AsyncSession, + template_type: CoreTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> CoreTemplateResponseList: + templates, count = await get_core_templates(db, template_type=template_type, offset=offset, limit=limit) + return CoreTemplateResponseList(templates=templates, count=count) + + async def get_core_templates_simple( + self, + db: AsyncSession, + offset: int | None = None, + limit: int | None = None, + search: str | None = None, + template_type: CoreTemplateType | None = None, + sort: str | None = None, + all: bool = False, + ) -> CoreTemplatesSimpleResponse: + sort_list = [] + if sort is not None: + opts = sort.strip(",").split(",") + for opt in opts: + try: + enum_member = CoreTemplateSortingOptionsSimple[opt] + sort_list.append(enum_member) + except KeyError: + await self.raise_error(message=f'"{opt}" is not a valid sort option', code=400) + + rows, total = await get_core_templates_simple( + db=db, + offset=offset, + limit=limit, + search=search, + template_type=template_type, + sort=sort_list if sort_list else None, + skip_pagination=all, + ) + + templates = [ + CoreTemplateSimple(id=row[0], name=row[1], template_type=row[2], is_default=row[3]) for row in rows + ] + return CoreTemplatesSimpleResponse(templates=templates, total=total) + + async def modify_core_template( + self, + db: AsyncSession, + template_id: int, + modified_template: CoreTemplateModify, + admin: AdminDetails, + ) -> CoreTemplateResponse: + db_template = await self.get_validated_core_template(db, template_id) + + if modified_template.content is not None: + await self._validate_template_content(CoreTemplateType(db_template.template_type), modified_template.content) + + if modified_template.is_default is False and db_template.is_default: + await self.raise_error( + message="Cannot unset default template directly. Set another template as default instead.", + code=400, + ) + + try: + db_template = await modify_core_template(db, db_template, modified_template) + except IntegrityError: + await self.raise_error("Template with this name already exists for this type", 409, db=db) + + logger.info( + f'Core template "{db_template.name}" ({db_template.template_type}) modified by admin "{admin.username}"' + ) + return CoreTemplateResponse.model_validate(db_template) + + async def remove_core_template(self, db: AsyncSession, template_id: int, admin: AdminDetails) -> None: + db_template = await self.get_validated_core_template(db, template_id) + template_type = CoreTemplateType(db_template.template_type) + + if db_template.is_system: + await self.raise_error(message="Cannot delete system template", code=403) + + template_count = await count_core_templates_by_type(db, template_type) + if template_count <= 1: + await self.raise_error(message="Cannot delete the last template for this type", code=403) + + replacement = None + if db_template.is_default: + replacement = await get_first_template_by_type(db, template_type, exclude_id=db_template.id) + + await remove_core_template(db, db_template) + + if replacement is not None: + await set_default_template(db, replacement) + + logger.info(f'Core template "{db_template.name}" ({template_type.value}) deleted by admin "{admin.username}"') diff --git a/app/operation/subscription.py b/app/operation/subscription.py index e2d0e048a..01d8b9d89 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -115,7 +115,9 @@ def create_info_response_headers(user: UsersResponseWithInbounds, sub_settings: # Only include headers that have values return {k: v for k, v in headers.items() if v} - async def fetch_config(self, user: UsersResponseWithInbounds, client_type: ConfigFormat) -> tuple[str, str]: + async def fetch_config( + self, db: AsyncSession, user: UsersResponseWithInbounds, client_type: ConfigFormat + ) -> tuple[str, str]: # Get client configuration config = client_config.get(client_type) sub_settings = await subscription_settings() @@ -124,6 +126,7 @@ async def fetch_config(self, user: UsersResponseWithInbounds, client_type: Confi # Generate subscription content return ( await generate_subscription( + db=db, user=user, config_format=config["config_format"], as_base64=config["as_base64"], @@ -159,6 +162,7 @@ async def user_subscription( links = [] if sub_settings.allow_browser_config: conf, media_type = await self.fetch_config( + db, user, ConfigFormat.links, ) @@ -183,7 +187,7 @@ async def user_subscription( # Update user subscription info await user_sub_update(db, db_user.id, user_agent) - conf, media_type = await self.fetch_config(user, client_type) + conf, media_type = await self.fetch_config(db, user, client_type) # If disable_sub_template is True and it's a browser request, use inline to view instead of download inline_view = sub_settings.disable_sub_template and is_browser_request @@ -216,7 +220,7 @@ async def user_subscription_with_client_type( user = await self.validated_user(db_user) response_headers = self.create_response_headers(user, request_url, sub_settings) - conf, media_type = await self.fetch_config(user, client_type) + conf, media_type = await self.fetch_config(db, user, client_type) # Create response headers return Response(content=conf, media_type=media_type, headers=response_headers) diff --git a/app/routers/__init__.py b/app/routers/__init__.py index ccf06c431..69168f64b 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import admin, core, group, home, host, node, settings, subscription, system, user, user_template +from . import admin, core, core_template, group, home, host, node, settings, subscription, system, user, user_template api_router = APIRouter() @@ -11,6 +11,7 @@ settings.router, group.router, core.router, + core_template.router, host.router, node.router, user.router, diff --git a/app/routers/core_template.py b/app/routers/core_template.py new file mode 100644 index 000000000..0a94902d2 --- /dev/null +++ b/app/routers/core_template.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter, Depends, status + +from app.db import AsyncSession, get_db +from app.models.admin import AdminDetails +from app.models.core_template import ( + CoreTemplateCreate, + CoreTemplateModify, + CoreTemplateResponse, + CoreTemplateResponseList, + CoreTemplatesSimpleResponse, + CoreTemplateType, +) +from app.operation import OperatorType +from app.operation.core_template import CoreTemplateOperation +from app.utils import responses + +from .authentication import check_sudo_admin, get_current + +router = APIRouter( + tags=["Core Template"], + prefix="/api/core_template", + responses={401: responses._401, 403: responses._403}, +) + +core_template_operator = CoreTemplateOperation(OperatorType.API) + + +@router.post("", response_model=CoreTemplateResponse, status_code=status.HTTP_201_CREATED) +async def create_core_template( + new_template: CoreTemplateCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(check_sudo_admin), +): + return await core_template_operator.create_core_template(db, new_template, admin) + + +@router.get("/{template_id}", response_model=CoreTemplateResponse) +async def get_core_template( + template_id: int, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(get_current), +): + return await core_template_operator.get_validated_core_template(db, template_id) + + +@router.put("/{template_id}", response_model=CoreTemplateResponse) +async def modify_core_template( + template_id: int, + modified_template: CoreTemplateModify, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(check_sudo_admin), +): + return await core_template_operator.modify_core_template(db, template_id, modified_template, admin) + + +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_core_template( + template_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(check_sudo_admin), +): + await core_template_operator.remove_core_template(db, template_id, admin) + return {} + + +@router.get("s", response_model=CoreTemplateResponseList) +async def get_core_templates( + template_type: CoreTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(get_current), +): + return await core_template_operator.get_core_templates(db, template_type=template_type, offset=offset, limit=limit) + + +@router.get("s/simple", response_model=CoreTemplatesSimpleResponse) +async def get_core_templates_simple( + template_type: CoreTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, + search: str | None = None, + sort: str | None = None, + all: bool = False, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(get_current), +): + return await core_template_operator.get_core_templates_simple( + db=db, + template_type=template_type, + offset=offset, + limit=limit, + search=search, + sort=sort, + all=all, + ) diff --git a/app/subscription/base.py b/app/subscription/base.py index 7841f4c7a..d9385c90b 100644 --- a/app/subscription/base.py +++ b/app/subscription/base.py @@ -4,20 +4,30 @@ import re from enum import Enum -from app.templates import render_template -from config import GRPC_USER_AGENT_TEMPLATE, USER_AGENT_TEMPLATE +from app.templates import render_template_string + +from .default_templates import DEFAULT_GRPC_USER_AGENT_TEMPLATE, DEFAULT_USER_AGENT_TEMPLATE class BaseSubscription: - def __init__(self): + def __init__( + self, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + if user_agent_template_content is None: + user_agent_template_content = DEFAULT_USER_AGENT_TEMPLATE + if grpc_user_agent_template_content is None: + grpc_user_agent_template_content = DEFAULT_GRPC_USER_AGENT_TEMPLATE + self.proxy_remarks = [] - user_agent_data = json.loads(render_template(USER_AGENT_TEMPLATE)) + user_agent_data = json.loads(render_template_string(user_agent_template_content)) if "list" in user_agent_data and isinstance(user_agent_data["list"], list): self.user_agent_list = user_agent_data["list"] else: self.user_agent_list = [] - grpc_user_agent_data = json.loads(render_template(GRPC_USER_AGENT_TEMPLATE)) + grpc_user_agent_data = json.loads(render_template_string(grpc_user_agent_template_content)) if "list" in grpc_user_agent_data and isinstance(grpc_user_agent_data["list"], list): self.grpc_user_agent_data = grpc_user_agent_data["list"] diff --git a/app/subscription/clash.py b/app/subscription/clash.py index cb7363afe..ab9fabc9a 100644 --- a/app/subscription/clash.py +++ b/app/subscription/clash.py @@ -10,18 +10,25 @@ TLSConfig, WebSocketTransportConfig, ) -from app.templates import render_template +from app.templates import render_template_string from app.utils.helpers import yml_uuid_representer -from config import ( - CLASH_SUBSCRIPTION_TEMPLATE, -) from . import BaseSubscription +from .default_templates import DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE class ClashConfiguration(BaseSubscription): - def __init__(self): - super().__init__() + def __init__( + self, + clash_template_content: str | None = None, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + super().__init__( + user_agent_template_content=user_agent_template_content, + grpc_user_agent_template_content=grpc_user_agent_template_content, + ) + self.clash_template_content = clash_template_content or DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE self.data = { "proxies": [], "proxy-groups": [], @@ -55,7 +62,10 @@ def render(self, reverse=False): yaml.add_representer(UUID, yml_uuid_representer) return yaml.dump( yaml.safe_load( - render_template(CLASH_SUBSCRIPTION_TEMPLATE, {"conf": self.data, "proxy_remarks": self.proxy_remarks}), + render_template_string( + self.clash_template_content, + {"conf": self.data, "proxy_remarks": self.proxy_remarks}, + ), ), sort_keys=False, allow_unicode=True, @@ -288,8 +298,17 @@ def add(self, remark: str, address: str, inbound: SubscriptionInboundData, setti class ClashMetaConfiguration(ClashConfiguration): - def __init__(self): - super().__init__() + def __init__( + self, + clash_template_content: str | None = None, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + super().__init__( + clash_template_content=clash_template_content, + user_agent_template_content=user_agent_template_content, + grpc_user_agent_template_content=grpc_user_agent_template_content, + ) # Override protocol handlers to include vless self.protocol_handlers = { "vmess": self._build_vmess, diff --git a/app/subscription/default_templates.py b/app/subscription/default_templates.py new file mode 100644 index 000000000..36a82b4c2 --- /dev/null +++ b/app/subscription/default_templates.py @@ -0,0 +1,228 @@ +DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE = """mode: rule +mixed-port: 7890 +ipv6: true + +tun: + enable: true + stack: mixed + dns-hijack: + - "any:53" + auto-route: true + auto-detect-interface: true + strict-route: true + +dns: + enable: true + listen: :1053 + ipv6: true + nameserver: + - 'https://1.1.1.1/dns-query#PROXY' + proxy-server-nameserver: + - '178.22.122.100' + - '78.157.42.100' + +sniffer: + enable: true + override-destination: true + sniff: + HTTP: + ports: [80, 8080-8880] + TLS: + ports: [443, 8443] + QUIC: + ports: [443, 8443] + +{{ conf | except("proxy-groups", "port", "mode", "rules") | yaml }} + +proxy-groups: +- name: 'PROXY' + type: 'select' + proxies: + - 'Fastest' + {{ proxy_remarks | yaml | indent(2) }} + +- name: 'Fastest' + type: 'url-test' + proxies: + {{ proxy_remarks | yaml | indent(2) }} + +rules: + - MATCH,PROXY +""" + +DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE = """{ + "log": { + "access": "", + "error": "", + "loglevel": "warning" + }, + "inbounds": [ + { + "tag": "socks", + "port": 10808, + "listen": "0.0.0.0", + "protocol": "socks", + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ], + "routeOnly": false + }, + "settings": { + "auth": "noauth", + "udp": true, + "allowTransparent": false + } + }, + { + "tag": "http", + "port": 10809, + "listen": "0.0.0.0", + "protocol": "http", + "sniffing": { + "enabled": true, + "destOverride": [ + "http", + "tls" + ], + "routeOnly": false + }, + "settings": { + "auth": "noauth", + "udp": true, + "allowTransparent": false + } + } + ], + "outbounds": [], + "dns": { + "servers": [ + "1.1.1.1", + "8.8.8.8" + ] + }, + "routing": { + "domainStrategy": "AsIs", + "rules": [] + } +} +""" + +DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE = """{ + "log": { + "level": "warn", + "timestamp": false + }, + "dns": { + "servers": [ + { + "tag": "dns-remote", + "address": "1.1.1.2", + "detour": "proxy" + }, + { + "tag": "dns-local", + "address": "local", + "detour": "direct" + } + ], + "rules": [ + { + "outbound": "any", + "server": "dns-local" + } + ], + "final": "dns-remote" + }, + "inbounds": [ + { + "type": "tun", + "tag": "tun-in", + "interface_name": "sing-tun", + "address": [ + "172.19.0.1/30", + "fdfe:dcba:9876::1/126" + ], + "auto_route": true, + "route_exclude_address": [ + "192.168.0.0/16", + "10.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "fe80::/10", + "fc00::/7" + ] + } + ], + "outbounds": [ + { + "type": "selector", + "tag": "proxy", + "outbounds": null, + "interrupt_exist_connections": true + }, + { + "type": "urltest", + "tag": "Best Latency", + "outbounds": null + }, + { + "type": "direct", + "tag": "direct" + } + ], + "route": { + "rules": [ + { + "inbound": "tun-in", + "action": "sniff" + }, + { + "protocol": "dns", + "action": "hijack-dns" + } + ], + "final": "proxy", + "auto_detect_interface": true, + "override_android_vpn": true + }, + "experimental": { + "cache_file": { + "enabled": true, + "store_rdrc": true + } + } +} +""" + +DEFAULT_USER_AGENT_TEMPLATE = """{ + "list": [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1" + ] +} +""" + +DEFAULT_GRPC_USER_AGENT_TEMPLATE = """{ + "list": [ + "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)", + "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)", + "grpc-go/1.58.1", + "grpc-java-okhttp/1.55.1", + "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)" + ] +} +""" + +DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY = { + "CLASH_SUBSCRIPTION_TEMPLATE": DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE, + "XRAY_SUBSCRIPTION_TEMPLATE": DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE, + "SINGBOX_SUBSCRIPTION_TEMPLATE": DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE, + "USER_AGENT_TEMPLATE": DEFAULT_USER_AGENT_TEMPLATE, + "GRPC_USER_AGENT_TEMPLATE": DEFAULT_GRPC_USER_AGENT_TEMPLATE, +} diff --git a/app/subscription/links.py b/app/subscription/links.py index fed0d7cd8..6079d053a 100644 --- a/app/subscription/links.py +++ b/app/subscription/links.py @@ -20,8 +20,15 @@ class StandardLinks(BaseSubscription): - def __init__(self): - super().__init__() + def __init__( + self, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + super().__init__( + user_agent_template_content=user_agent_template_content, + grpc_user_agent_template_content=grpc_user_agent_template_content, + ) self.links = [] # Registry pattern for transport handlers diff --git a/app/subscription/share.py b/app/subscription/share.py index d3cb5c446..065cc9a50 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -8,6 +8,8 @@ from jdatetime import date as jd from app.core.hosts import host_manager +from app.db import AsyncSession +from app.db.crud.core_template import get_core_template_values from app.db.models import UserStatus from app.models.subscription import SubscriptionInboundData from app.models.user import UsersResponseWithInbounds @@ -33,31 +35,60 @@ "on_hold": "🔌", } - -config_format_handler = { - "links": StandardLinks, - "clash": ClashMetaConfiguration, - "clash_meta": ClashMetaConfiguration, - "sing_box": SingBoxConfiguration, - "outline": OutlineConfiguration, - "xray": XrayConfiguration, -} +def _build_subscription_config( + config_format: str, + core_templates: dict[str, str], +) -> StandardLinks | XrayConfiguration | SingBoxConfiguration | ClashConfiguration | ClashMetaConfiguration | OutlineConfiguration | None: + common_kwargs = { + "user_agent_template_content": core_templates["USER_AGENT_TEMPLATE"], + "grpc_user_agent_template_content": core_templates["GRPC_USER_AGENT_TEMPLATE"], + } + + if config_format == "links": + return StandardLinks(**common_kwargs) + if config_format in ("clash", "clash_meta"): + return ClashMetaConfiguration( + clash_template_content=core_templates["CLASH_SUBSCRIPTION_TEMPLATE"], + **common_kwargs, + ) + if config_format == "sing_box": + return SingBoxConfiguration( + singbox_template_content=core_templates["SINGBOX_SUBSCRIPTION_TEMPLATE"], + **common_kwargs, + ) + if config_format == "outline": + return OutlineConfiguration() + if config_format == "xray": + return XrayConfiguration( + xray_template_content=core_templates["XRAY_SUBSCRIPTION_TEMPLATE"], + **common_kwargs, + ) + return None async def generate_subscription( + db: AsyncSession, user: UsersResponseWithInbounds, config_format: str, as_base64: bool, reverse: bool = False, randomize_order: bool = False, ) -> str: - conf = config_format_handler.get(config_format, None) + core_templates = await get_core_template_values(db) + conf = _build_subscription_config(config_format, core_templates) if conf is None: raise ValueError(f'Unsupported format "{config_format}"') format_variables = setup_format_variables(user) - config = await process_inbounds_and_tags(user, format_variables, conf(), reverse, randomize_order=randomize_order) + config = await process_inbounds_and_tags( + user, + format_variables, + conf, + core_templates, + reverse, + randomize_order=randomize_order, + ) if as_base64: config = base64.b64encode(config.encode()).decode() @@ -251,6 +282,7 @@ async def _prepare_download_settings( format_variables: dict, inbounds: list[str], proxies: dict, + core_templates: dict[str, str], conf: StandardLinks | XrayConfiguration | SingBoxConfiguration @@ -269,7 +301,11 @@ async def _prepare_download_settings( download_copy.address = download_copy.address.format_map(format_variables) if isinstance(conf, StandardLinks): - xc = XrayConfiguration() + xc = XrayConfiguration( + xray_template_content=core_templates["XRAY_SUBSCRIPTION_TEMPLATE"], + user_agent_template_content=core_templates["USER_AGENT_TEMPLATE"], + grpc_user_agent_template_content=core_templates["GRPC_USER_AGENT_TEMPLATE"], + ) return xc._download_config(download_copy, link_format=True) return download_copy @@ -284,6 +320,7 @@ async def process_inbounds_and_tags( | ClashConfiguration | ClashMetaConfiguration | OutlineConfiguration, + core_templates: dict[str, str], reverse=False, randomize_order: bool = False, ) -> list | str: @@ -310,6 +347,7 @@ async def process_inbounds_and_tags( format_variables, user.inbounds, proxy_settings, + core_templates, conf, ) if hasattr(inbound_copy.transport_config, "download_settings"): diff --git a/app/subscription/singbox.py b/app/subscription/singbox.py index e4b6ac0b7..db0a99332 100644 --- a/app/subscription/singbox.py +++ b/app/subscription/singbox.py @@ -8,17 +8,27 @@ TLSConfig, WebSocketTransportConfig, ) -from app.templates import render_template +from app.templates import render_template_string from app.utils.helpers import UUIDEncoder -from config import SINGBOX_SUBSCRIPTION_TEMPLATE from . import BaseSubscription +from .default_templates import DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE class SingBoxConfiguration(BaseSubscription): - def __init__(self): - super().__init__() - self.config = json.loads(render_template(SINGBOX_SUBSCRIPTION_TEMPLATE)) + def __init__( + self, + singbox_template_content: str | None = None, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + super().__init__( + user_agent_template_content=user_agent_template_content, + grpc_user_agent_template_content=grpc_user_agent_template_content, + ) + if singbox_template_content is None: + singbox_template_content = DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE + self.config = json.loads(render_template_string(singbox_template_content)) # Registry for transport handlers self.transport_handlers = { diff --git a/app/subscription/xray.py b/app/subscription/xray.py index 39b4d64d3..efa607a7d 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -11,18 +11,28 @@ WebSocketTransportConfig, XHTTPTransportConfig, ) -from app.templates import render_template +from app.templates import render_template_string from app.utils.helpers import UUIDEncoder -from config import XRAY_SUBSCRIPTION_TEMPLATE from . import BaseSubscription +from .default_templates import DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE class XrayConfiguration(BaseSubscription): - def __init__(self): - super().__init__() + def __init__( + self, + xray_template_content: str | None = None, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + super().__init__( + user_agent_template_content=user_agent_template_content, + grpc_user_agent_template_content=grpc_user_agent_template_content, + ) self.config = [] - self.template = render_template(XRAY_SUBSCRIPTION_TEMPLATE) + if xray_template_content is None: + xray_template_content = DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE + self.template = render_template_string(xray_template_content) # Registry for transport handlers self.transport_handlers = { diff --git a/app/templates/__init__.py b/app/templates/__init__.py index 02a9d0b54..9b006e5c7 100644 --- a/app/templates/__init__.py +++ b/app/templates/__init__.py @@ -19,3 +19,7 @@ def render_template(template: str, context: Union[dict, None] = None) -> str: return env.get_template(template).render(context or {}) + + +def render_template_string(template_content: str, context: Union[dict, None] = None) -> str: + return env.from_string(template_content).render(context or {}) diff --git a/app/templates/clash/default.yml b/app/templates/clash/default.yml deleted file mode 100644 index 344267ebd..000000000 --- a/app/templates/clash/default.yml +++ /dev/null @@ -1,50 +0,0 @@ -mode: rule -mixed-port: 7890 -ipv6: true - -tun: - enable: true - stack: mixed - dns-hijack: - - "any:53" - auto-route: true - auto-detect-interface: true - strict-route: true - -dns: - enable: true - listen: :1053 - ipv6: true - nameserver: - - 'https://1.1.1.1/dns-query#PROXY' - proxy-server-nameserver: - - '178.22.122.100' - - '78.157.42.100' - -sniffer: - enable: true - override-destination: true - sniff: - HTTP: - ports: [80, 8080-8880] - TLS: - ports: [443, 8443] - QUIC: - ports: [443, 8443] - -{{ conf | except("proxy-groups", "port", "mode", "rules") | yaml }} - -proxy-groups: -- name: 'PROXY' - type: 'select' - proxies: - - '⚡️ Fastest' - {{ proxy_remarks | yaml | indent(2) }} - -- name: '⚡️ Fastest' - type: 'url-test' - proxies: - {{ proxy_remarks | yaml | indent(2) }} - -rules: - - MATCH,PROXY diff --git a/app/templates/singbox/default.json b/app/templates/singbox/default.json deleted file mode 100644 index e5594628d..000000000 --- a/app/templates/singbox/default.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "log": { - "level": "warn", - "timestamp": false - }, - "dns": { - "servers": [ - { - "tag": "dns-remote", - "address": "1.1.1.2", - "detour": "proxy" - }, - { - "tag": "dns-local", - "address": "local", - "detour": "direct" - } - ], - "rules": [ - { - "outbound": "any", - "server": "dns-local" - } - ], - "final": "dns-remote" - }, - "inbounds": [ - { - "type": "tun", - "tag": "tun-in", - "interface_name": "sing-tun", - "address": [ - "172.19.0.1/30", - "fdfe:dcba:9876::1/126" - ], - "auto_route": true, - "route_exclude_address": [ - "192.168.0.0/16", - "10.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "fe80::/10", - "fc00::/7" - ] - } - ], - "outbounds": [ - { - "type": "selector", - "tag": "proxy", - "outbounds": null, - "interrupt_exist_connections": true - }, - { - "type": "urltest", - "tag": "Best Latency", - "outbounds": null - }, - { - "type": "direct", - "tag": "direct" - } - ], - "route": { - "rules": [ - { - "inbound": "tun-in", - "action": "sniff" - }, - { - "protocol": "dns", - "action": "hijack-dns" - } - ], - "final": "proxy", - "auto_detect_interface": true, - "override_android_vpn": true - }, - "experimental": { - "cache_file": { - "enabled": true, - "store_rdrc": true - } - } -} diff --git a/app/templates/user_agent/default.json b/app/templates/user_agent/default.json deleted file mode 100644 index abb85644c..000000000 --- a/app/templates/user_agent/default.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "list":[ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 PageSpeedPlus/1.0.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0", - "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.14 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.16 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.3 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.12 Chrome/120.0.6099.283 Electron/28.2.3 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.16 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 OPR/109.0.0.0", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.13 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.13 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", - "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.12 Chrome/120.0.6099.283 Electron/28.2.3 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15", - "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.3 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", - "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/117.0.0.0 Mobile Safari/537.36" - ] -} \ No newline at end of file diff --git a/app/templates/user_agent/grpc.json b/app/templates/user_agent/grpc.json deleted file mode 100644 index 821671ea4..000000000 --- a/app/templates/user_agent/grpc.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "list": [ - "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)", - "grpc-dotnet/2.41.0 (.NET 6.0.0-preview.7.21377.19; CLR 6.0.0; net6.0; osx; x64)", - "grpc-dotnet/2.41.0 (Mono 6.12.0.140; CLR 4.0.30319; netstandard2.0; osx; x64)", - "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; linux; arm64)", - "grpc-dotnet/2.41.0 (.NET 5.0.8; CLR 5.0.8; net5.0; linux; arm64)", - "grpc-dotnet/2.41.0 (.NET Core; CLR 3.1.4; netstandard2.1; linux; arm64)", - "grpc-dotnet/2.41.0 (.NET Framework; CLR 4.0.30319.42000; netstandard2.0; windows; x86)", - "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; windows; x64)", - "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)", - "grpc-go/1.58.1", - "grpc-java-okhttp/1.55.1", - "grpc-node/1.7.1 grpc-c/1.7.1 (osx; chttp2)", - "grpc-node/1.24.2 grpc-c/8.0.0 (linux; chttp2; ganges)", - "grpc-c++/1.16.0 grpc-c/6.0.0 (linux; nghttp2; hw)", - "grpc-node/1.19.0 grpc-c/7.0.0 (linux; chttp2; gold)", - "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)]" - ] -} \ No newline at end of file diff --git a/app/templates/xray/default.json b/app/templates/xray/default.json deleted file mode 100644 index d842a9f79..000000000 --- a/app/templates/xray/default.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "log": { - "access": "", - "error": "", - "loglevel": "warning" - }, - "inbounds": [ - { - "tag": "socks", - "port": 10808, - "listen": "0.0.0.0", - "protocol": "socks", - "sniffing": { - "enabled": true, - "destOverride": [ - "http", - "tls" - ], - "routeOnly": false - }, - "settings": { - "auth": "noauth", - "udp": true, - "allowTransparent": false - } - }, - { - "tag": "http", - "port": 10809, - "listen": "0.0.0.0", - "protocol": "http", - "sniffing": { - "enabled": true, - "destOverride": [ - "http", - "tls" - ], - "routeOnly": false - }, - "settings": { - "auth": "noauth", - "udp": true, - "allowTransparent": false - } - } - ], - "outbounds": [], - "dns": { - "servers": [ - "1.1.1.1", - "8.8.8.8" - ] - }, - "routing": { - "domainStrategy": "AsIs", - "rules": [] - } -} \ No newline at end of file diff --git a/config.py b/config.py index b56fec2f1..666914e69 100644 --- a/config.py +++ b/config.py @@ -75,15 +75,6 @@ SUBSCRIPTION_PAGE_TEMPLATE = config("SUBSCRIPTION_PAGE_TEMPLATE", default="subscription/index.html") HOME_PAGE_TEMPLATE = config("HOME_PAGE_TEMPLATE", default="home/index.html") -CLASH_SUBSCRIPTION_TEMPLATE = config("CLASH_SUBSCRIPTION_TEMPLATE", default="clash/default.yml") - -SINGBOX_SUBSCRIPTION_TEMPLATE = config("SINGBOX_SUBSCRIPTION_TEMPLATE", default="singbox/default.json") - -XRAY_SUBSCRIPTION_TEMPLATE = config("XRAY_SUBSCRIPTION_TEMPLATE", default="xray/default.json") - -USER_AGENT_TEMPLATE = config("USER_AGENT_TEMPLATE", default="user_agent/default.json") -GRPC_USER_AGENT_TEMPLATE = config("GRPC_USER_AGENT_TEMPLATE", default="user_agent/grpc.json") - EXTERNAL_CONFIG = config("EXTERNAL_CONFIG", default="", cast=str) USERS_AUTODELETE_DAYS = config("USERS_AUTODELETE_DAYS", default=-1, cast=int) diff --git a/tests/api/helpers.py b/tests/api/helpers.py index d4ffef3cc..78d15c230 100644 --- a/tests/api/helpers.py +++ b/tests/api/helpers.py @@ -77,6 +77,30 @@ def delete_core(access_token: str, core_id: int) -> None: assert response.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_403_FORBIDDEN) +def create_core_template( + access_token: str, + *, + name: str | None = None, + template_type: str = "xray_subscription", + content: str = '{"outbounds": []}', + is_default: bool = False, +) -> dict: + payload = { + "name": name or unique_name("core_template"), + "template_type": template_type, + "content": content, + "is_default": is_default, + } + response = client.post("/api/core_template", headers=auth_headers(access_token), json=payload) + assert response.status_code == status.HTTP_201_CREATED + return response.json() + + +def delete_core_template(access_token: str, template_id: int) -> None: + response = client.delete(f"/api/core_template/{template_id}", headers=auth_headers(access_token)) + assert response.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_403_FORBIDDEN) + + def get_inbounds(access_token: str) -> list[str]: def _fetch() -> tuple[int, list[str]]: response = client.get("/api/inbounds", headers=auth_headers(access_token)) diff --git a/tests/api/test_core_template.py b/tests/api/test_core_template.py new file mode 100644 index 000000000..66c8eafc9 --- /dev/null +++ b/tests/api/test_core_template.py @@ -0,0 +1,88 @@ +from fastapi import status + +from tests.api import client +from tests.api.helpers import create_core_template, unique_name + + +def test_core_template_create_and_get(access_token): + created = create_core_template( + access_token, + name=unique_name("tmpl_clash"), + template_type="clash_subscription", + content="proxies: []\nproxy-groups: []\nrules: []\n", + ) + + assert created["name"] + assert created["template_type"] == "clash_subscription" + assert created["content"] + assert isinstance(created["is_default"], bool) + assert isinstance(created["is_system"], bool) + + response = client.get( + f"/api/core_template/{created['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["id"] == created["id"] + + +def test_core_template_can_switch_default(access_token): + first = create_core_template( + access_token, + name=unique_name("tmpl_sb_first"), + template_type="singbox_subscription", + content='{"outbounds": []}', + ) + second = create_core_template( + access_token, + name=unique_name("tmpl_sb_second"), + template_type="singbox_subscription", + content='{"outbounds": [{"tag": "a"}]}', + is_default=True, + ) + + first_after = client.get( + f"/api/core_template/{first['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ).json() + second_after = client.get( + f"/api/core_template/{second['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ).json() + + assert first_after["is_default"] is False + assert second_after["is_default"] is True + + +def test_core_template_last_and_system_delete_guards(access_token): + first = create_core_template( + access_token, + name=unique_name("tmpl_grpc_first"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent"]}', + ) + + response = client.delete( + f"/api/core_template/{first['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + second = create_core_template( + access_token, + name=unique_name("tmpl_grpc_second"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent-2"]}', + ) + + response = client.delete( + f"/api/core_template/{second['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + + response = client.delete( + f"/api/core_template/{first['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN From e2daba99c37d118487e60e4b7d5530490b67e19c Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sat, 21 Feb 2026 12:31:14 +0330 Subject: [PATCH 02/10] fix: migration file --- .../e8c6a4f1d2b7_add_core_templates_table.py | 206 ++++++++++++++++-- 1 file changed, 190 insertions(+), 16 deletions(-) diff --git a/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py b/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py index 0e714e9e7..8c6ae57af 100644 --- a/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py +++ b/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py @@ -6,6 +6,9 @@ """ +import os +from pathlib import Path + from alembic import op import sqlalchemy as sa @@ -17,6 +20,9 @@ depends_on = None +PROJECT_ROOT = Path(__file__).resolve().parents[4] + + DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE = """mode: rule mixed-port: 7890 ipv6: true @@ -216,29 +222,171 @@ } """ -DEFAULT_USER_AGENT_TEMPLATE = """{ - "list": [ +DEFAULT_USER_AGENT_TEMPLATE = """ +{ + "list":[ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36 PageSpeedPlus/1.0.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Mobile Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) Gecko/20100101 Firefox/126.0", - "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Mobile/15E148 Safari/604.1" + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_4_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 11.6; rv:92.0) Gecko/20100101 Firefox/92.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.14 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:126.0) Gecko/20100101 Firefox/126.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/25.0 Chrome/121.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:125.0) Gecko/20100101 Firefox/125.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.16 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.3 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.12 Chrome/120.0.6099.283 Electron/28.2.3 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.16 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 OPR/109.0.0.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.13 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.3 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.4.13 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Safari/605.1.15", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.1 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0", + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.12 Chrome/120.0.6099.283 Electron/28.2.3 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Safari/605.1.15", + "Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.0.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:124.0) Gecko/20100101 Firefox/124.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) obsidian/1.5.3 Chrome/114.0.5735.289 Electron/25.8.1 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.67 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.3 Safari/605.1.15", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Mobile Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", + "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/24.0 Chrome/117.0.0.0 Mobile Safari/537.36" ] } """ -DEFAULT_GRPC_USER_AGENT_TEMPLATE = """{ - "list": [ - "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)", - "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)", - "grpc-go/1.58.1", - "grpc-java-okhttp/1.55.1", - "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)" - ] +DEFAULT_GRPC_USER_AGENT_TEMPLATE = """ +{ + "list": [ + "grpc-dotnet/2.41.0 (.NET 6.0.1; CLR 6.0.1; net6.0; windows; x64)", + "grpc-dotnet/2.41.0 (.NET 6.0.0-preview.7.21377.19; CLR 6.0.0; net6.0; osx; x64)", + "grpc-dotnet/2.41.0 (Mono 6.12.0.140; CLR 4.0.30319; netstandard2.0; osx; x64)", + "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; linux; arm64)", + "grpc-dotnet/2.41.0 (.NET 5.0.8; CLR 5.0.8; net5.0; linux; arm64)", + "grpc-dotnet/2.41.0 (.NET Core; CLR 3.1.4; netstandard2.1; linux; arm64)", + "grpc-dotnet/2.41.0 (.NET Framework; CLR 4.0.30319.42000; netstandard2.0; windows; x86)", + "grpc-dotnet/2.41.0 (.NET 6.0.0-rc.1.21380.1; CLR 6.0.0; net6.0; windows; x64)", + "grpc-python-asyncio/1.62.1 grpc-c/39.0.0 (linux; chttp2)", + "grpc-go/1.58.1", + "grpc-java-okhttp/1.55.1", + "grpc-node/1.7.1 grpc-c/1.7.1 (osx; chttp2)", + "grpc-node/1.24.2 grpc-c/8.0.0 (linux; chttp2; ganges)", + "grpc-c++/1.16.0 grpc-c/6.0.0 (linux; nghttp2; hw)", + "grpc-node/1.19.0 grpc-c/7.0.0 (linux; chttp2; gold)", + "grpc-ruby/1.62.0 grpc-c/39.0.0 (osx; chttp2)]" + ] } """ +def _template_content_or_default( + env_key: str, + path_from_project_root: str, + default_content: str, +) -> str: + env_value = os.getenv(env_key) + if env_value: + return env_value + + custom_templates_directory = os.getenv("CUSTOM_TEMPLATES_DIRECTORY") + if custom_templates_directory: + custom_base_path = Path(custom_templates_directory).expanduser() + project_relative_path = Path(path_from_project_root) + try: + custom_relative_path = project_relative_path.relative_to("app/templates") + except ValueError: + custom_relative_path = project_relative_path + + custom_file_path = custom_base_path / custom_relative_path + try: + if custom_file_path.exists(): + return custom_file_path.read_text(encoding="utf-8") + except OSError: + pass + + file_path = PROJECT_ROOT / path_from_project_root + try: + if file_path.exists(): + return file_path.read_text(encoding="utf-8") + except OSError: + pass + return default_content + + def upgrade() -> None: op.create_table( "core_templates", @@ -253,6 +401,32 @@ def upgrade() -> None: ) op.create_index("ix_core_templates_template_type", "core_templates", ["template_type"], unique=False) + clash_template_content = _template_content_or_default( + "CLASH_SUBSCRIPTION_TEMPLATE", + "app/templates/clash/default.yml", + DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE, + ) + xray_template_content = _template_content_or_default( + "XRAY_SUBSCRIPTION_TEMPLATE", + "app/templates/xray/default.json", + DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE, + ) + singbox_template_content = _template_content_or_default( + "SINGBOX_SUBSCRIPTION_TEMPLATE", + "app/templates/singbox/default.json", + DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE, + ) + user_agent_template_content = _template_content_or_default( + "USER_AGENT_TEMPLATE", + "app/templates/user_agent/default.json", + DEFAULT_USER_AGENT_TEMPLATE, + ) + grpc_user_agent_template_content = _template_content_or_default( + "GRPC_USER_AGENT_TEMPLATE", + "app/templates/user_agent/grpc.json", + DEFAULT_GRPC_USER_AGENT_TEMPLATE, + ) + op.bulk_insert( sa.table( "core_templates", @@ -266,35 +440,35 @@ def upgrade() -> None: { "name": "Default Clash Subscription", "template_type": "clash_subscription", - "content": DEFAULT_CLASH_SUBSCRIPTION_TEMPLATE, + "content": clash_template_content, "is_default": True, "is_system": True, }, { "name": "Default Xray Subscription", "template_type": "xray_subscription", - "content": DEFAULT_XRAY_SUBSCRIPTION_TEMPLATE, + "content": xray_template_content, "is_default": True, "is_system": True, }, { "name": "Default Singbox Subscription", "template_type": "singbox_subscription", - "content": DEFAULT_SINGBOX_SUBSCRIPTION_TEMPLATE, + "content": singbox_template_content, "is_default": True, "is_system": True, }, { "name": "Default User-Agent Template", "template_type": "user_agent", - "content": DEFAULT_USER_AGENT_TEMPLATE, + "content": user_agent_template_content, "is_default": True, "is_system": True, }, { "name": "Default gRPC User-Agent Template", "template_type": "grpc_user_agent", - "content": DEFAULT_GRPC_USER_AGENT_TEMPLATE, + "content": grpc_user_agent_template_content, "is_default": True, "is_system": True, }, From f52e60566afbe56c9f619d1789d75b8b11477a97 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sat, 21 Feb 2026 12:50:24 +0330 Subject: [PATCH 03/10] feat(core-templates): Add core template message handling and caching --- app/app_factory.py | 8 ++++++-- app/nats/message.py | 1 + app/operation/core_template.py | 11 +++++++++++ app/operation/subscription.py | 8 +++----- app/subscription/core_templates.py | 19 +++++++++++++++++++ app/subscription/share.py | 6 ++---- 6 files changed, 42 insertions(+), 11 deletions(-) create mode 100644 app/subscription/core_templates.py diff --git a/app/app_factory.py b/app/app_factory.py index d7df2e648..4f86e3dd6 100644 --- a/app/app_factory.py +++ b/app/app_factory.py @@ -10,6 +10,7 @@ from app.nats.message import MessageTopic from app.nats.router import router from app.settings import handle_settings_message +from app.subscription.core_templates import handle_core_template_message from app.utils.logger import get_logger from app.version import __version__ from config import DOCS, ROLE, SUBSCRIPTION_PATH @@ -24,12 +25,14 @@ def _use_route_names_as_operation_ids(app: FastAPI) -> None: route.operation_id = route.name -def _register_nats_handlers(enable_router: bool, enable_settings: bool): +def _register_nats_handlers(enable_router: bool, enable_settings: bool, enable_core_templates: bool): if enable_router: on_startup(router.start) on_shutdown(router.stop) if enable_settings: router.register_handler(MessageTopic.SETTING, handle_settings_message) + if enable_core_templates: + router.register_handler(MessageTopic.CORE_TEMPLATE, handle_core_template_message) def _register_scheduler_hooks(): @@ -105,7 +108,8 @@ def _validate_paths(): enable_router = ROLE.runs_panel or ROLE.runs_node or ROLE.runs_scheduler enable_settings = ROLE.runs_panel or ROLE.runs_scheduler - _register_nats_handlers(enable_router, enable_settings) + enable_core_templates = ROLE.runs_panel or ROLE.runs_scheduler + _register_nats_handlers(enable_router, enable_settings, enable_core_templates) _register_scheduler_hooks() _register_jobs() diff --git a/app/nats/message.py b/app/nats/message.py index 63e4658c7..04d84710e 100644 --- a/app/nats/message.py +++ b/app/nats/message.py @@ -10,6 +10,7 @@ class MessageTopic(str, Enum): CORE = "core" HOST = "host" SETTING = "setting" + CORE_TEMPLATE = "core_template" NODE = "node" # For future use diff --git a/app/operation/core_template.py b/app/operation/core_template.py index 371556c42..deb923691 100644 --- a/app/operation/core_template.py +++ b/app/operation/core_template.py @@ -25,6 +25,9 @@ CoreTemplatesSimpleResponse, CoreTemplateType, ) +from app.nats.message import MessageTopic +from app.nats.router import router +from app.subscription.core_templates import refresh_core_templates_cache from app.templates import render_template_string from app.utils.logger import get_logger @@ -35,6 +38,11 @@ class CoreTemplateOperation(BaseOperation): + @staticmethod + async def _sync_core_template_cache() -> None: + await refresh_core_templates_cache() + await router.publish(MessageTopic.CORE_TEMPLATE, {"action": "refresh"}) + async def _validate_template_content(self, template_type: CoreTemplateType, content: str) -> None: try: if template_type == CoreTemplateType.clash_subscription: @@ -73,6 +81,7 @@ async def create_core_template( logger.info( f'Core template "{db_template.name}" ({db_template.template_type}) created by admin "{admin.username}"' ) + await self._sync_core_template_cache() return CoreTemplateResponse.model_validate(db_template) async def get_core_templates( @@ -146,6 +155,7 @@ async def modify_core_template( logger.info( f'Core template "{db_template.name}" ({db_template.template_type}) modified by admin "{admin.username}"' ) + await self._sync_core_template_cache() return CoreTemplateResponse.model_validate(db_template) async def remove_core_template(self, db: AsyncSession, template_id: int, admin: AdminDetails) -> None: @@ -169,3 +179,4 @@ async def remove_core_template(self, db: AsyncSession, template_id: int, admin: await set_default_template(db, replacement) logger.info(f'Core template "{db_template.name}" ({template_type.value}) deleted by admin "{admin.username}"') + await self._sync_core_template_cache() diff --git a/app/operation/subscription.py b/app/operation/subscription.py index 01d8b9d89..93a321c31 100644 --- a/app/operation/subscription.py +++ b/app/operation/subscription.py @@ -116,7 +116,7 @@ def create_info_response_headers(user: UsersResponseWithInbounds, sub_settings: return {k: v for k, v in headers.items() if v} async def fetch_config( - self, db: AsyncSession, user: UsersResponseWithInbounds, client_type: ConfigFormat + self, user: UsersResponseWithInbounds, client_type: ConfigFormat ) -> tuple[str, str]: # Get client configuration config = client_config.get(client_type) @@ -126,7 +126,6 @@ async def fetch_config( # Generate subscription content return ( await generate_subscription( - db=db, user=user, config_format=config["config_format"], as_base64=config["as_base64"], @@ -162,7 +161,6 @@ async def user_subscription( links = [] if sub_settings.allow_browser_config: conf, media_type = await self.fetch_config( - db, user, ConfigFormat.links, ) @@ -187,7 +185,7 @@ async def user_subscription( # Update user subscription info await user_sub_update(db, db_user.id, user_agent) - conf, media_type = await self.fetch_config(db, user, client_type) + conf, media_type = await self.fetch_config(user, client_type) # If disable_sub_template is True and it's a browser request, use inline to view instead of download inline_view = sub_settings.disable_sub_template and is_browser_request @@ -220,7 +218,7 @@ async def user_subscription_with_client_type( user = await self.validated_user(db_user) response_headers = self.create_response_headers(user, request_url, sub_settings) - conf, media_type = await self.fetch_config(db, user, client_type) + conf, media_type = await self.fetch_config(user, client_type) # Create response headers return Response(content=conf, media_type=media_type, headers=response_headers) diff --git a/app/subscription/core_templates.py b/app/subscription/core_templates.py new file mode 100644 index 000000000..6bf56941c --- /dev/null +++ b/app/subscription/core_templates.py @@ -0,0 +1,19 @@ +from aiocache import cached + +from app.db import GetDB +from app.db.crud.core_template import get_core_template_values + + +@cached() +async def subscription_core_templates() -> dict[str, str]: + async with GetDB() as db: + return await get_core_template_values(db) + + +async def refresh_core_templates_cache() -> None: + await subscription_core_templates.cache.clear() + + +async def handle_core_template_message(_: dict) -> None: + """Handle core template update messages from NATS router.""" + await refresh_core_templates_cache() diff --git a/app/subscription/share.py b/app/subscription/share.py index 065cc9a50..2af5037ca 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -8,12 +8,11 @@ from jdatetime import date as jd from app.core.hosts import host_manager -from app.db import AsyncSession -from app.db.crud.core_template import get_core_template_values from app.db.models import UserStatus from app.models.subscription import SubscriptionInboundData from app.models.user import UsersResponseWithInbounds from app.utils.system import get_public_ip, get_public_ipv6, readable_size +from app.subscription.core_templates import subscription_core_templates from . import ( ClashConfiguration, @@ -67,14 +66,13 @@ def _build_subscription_config( async def generate_subscription( - db: AsyncSession, user: UsersResponseWithInbounds, config_format: str, as_base64: bool, reverse: bool = False, randomize_order: bool = False, ) -> str: - core_templates = await get_core_template_values(db) + core_templates = await subscription_core_templates() conf = _build_subscription_config(config_format, core_templates) if conf is None: raise ValueError(f'Unsupported format "{config_format}"') From 8c073ebc5c97f6e62a8ca1a411b559672013c2f8 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 22 Feb 2026 14:12:03 +0330 Subject: [PATCH 04/10] try to fix tests --- tests/api/conftest.py | 3 ++- tests/api/test_core_template.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 835aca985..e8d60607f 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -5,13 +5,14 @@ from app.db.models import Settings -from . import TestSession, client +from . import GetTestDB, TestSession, client @pytest.fixture(autouse=True) def mock_db_session(monkeypatch: pytest.MonkeyPatch): db_session = MagicMock(spec=TestSession) monkeypatch.setattr("app.settings.GetDB", db_session) + monkeypatch.setattr("app.subscription.core_templates.GetDB", GetTestDB) return db_session diff --git a/tests/api/test_core_template.py b/tests/api/test_core_template.py index 66c8eafc9..fafd12ec4 100644 --- a/tests/api/test_core_template.py +++ b/tests/api/test_core_template.py @@ -37,7 +37,7 @@ def test_core_template_can_switch_default(access_token): access_token, name=unique_name("tmpl_sb_second"), template_type="singbox_subscription", - content='{"outbounds": [{"tag": "a"}]}', + content='{"outbounds": [{"type": "direct", "tag": "a"}]}', is_default=True, ) From 9cc26bd96b8870ebc499a1eeb88686b7fcc329fb Mon Sep 17 00:00:00 2001 From: Mohammad Date: Sun, 22 Feb 2026 14:50:10 +0330 Subject: [PATCH 05/10] fix test --- tests/api/test_core_template.py | 47 ++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/tests/api/test_core_template.py b/tests/api/test_core_template.py index fafd12ec4..3db001c80 100644 --- a/tests/api/test_core_template.py +++ b/tests/api/test_core_template.py @@ -54,13 +54,24 @@ def test_core_template_can_switch_default(access_token): assert second_after["is_default"] is True -def test_core_template_last_and_system_delete_guards(access_token): - first = create_core_template( - access_token, - name=unique_name("tmpl_grpc_first"), - template_type="grpc_user_agent", - content='{"list": ["grpc-agent"]}', +def test_core_template_cannot_delete_first_template(access_token): + response = client.get( + "/api/core_templates", + params={"template_type": "grpc_user_agent"}, + headers={"Authorization": f"Bearer {access_token}"}, ) + assert response.status_code == status.HTTP_200_OK + templates = response.json()["templates"] + + if templates: + first = min(templates, key=lambda template: template["id"]) + else: + first = create_core_template( + access_token, + name=unique_name("tmpl_grpc_first"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent"]}', + ) response = client.delete( f"/api/core_template/{first['id']}", @@ -68,6 +79,24 @@ def test_core_template_last_and_system_delete_guards(access_token): ) assert response.status_code == status.HTTP_403_FORBIDDEN + +def test_core_template_can_delete_non_first_template(access_token): + response = client.get( + "/api/core_templates", + params={"template_type": "grpc_user_agent"}, + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_200_OK + templates = response.json()["templates"] + + if not templates: + create_core_template( + access_token, + name=unique_name("tmpl_grpc_seed_first"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent-seed"]}', + ) + second = create_core_template( access_token, name=unique_name("tmpl_grpc_second"), @@ -80,9 +109,3 @@ def test_core_template_last_and_system_delete_guards(access_token): headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_204_NO_CONTENT - - response = client.delete( - f"/api/core_template/{first['id']}", - headers={"Authorization": f"Bearer {access_token}"}, - ) - assert response.status_code == status.HTTP_403_FORBIDDEN From bc51081b4b69a4e6b63283ea55a6c42bd460cf9b Mon Sep 17 00:00:00 2001 From: Mohammad Date: Wed, 25 Feb 2026 21:16:13 +0330 Subject: [PATCH 06/10] fix: mmigration id --- .../versions/e8c6a4f1d2b7_add_core_templates_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py b/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py index 8c6ae57af..89889fab8 100644 --- a/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py +++ b/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py @@ -15,7 +15,7 @@ # revision identifiers, used by Alembic. revision = "e8c6a4f1d2b7" -down_revision = "20e2a5cf1e40" +down_revision = "2f3179c6dc49" branch_labels = None depends_on = None From e04849f8869f4c57fa4d16832b48d82346e708e2 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 27 Feb 2026 18:05:41 +0330 Subject: [PATCH 07/10] fix: Add better validations for each template type --- app/operation/core_template.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/app/operation/core_template.py b/app/operation/core_template.py index deb923691..063b6d614 100644 --- a/app/operation/core_template.py +++ b/app/operation/core_template.py @@ -33,7 +33,6 @@ from . import BaseOperation - logger = get_logger("core-template-operation") @@ -58,10 +57,28 @@ async def _validate_template_content(self, template_type: CoreTemplateType, cont rendered = render_template_string(content) parsed = json.loads(rendered) - if template_type in (CoreTemplateType.user_agent, CoreTemplateType.grpc_user_agent) and not isinstance( - parsed, dict - ): - raise ValueError("User-Agent template content must render to a JSON object") + if template_type in (CoreTemplateType.user_agent, CoreTemplateType.grpc_user_agent): + if not isinstance(parsed, dict): + raise ValueError("User-Agent template content must render to a JSON object") + if (_list := parsed.get("list")) is None or not isinstance(_list, list): + raise ValueError("User-Agent template content must contain a 'list' field with an array of strings") + if not _list: + raise ValueError("User-Agent template content must contain at least one User-Agent string") + if template_type in (CoreTemplateType.xray_subscription, CoreTemplateType.singbox_subscription): + if not isinstance(parsed, dict): + raise ValueError("Subscription template content must render to a JSON object") + if (inb := parsed.get("inbounds")) is None or not isinstance(inb, list): + raise ValueError( + "Subscription template content must contain a 'inbounds' field with an array of proxy objects" + ) + if not inb: + raise ValueError("Subscription template content must contain at least one inbound proxy") + if (out := parsed.get("outbounds")) is None or not isinstance(out, list): + raise ValueError( + "Subscription template content must contain a 'outbounds' field with an array of proxy objects" + ) + if not out: + raise ValueError("Subscription template content must contain at least one outbound proxy") except Exception as exc: await self.raise_error(message=f"Invalid template content: {str(exc)}", code=400) @@ -139,7 +156,9 @@ async def modify_core_template( db_template = await self.get_validated_core_template(db, template_id) if modified_template.content is not None: - await self._validate_template_content(CoreTemplateType(db_template.template_type), modified_template.content) + await self._validate_template_content( + CoreTemplateType(db_template.template_type), modified_template.content + ) if modified_template.is_default is False and db_template.is_default: await self.raise_error( From e4dc637593e1b8dbfaabe84aa91d658b787b09a9 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 27 Feb 2026 18:42:07 +0330 Subject: [PATCH 08/10] fix tests --- tests/api/helpers.py | 5 ++--- tests/api/test_core_template.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/api/helpers.py b/tests/api/helpers.py index c4d55b033..cb1634423 100644 --- a/tests/api/helpers.py +++ b/tests/api/helpers.py @@ -6,10 +6,9 @@ from fastapi import status +from config import NATS_ENABLED, ROLE from tests.api import client from tests.api.sample_data import XRAY_CONFIG -from config import ROLE, NATS_ENABLED - _WAIT_FOR_INBOUNDS = ROLE.requires_nats and NATS_ENABLED _INBOUNDS_RETRIES = 10 @@ -82,7 +81,7 @@ def create_core_template( *, name: str | None = None, template_type: str = "xray_subscription", - content: str = '{"outbounds": []}', + content: str = '{"outbounds": [{"tag":"direct","protocol":"freedom","settings":{}}],"inbounds":[{"tag":"proxy","protocol":"vmess","settings":{"clients":[{"id":"uuid","alterId":0,"email":"}', is_default: bool = False, ) -> dict: payload = { diff --git a/tests/api/test_core_template.py b/tests/api/test_core_template.py index 3db001c80..f06f4f652 100644 --- a/tests/api/test_core_template.py +++ b/tests/api/test_core_template.py @@ -31,13 +31,13 @@ def test_core_template_can_switch_default(access_token): access_token, name=unique_name("tmpl_sb_first"), template_type="singbox_subscription", - content='{"outbounds": []}', + content='{"outbounds": [{"type": "direct", "tag": "a"}],inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', ) second = create_core_template( access_token, name=unique_name("tmpl_sb_second"), template_type="singbox_subscription", - content='{"outbounds": [{"type": "direct", "tag": "a"}]}', + content='{"outbounds": [{"type": "direct", "tag": "a"}],inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', is_default=True, ) From 84498e2733c7bff5cd9e3e13f37672a494d08d10 Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 27 Feb 2026 18:57:13 +0330 Subject: [PATCH 09/10] rename core_templates to client_templates --- app/app_factory.py | 12 +- app/db/crud/__init__.py | 4 +- .../{core_template.py => client_template.py} | 138 +++++++++--------- ...8c6a4f1d2b7_add_client_templates_table.py} | 12 +- app/db/models.py | 6 +- .../{core_template.py => client_template.py} | 26 ++-- app/nats/message.py | 2 +- app/operation/__init__.py | 14 +- .../{core_template.py => client_template.py} | 122 ++++++++-------- app/routers/__init__.py | 4 +- app/routers/client_template.py | 96 ++++++++++++ app/routers/core_template.py | 96 ------------ app/subscription/client_templates.py | 19 +++ app/subscription/core_templates.py | 19 --- app/subscription/share.py | 32 ++-- tests/api/conftest.py | 2 +- tests/api/helpers.py | 10 +- ...re_template.py => test_client_template.py} | 36 ++--- 18 files changed, 325 insertions(+), 325 deletions(-) rename app/db/crud/{core_template.py => client_template.py} (50%) rename app/db/migrations/versions/{e8c6a4f1d2b7_add_core_templates_table.py => e8c6a4f1d2b7_add_client_templates_table.py} (98%) rename app/models/{core_template.py => client_template.py} (77%) rename app/operation/{core_template.py => client_template.py} (57%) create mode 100644 app/routers/client_template.py delete mode 100644 app/routers/core_template.py create mode 100644 app/subscription/client_templates.py delete mode 100644 app/subscription/core_templates.py rename tests/api/{test_core_template.py => test_client_template.py} (78%) diff --git a/app/app_factory.py b/app/app_factory.py index 4f86e3dd6..fa6358937 100644 --- a/app/app_factory.py +++ b/app/app_factory.py @@ -10,7 +10,7 @@ from app.nats.message import MessageTopic from app.nats.router import router from app.settings import handle_settings_message -from app.subscription.core_templates import handle_core_template_message +from app.subscription.client_templates import handle_client_template_message from app.utils.logger import get_logger from app.version import __version__ from config import DOCS, ROLE, SUBSCRIPTION_PATH @@ -25,14 +25,14 @@ def _use_route_names_as_operation_ids(app: FastAPI) -> None: route.operation_id = route.name -def _register_nats_handlers(enable_router: bool, enable_settings: bool, enable_core_templates: bool): +def _register_nats_handlers(enable_router: bool, enable_settings: bool, enable_client_templates: bool): if enable_router: on_startup(router.start) on_shutdown(router.stop) if enable_settings: router.register_handler(MessageTopic.SETTING, handle_settings_message) - if enable_core_templates: - router.register_handler(MessageTopic.CORE_TEMPLATE, handle_core_template_message) + if enable_client_templates: + router.register_handler(MessageTopic.CLIENT_TEMPLATE, handle_client_template_message) def _register_scheduler_hooks(): @@ -108,8 +108,8 @@ def _validate_paths(): enable_router = ROLE.runs_panel or ROLE.runs_node or ROLE.runs_scheduler enable_settings = ROLE.runs_panel or ROLE.runs_scheduler - enable_core_templates = ROLE.runs_panel or ROLE.runs_scheduler - _register_nats_handlers(enable_router, enable_settings, enable_core_templates) + enable_client_templates = ROLE.runs_panel or ROLE.runs_scheduler + _register_nats_handlers(enable_router, enable_settings, enable_client_templates) _register_scheduler_hooks() _register_jobs() diff --git a/app/db/crud/__init__.py b/app/db/crud/__init__.py index 73db98080..f59f23678 100644 --- a/app/db/crud/__init__.py +++ b/app/db/crud/__init__.py @@ -1,6 +1,6 @@ from .admin import get_admin from .core import get_core_config_by_id -from .core_template import get_core_template_by_id +from .client_template import get_client_template_by_id from .group import get_group_by_id from .host import get_host_by_id from .node import get_node_by_id @@ -11,7 +11,7 @@ __all__ = [ "get_admin", "get_core_config_by_id", - "get_core_template_by_id", + "get_client_template_by_id", "get_group_by_id", "get_host_by_id", "get_node_by_id", diff --git a/app/db/crud/core_template.py b/app/db/crud/client_template.py similarity index 50% rename from app/db/crud/core_template.py rename to app/db/crud/client_template.py index 4aac750a4..8c83b25f0 100644 --- a/app/db/crud/core_template.py +++ b/app/db/crud/client_template.py @@ -6,37 +6,37 @@ from sqlalchemy.exc import IntegrityError, SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession -from app.db.models import CoreTemplate -from app.models.core_template import CoreTemplateCreate, CoreTemplateModify, CoreTemplateType +from app.db.models import ClientTemplate +from app.models.client_template import ClientTemplateCreate, ClientTemplateModify, ClientTemplateType from app.subscription.default_templates import DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY -TEMPLATE_TYPE_TO_LEGACY_KEY: dict[CoreTemplateType, str] = { - CoreTemplateType.clash_subscription: "CLASH_SUBSCRIPTION_TEMPLATE", - CoreTemplateType.xray_subscription: "XRAY_SUBSCRIPTION_TEMPLATE", - CoreTemplateType.singbox_subscription: "SINGBOX_SUBSCRIPTION_TEMPLATE", - CoreTemplateType.user_agent: "USER_AGENT_TEMPLATE", - CoreTemplateType.grpc_user_agent: "GRPC_USER_AGENT_TEMPLATE", +TEMPLATE_TYPE_TO_LEGACY_KEY: dict[ClientTemplateType, str] = { + ClientTemplateType.clash_subscription: "CLASH_SUBSCRIPTION_TEMPLATE", + ClientTemplateType.xray_subscription: "XRAY_SUBSCRIPTION_TEMPLATE", + ClientTemplateType.singbox_subscription: "SINGBOX_SUBSCRIPTION_TEMPLATE", + ClientTemplateType.user_agent: "USER_AGENT_TEMPLATE", + ClientTemplateType.grpc_user_agent: "GRPC_USER_AGENT_TEMPLATE", } -CoreTemplateSortingOptionsSimple = Enum( - "CoreTemplateSortingOptionsSimple", +ClientTemplateSortingOptionsSimple = Enum( + "ClientTemplateSortingOptionsSimple", { - "id": CoreTemplate.id.asc(), - "-id": CoreTemplate.id.desc(), - "name": CoreTemplate.name.asc(), - "-name": CoreTemplate.name.desc(), - "type": CoreTemplate.template_type.asc(), - "-type": CoreTemplate.template_type.desc(), + "id": ClientTemplate.id.asc(), + "-id": ClientTemplate.id.desc(), + "name": ClientTemplate.name.asc(), + "-name": ClientTemplate.name.desc(), + "type": ClientTemplate.template_type.asc(), + "-type": ClientTemplate.template_type.desc(), }, ) -def get_default_core_template_contents() -> dict[str, str]: +def get_default_client_template_contents() -> dict[str, str]: return DEFAULT_TEMPLATE_CONTENTS_BY_LEGACY_KEY.copy() -def merge_core_template_values(values: Mapping[str, str] | None = None) -> dict[str, str]: - merged = get_default_core_template_contents() +def merge_client_template_values(values: Mapping[str, str] | None = None) -> dict[str, str]: + merged = get_default_client_template_contents() if not values: return merged @@ -47,17 +47,17 @@ def merge_core_template_values(values: Mapping[str, str] | None = None) -> dict[ return merged -async def get_core_template_values(db: AsyncSession) -> dict[str, str]: - defaults = get_default_core_template_contents() +async def get_client_template_values(db: AsyncSession) -> dict[str, str]: + defaults = get_default_client_template_contents() try: rows = ( await db.execute( select( - CoreTemplate.id, - CoreTemplate.template_type, - CoreTemplate.content, - CoreTemplate.is_default, - ).order_by(CoreTemplate.template_type.asc(), CoreTemplate.id.asc()) + ClientTemplate.id, + ClientTemplate.template_type, + ClientTemplate.content, + ClientTemplate.is_default, + ).order_by(ClientTemplate.template_type.asc(), ClientTemplate.id.asc()) ) ).all() except SQLAlchemyError: @@ -85,26 +85,26 @@ async def get_core_template_values(db: AsyncSession) -> dict[str, str]: if selected_content: values[legacy_key] = selected_content - return merge_core_template_values(values) + return merge_client_template_values(values) -async def get_core_template_by_id(db: AsyncSession, template_id: int) -> CoreTemplate | None: - return (await db.execute(select(CoreTemplate).where(CoreTemplate.id == template_id))).unique().scalar_one_or_none() +async def get_client_template_by_id(db: AsyncSession, template_id: int) -> ClientTemplate | None: + return (await db.execute(select(ClientTemplate).where(ClientTemplate.id == template_id))).unique().scalar_one_or_none() -async def get_core_templates( +async def get_client_templates( db: AsyncSession, - template_type: CoreTemplateType | None = None, + template_type: ClientTemplateType | None = None, offset: int | None = None, limit: int | None = None, -) -> tuple[list[CoreTemplate], int]: - query = select(CoreTemplate) +) -> tuple[list[ClientTemplate], int]: + query = select(ClientTemplate) if template_type is not None: - query = query.where(CoreTemplate.template_type == template_type.value) + query = query.where(ClientTemplate.template_type == template_type.value) total = (await db.execute(select(func.count()).select_from(query.subquery()))).scalar() or 0 - query = query.order_by(CoreTemplate.template_type.asc(), CoreTemplate.id.asc()) + query = query.order_by(ClientTemplate.template_type.asc(), ClientTemplate.id.asc()) if offset: query = query.offset(offset) if limit: @@ -114,22 +114,22 @@ async def get_core_templates( return rows, total -async def get_core_templates_simple( +async def get_client_templates_simple( db: AsyncSession, offset: int | None = None, limit: int | None = None, search: str | None = None, - template_type: CoreTemplateType | None = None, - sort: list[CoreTemplateSortingOptionsSimple] | None = None, + template_type: ClientTemplateType | None = None, + sort: list[ClientTemplateSortingOptionsSimple] | None = None, skip_pagination: bool = False, ) -> tuple[list[tuple[int, str, str, bool]], int]: - stmt = select(CoreTemplate.id, CoreTemplate.name, CoreTemplate.template_type, CoreTemplate.is_default) + stmt = select(ClientTemplate.id, ClientTemplate.name, ClientTemplate.template_type, ClientTemplate.is_default) if search: - stmt = stmt.where(CoreTemplate.name.ilike(f"%{search.strip()}%")) + stmt = stmt.where(ClientTemplate.name.ilike(f"%{search.strip()}%")) if template_type is not None: - stmt = stmt.where(CoreTemplate.template_type == template_type.value) + stmt = stmt.where(ClientTemplate.template_type == template_type.value) if sort: sort_list = [] @@ -140,7 +140,7 @@ async def get_core_templates_simple( sort_list.append(s.value) stmt = stmt.order_by(*sort_list) else: - stmt = stmt.order_by(CoreTemplate.template_type.asc(), CoreTemplate.id.asc()) + stmt = stmt.order_by(ClientTemplate.template_type.asc(), ClientTemplate.id.asc()) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar() or 0 @@ -156,30 +156,30 @@ async def get_core_templates_simple( return rows, total -async def count_core_templates_by_type(db: AsyncSession, template_type: CoreTemplateType) -> int: - count_stmt = select(func.count()).select_from(CoreTemplate).where(CoreTemplate.template_type == template_type.value) +async def count_client_templates_by_type(db: AsyncSession, template_type: ClientTemplateType) -> int: + count_stmt = select(func.count()).select_from(ClientTemplate).where(ClientTemplate.template_type == template_type.value) return (await db.execute(count_stmt)).scalar() or 0 async def get_first_template_by_type( db: AsyncSession, - template_type: CoreTemplateType, + template_type: ClientTemplateType, exclude_id: int | None = None, -) -> CoreTemplate | None: +) -> ClientTemplate | None: stmt = ( - select(CoreTemplate) - .where(CoreTemplate.template_type == template_type.value) - .order_by(CoreTemplate.id.asc()) + select(ClientTemplate) + .where(ClientTemplate.template_type == template_type.value) + .order_by(ClientTemplate.id.asc()) ) if exclude_id is not None: - stmt = stmt.where(CoreTemplate.id != exclude_id) + stmt = stmt.where(ClientTemplate.id != exclude_id) return (await db.execute(stmt)).scalars().first() -async def set_default_template(db: AsyncSession, db_template: CoreTemplate) -> CoreTemplate: +async def set_default_template(db: AsyncSession, db_template: ClientTemplate) -> ClientTemplate: await db.execute( - update(CoreTemplate) - .where(CoreTemplate.template_type == db_template.template_type) + update(ClientTemplate) + .where(ClientTemplate.template_type == db_template.template_type) .values(is_default=False) ) db_template.is_default = True @@ -188,22 +188,22 @@ async def set_default_template(db: AsyncSession, db_template: CoreTemplate) -> C return db_template -async def create_core_template(db: AsyncSession, core_template: CoreTemplateCreate) -> CoreTemplate: - type_count = await count_core_templates_by_type(db, core_template.template_type) +async def create_client_template(db: AsyncSession, client_template: ClientTemplateCreate) -> ClientTemplate: + type_count = await count_client_templates_by_type(db, client_template.template_type) is_first_for_type = type_count == 0 - should_be_default = core_template.is_default or is_first_for_type + should_be_default = client_template.is_default or is_first_for_type if should_be_default: await db.execute( - update(CoreTemplate) - .where(CoreTemplate.template_type == core_template.template_type.value) + update(ClientTemplate) + .where(ClientTemplate.template_type == client_template.template_type.value) .values(is_default=False) ) - db_template = CoreTemplate( - name=core_template.name, - template_type=core_template.template_type.value, - content=core_template.content, + db_template = ClientTemplate( + name=client_template.name, + template_type=client_template.template_type.value, + content=client_template.content, is_default=should_be_default, is_system=is_first_for_type, ) @@ -217,17 +217,17 @@ async def create_core_template(db: AsyncSession, core_template: CoreTemplateCrea return db_template -async def modify_core_template( +async def modify_client_template( db: AsyncSession, - db_template: CoreTemplate, - modified_template: CoreTemplateModify, -) -> CoreTemplate: + db_template: ClientTemplate, + modified_template: ClientTemplateModify, +) -> ClientTemplate: template_data = modified_template.model_dump(exclude_none=True) if modified_template.is_default is True: await db.execute( - update(CoreTemplate) - .where(CoreTemplate.template_type == db_template.template_type) + update(ClientTemplate) + .where(ClientTemplate.template_type == db_template.template_type) .values(is_default=False) ) db_template.is_default = True @@ -246,6 +246,6 @@ async def modify_core_template( return db_template -async def remove_core_template(db: AsyncSession, db_template: CoreTemplate) -> None: +async def remove_client_template(db: AsyncSession, db_template: ClientTemplate) -> None: await db.delete(db_template) await db.commit() diff --git a/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py b/app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py similarity index 98% rename from app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py rename to app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py index 89889fab8..aba442311 100644 --- a/app/db/migrations/versions/e8c6a4f1d2b7_add_core_templates_table.py +++ b/app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py @@ -1,4 +1,4 @@ -"""add core_templates table +"""add client_templates table Revision ID: e8c6a4f1d2b7 Revises: 20e2a5cf1e40 @@ -389,7 +389,7 @@ def _template_content_or_default( def upgrade() -> None: op.create_table( - "core_templates", + "client_templates", sa.Column("id", sa.Integer(), nullable=False), sa.Column("name", sa.String(length=64), nullable=False), sa.Column("template_type", sa.String(length=32), nullable=False), @@ -399,7 +399,7 @@ def upgrade() -> None: sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("template_type", "name"), ) - op.create_index("ix_core_templates_template_type", "core_templates", ["template_type"], unique=False) + op.create_index("ix_client_templates_template_type", "client_templates", ["template_type"], unique=False) clash_template_content = _template_content_or_default( "CLASH_SUBSCRIPTION_TEMPLATE", @@ -429,7 +429,7 @@ def upgrade() -> None: op.bulk_insert( sa.table( - "core_templates", + "client_templates", sa.Column("name", sa.String), sa.Column("template_type", sa.String), sa.Column("content", sa.Text), @@ -477,5 +477,5 @@ def upgrade() -> None: def downgrade() -> None: - op.drop_index("ix_core_templates_template_type", table_name="core_templates") - op.drop_table("core_templates") + op.drop_index("ix_client_templates_template_type", table_name="client_templates") + op.drop_table("client_templates") diff --git a/app/db/models.py b/app/db/models.py index 98fd95459..1deecd020 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -722,11 +722,11 @@ class CoreConfig(Base): fallbacks_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set) -class CoreTemplate(Base): - __tablename__ = "core_templates" +class ClientTemplate(Base): + __tablename__ = "client_templates" __table_args__ = ( UniqueConstraint("template_type", "name"), - Index("ix_core_templates_template_type", "template_type"), + Index("ix_client_templates_template_type", "template_type"), ) id: Mapped[int] = mapped_column(primary_key=True, init=False, autoincrement=True) diff --git a/app/models/core_template.py b/app/models/client_template.py similarity index 77% rename from app/models/core_template.py rename to app/models/client_template.py index 4d3d4e8ac..62a5f5586 100644 --- a/app/models/core_template.py +++ b/app/models/client_template.py @@ -3,7 +3,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator -class CoreTemplateType(StrEnum): +class ClientTemplateType(StrEnum): clash_subscription = "clash_subscription" xray_subscription = "xray_subscription" singbox_subscription = "singbox_subscription" @@ -11,9 +11,9 @@ class CoreTemplateType(StrEnum): grpc_user_agent = "grpc_user_agent" -class CoreTemplateBase(BaseModel): +class ClientTemplateBase(BaseModel): name: str = Field(max_length=64) - template_type: CoreTemplateType + template_type: ClientTemplateType content: str is_default: bool = Field(default=False) @@ -33,11 +33,11 @@ def validate_content(cls, value: str) -> str: return value -class CoreTemplateCreate(CoreTemplateBase): +class ClientTemplateCreate(ClientTemplateBase): pass -class CoreTemplateModify(BaseModel): +class ClientTemplateModify(BaseModel): name: str | None = Field(default=None, max_length=64) content: str | None = None is_default: bool | None = None @@ -62,10 +62,10 @@ def validate_content(cls, value: str | None) -> str | None: return value -class CoreTemplateResponse(BaseModel): +class ClientTemplateResponse(BaseModel): id: int name: str - template_type: CoreTemplateType + template_type: ClientTemplateType content: str is_default: bool is_system: bool @@ -73,22 +73,22 @@ class CoreTemplateResponse(BaseModel): model_config = ConfigDict(from_attributes=True) -class CoreTemplateResponseList(BaseModel): +class ClientTemplateResponseList(BaseModel): count: int - templates: list[CoreTemplateResponse] = [] + templates: list[ClientTemplateResponse] = [] model_config = ConfigDict(from_attributes=True) -class CoreTemplateSimple(BaseModel): +class ClientTemplateSimple(BaseModel): id: int name: str - template_type: CoreTemplateType + template_type: ClientTemplateType is_default: bool model_config = ConfigDict(from_attributes=True) -class CoreTemplatesSimpleResponse(BaseModel): - templates: list[CoreTemplateSimple] +class ClientTemplatesSimpleResponse(BaseModel): + templates: list[ClientTemplateSimple] total: int diff --git a/app/nats/message.py b/app/nats/message.py index 04d84710e..ff84d7a33 100644 --- a/app/nats/message.py +++ b/app/nats/message.py @@ -10,7 +10,7 @@ class MessageTopic(str, Enum): CORE = "core" HOST = "host" SETTING = "setting" - CORE_TEMPLATE = "core_template" + CLIENT_TEMPLATE = "client_template" NODE = "node" # For future use diff --git a/app/operation/__init__.py b/app/operation/__init__.py index de643da86..6fcb2e234 100644 --- a/app/operation/__init__.py +++ b/app/operation/__init__.py @@ -8,7 +8,7 @@ from app.db.crud import ( get_admin, get_core_config_by_id, - get_core_template_by_id, + get_client_template_by_id, get_group_by_id, get_host_by_id, get_node_by_id, @@ -18,7 +18,7 @@ from app.db.crud.admin import get_admin_by_id from app.db.crud.group import get_groups_by_ids from app.db.crud.user import get_user_by_id -from app.db.models import Admin as DBAdmin, CoreConfig, CoreTemplate, Group, Node, ProxyHost, User, UserTemplate +from app.db.models import Admin as DBAdmin, CoreConfig, ClientTemplate, Group, Node, ProxyHost, User, UserTemplate from app.models.admin import AdminDetails from app.models.group import BulkGroup from app.models.user import UserCreate, UserModify @@ -227,8 +227,8 @@ async def get_validated_core_config(self, db: AsyncSession, core_id) -> CoreConf await self.raise_error(message="Core config not found", code=404) return db_core_config - async def get_validated_core_template(self, db: AsyncSession, template_id: int) -> CoreTemplate: - db_core_template = await get_core_template_by_id(db, template_id) - if not db_core_template: - await self.raise_error(message="Core template not found", code=404) - return db_core_template + async def get_validated_client_template(self, db: AsyncSession, template_id: int) -> ClientTemplate: + db_client_template = await get_client_template_by_id(db, template_id) + if not db_client_template: + await self.raise_error(message="Client template not found", code=404) + return db_client_template diff --git a/app/operation/core_template.py b/app/operation/client_template.py similarity index 57% rename from app/operation/core_template.py rename to app/operation/client_template.py index 063b6d614..8a7e01afe 100644 --- a/app/operation/core_template.py +++ b/app/operation/client_template.py @@ -4,47 +4,47 @@ from sqlalchemy.exc import IntegrityError from app.db import AsyncSession -from app.db.crud.core_template import ( - CoreTemplateSortingOptionsSimple, - count_core_templates_by_type, - create_core_template, - get_core_templates, - get_core_templates_simple, +from app.db.crud.client_template import ( + ClientTemplateSortingOptionsSimple, + count_client_templates_by_type, + create_client_template, + get_client_templates, + get_client_templates_simple, get_first_template_by_type, - modify_core_template, - remove_core_template, + modify_client_template, + remove_client_template, set_default_template, ) from app.models.admin import AdminDetails -from app.models.core_template import ( - CoreTemplateCreate, - CoreTemplateModify, - CoreTemplateResponse, - CoreTemplateResponseList, - CoreTemplateSimple, - CoreTemplatesSimpleResponse, - CoreTemplateType, +from app.models.client_template import ( + ClientTemplateCreate, + ClientTemplateModify, + ClientTemplateResponse, + ClientTemplateResponseList, + ClientTemplateSimple, + ClientTemplatesSimpleResponse, + ClientTemplateType, ) from app.nats.message import MessageTopic from app.nats.router import router -from app.subscription.core_templates import refresh_core_templates_cache +from app.subscription.client_templates import refresh_client_templates_cache from app.templates import render_template_string from app.utils.logger import get_logger from . import BaseOperation -logger = get_logger("core-template-operation") +logger = get_logger("client-template-operation") -class CoreTemplateOperation(BaseOperation): +class ClientTemplateOperation(BaseOperation): @staticmethod - async def _sync_core_template_cache() -> None: - await refresh_core_templates_cache() - await router.publish(MessageTopic.CORE_TEMPLATE, {"action": "refresh"}) + async def _sync_client_template_cache() -> None: + await refresh_client_templates_cache() + await router.publish(MessageTopic.CLIENT_TEMPLATE, {"action": "refresh"}) - async def _validate_template_content(self, template_type: CoreTemplateType, content: str) -> None: + async def _validate_template_content(self, template_type: ClientTemplateType, content: str) -> None: try: - if template_type == CoreTemplateType.clash_subscription: + if template_type == ClientTemplateType.clash_subscription: rendered = render_template_string( content, { @@ -57,14 +57,14 @@ async def _validate_template_content(self, template_type: CoreTemplateType, cont rendered = render_template_string(content) parsed = json.loads(rendered) - if template_type in (CoreTemplateType.user_agent, CoreTemplateType.grpc_user_agent): + if template_type in (ClientTemplateType.user_agent, ClientTemplateType.grpc_user_agent): if not isinstance(parsed, dict): raise ValueError("User-Agent template content must render to a JSON object") if (_list := parsed.get("list")) is None or not isinstance(_list, list): raise ValueError("User-Agent template content must contain a 'list' field with an array of strings") if not _list: raise ValueError("User-Agent template content must contain at least one User-Agent string") - if template_type in (CoreTemplateType.xray_subscription, CoreTemplateType.singbox_subscription): + if template_type in (ClientTemplateType.xray_subscription, ClientTemplateType.singbox_subscription): if not isinstance(parsed, dict): raise ValueError("Subscription template content must render to a JSON object") if (inb := parsed.get("inbounds")) is None or not isinstance(inb, list): @@ -82,56 +82,56 @@ async def _validate_template_content(self, template_type: CoreTemplateType, cont except Exception as exc: await self.raise_error(message=f"Invalid template content: {str(exc)}", code=400) - async def create_core_template( + async def create_client_template( self, db: AsyncSession, - new_template: CoreTemplateCreate, + new_template: ClientTemplateCreate, admin: AdminDetails, - ) -> CoreTemplateResponse: + ) -> ClientTemplateResponse: await self._validate_template_content(new_template.template_type, new_template.content) try: - db_template = await create_core_template(db, new_template) + db_template = await create_client_template(db, new_template) except IntegrityError: await self.raise_error("Template with this name already exists for this type", 409, db=db) logger.info( - f'Core template "{db_template.name}" ({db_template.template_type}) created by admin "{admin.username}"' + f'Client template "{db_template.name}" ({db_template.template_type}) created by admin "{admin.username}"' ) - await self._sync_core_template_cache() - return CoreTemplateResponse.model_validate(db_template) + await self._sync_client_template_cache() + return ClientTemplateResponse.model_validate(db_template) - async def get_core_templates( + async def get_client_templates( self, db: AsyncSession, - template_type: CoreTemplateType | None = None, + template_type: ClientTemplateType | None = None, offset: int | None = None, limit: int | None = None, - ) -> CoreTemplateResponseList: - templates, count = await get_core_templates(db, template_type=template_type, offset=offset, limit=limit) - return CoreTemplateResponseList(templates=templates, count=count) + ) -> ClientTemplateResponseList: + templates, count = await get_client_templates(db, template_type=template_type, offset=offset, limit=limit) + return ClientTemplateResponseList(templates=templates, count=count) - async def get_core_templates_simple( + async def get_client_templates_simple( self, db: AsyncSession, offset: int | None = None, limit: int | None = None, search: str | None = None, - template_type: CoreTemplateType | None = None, + template_type: ClientTemplateType | None = None, sort: str | None = None, all: bool = False, - ) -> CoreTemplatesSimpleResponse: + ) -> ClientTemplatesSimpleResponse: sort_list = [] if sort is not None: opts = sort.strip(",").split(",") for opt in opts: try: - enum_member = CoreTemplateSortingOptionsSimple[opt] + enum_member = ClientTemplateSortingOptionsSimple[opt] sort_list.append(enum_member) except KeyError: await self.raise_error(message=f'"{opt}" is not a valid sort option', code=400) - rows, total = await get_core_templates_simple( + rows, total = await get_client_templates_simple( db=db, offset=offset, limit=limit, @@ -142,22 +142,22 @@ async def get_core_templates_simple( ) templates = [ - CoreTemplateSimple(id=row[0], name=row[1], template_type=row[2], is_default=row[3]) for row in rows + ClientTemplateSimple(id=row[0], name=row[1], template_type=row[2], is_default=row[3]) for row in rows ] - return CoreTemplatesSimpleResponse(templates=templates, total=total) + return ClientTemplatesSimpleResponse(templates=templates, total=total) - async def modify_core_template( + async def modify_client_template( self, db: AsyncSession, template_id: int, - modified_template: CoreTemplateModify, + modified_template: ClientTemplateModify, admin: AdminDetails, - ) -> CoreTemplateResponse: - db_template = await self.get_validated_core_template(db, template_id) + ) -> ClientTemplateResponse: + db_template = await self.get_validated_client_template(db, template_id) if modified_template.content is not None: await self._validate_template_content( - CoreTemplateType(db_template.template_type), modified_template.content + ClientTemplateType(db_template.template_type), modified_template.content ) if modified_template.is_default is False and db_template.is_default: @@ -167,24 +167,24 @@ async def modify_core_template( ) try: - db_template = await modify_core_template(db, db_template, modified_template) + db_template = await modify_client_template(db, db_template, modified_template) except IntegrityError: await self.raise_error("Template with this name already exists for this type", 409, db=db) logger.info( - f'Core template "{db_template.name}" ({db_template.template_type}) modified by admin "{admin.username}"' + f'Client template "{db_template.name}" ({db_template.template_type}) modified by admin "{admin.username}"' ) - await self._sync_core_template_cache() - return CoreTemplateResponse.model_validate(db_template) + await self._sync_client_template_cache() + return ClientTemplateResponse.model_validate(db_template) - async def remove_core_template(self, db: AsyncSession, template_id: int, admin: AdminDetails) -> None: - db_template = await self.get_validated_core_template(db, template_id) - template_type = CoreTemplateType(db_template.template_type) + async def remove_client_template(self, db: AsyncSession, template_id: int, admin: AdminDetails) -> None: + db_template = await self.get_validated_client_template(db, template_id) + template_type = ClientTemplateType(db_template.template_type) if db_template.is_system: await self.raise_error(message="Cannot delete system template", code=403) - template_count = await count_core_templates_by_type(db, template_type) + template_count = await count_client_templates_by_type(db, template_type) if template_count <= 1: await self.raise_error(message="Cannot delete the last template for this type", code=403) @@ -192,10 +192,10 @@ async def remove_core_template(self, db: AsyncSession, template_id: int, admin: if db_template.is_default: replacement = await get_first_template_by_type(db, template_type, exclude_id=db_template.id) - await remove_core_template(db, db_template) + await remove_client_template(db, db_template) if replacement is not None: await set_default_template(db, replacement) - logger.info(f'Core template "{db_template.name}" ({template_type.value}) deleted by admin "{admin.username}"') - await self._sync_core_template_cache() + logger.info(f'Client template "{db_template.name}" ({template_type.value}) deleted by admin "{admin.username}"') + await self._sync_client_template_cache() diff --git a/app/routers/__init__.py b/app/routers/__init__.py index 69168f64b..03df78fbe 100644 --- a/app/routers/__init__.py +++ b/app/routers/__init__.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from . import admin, core, core_template, group, home, host, node, settings, subscription, system, user, user_template +from . import admin, core, client_template, group, home, host, node, settings, subscription, system, user, user_template api_router = APIRouter() @@ -11,7 +11,7 @@ settings.router, group.router, core.router, - core_template.router, + client_template.router, host.router, node.router, user.router, diff --git a/app/routers/client_template.py b/app/routers/client_template.py new file mode 100644 index 000000000..d2d2638e6 --- /dev/null +++ b/app/routers/client_template.py @@ -0,0 +1,96 @@ +from fastapi import APIRouter, Depends, status + +from app.db import AsyncSession, get_db +from app.models.admin import AdminDetails +from app.models.client_template import ( + ClientTemplateCreate, + ClientTemplateModify, + ClientTemplateResponse, + ClientTemplateResponseList, + ClientTemplatesSimpleResponse, + ClientTemplateType, +) +from app.operation import OperatorType +from app.operation.client_template import ClientTemplateOperation +from app.utils import responses + +from .authentication import check_sudo_admin, get_current + +router = APIRouter( + tags=["Client Template"], + prefix="/api/client_template", + responses={401: responses._401, 403: responses._403}, +) + +client_template_operator = ClientTemplateOperation(OperatorType.API) + + +@router.post("", response_model=ClientTemplateResponse, status_code=status.HTTP_201_CREATED) +async def create_client_template( + new_template: ClientTemplateCreate, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(check_sudo_admin), +): + return await client_template_operator.create_client_template(db, new_template, admin) + + +@router.get("/{template_id}", response_model=ClientTemplateResponse) +async def get_client_template( + template_id: int, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(get_current), +): + return await client_template_operator.get_validated_client_template(db, template_id) + + +@router.put("/{template_id}", response_model=ClientTemplateResponse) +async def modify_client_template( + template_id: int, + modified_template: ClientTemplateModify, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(check_sudo_admin), +): + return await client_template_operator.modify_client_template(db, template_id, modified_template, admin) + + +@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) +async def remove_client_template( + template_id: int, + db: AsyncSession = Depends(get_db), + admin: AdminDetails = Depends(check_sudo_admin), +): + await client_template_operator.remove_client_template(db, template_id, admin) + return {} + + +@router.get("s", response_model=ClientTemplateResponseList) +async def get_client_templates( + template_type: ClientTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(get_current), +): + return await client_template_operator.get_client_templates(db, template_type=template_type, offset=offset, limit=limit) + + +@router.get("s/simple", response_model=ClientTemplatesSimpleResponse) +async def get_client_templates_simple( + template_type: ClientTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, + search: str | None = None, + sort: str | None = None, + all: bool = False, + db: AsyncSession = Depends(get_db), + _: AdminDetails = Depends(get_current), +): + return await client_template_operator.get_client_templates_simple( + db=db, + template_type=template_type, + offset=offset, + limit=limit, + search=search, + sort=sort, + all=all, + ) diff --git a/app/routers/core_template.py b/app/routers/core_template.py deleted file mode 100644 index 0a94902d2..000000000 --- a/app/routers/core_template.py +++ /dev/null @@ -1,96 +0,0 @@ -from fastapi import APIRouter, Depends, status - -from app.db import AsyncSession, get_db -from app.models.admin import AdminDetails -from app.models.core_template import ( - CoreTemplateCreate, - CoreTemplateModify, - CoreTemplateResponse, - CoreTemplateResponseList, - CoreTemplatesSimpleResponse, - CoreTemplateType, -) -from app.operation import OperatorType -from app.operation.core_template import CoreTemplateOperation -from app.utils import responses - -from .authentication import check_sudo_admin, get_current - -router = APIRouter( - tags=["Core Template"], - prefix="/api/core_template", - responses={401: responses._401, 403: responses._403}, -) - -core_template_operator = CoreTemplateOperation(OperatorType.API) - - -@router.post("", response_model=CoreTemplateResponse, status_code=status.HTTP_201_CREATED) -async def create_core_template( - new_template: CoreTemplateCreate, - db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), -): - return await core_template_operator.create_core_template(db, new_template, admin) - - -@router.get("/{template_id}", response_model=CoreTemplateResponse) -async def get_core_template( - template_id: int, - db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), -): - return await core_template_operator.get_validated_core_template(db, template_id) - - -@router.put("/{template_id}", response_model=CoreTemplateResponse) -async def modify_core_template( - template_id: int, - modified_template: CoreTemplateModify, - db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), -): - return await core_template_operator.modify_core_template(db, template_id, modified_template, admin) - - -@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT) -async def remove_core_template( - template_id: int, - db: AsyncSession = Depends(get_db), - admin: AdminDetails = Depends(check_sudo_admin), -): - await core_template_operator.remove_core_template(db, template_id, admin) - return {} - - -@router.get("s", response_model=CoreTemplateResponseList) -async def get_core_templates( - template_type: CoreTemplateType | None = None, - offset: int | None = None, - limit: int | None = None, - db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), -): - return await core_template_operator.get_core_templates(db, template_type=template_type, offset=offset, limit=limit) - - -@router.get("s/simple", response_model=CoreTemplatesSimpleResponse) -async def get_core_templates_simple( - template_type: CoreTemplateType | None = None, - offset: int | None = None, - limit: int | None = None, - search: str | None = None, - sort: str | None = None, - all: bool = False, - db: AsyncSession = Depends(get_db), - _: AdminDetails = Depends(get_current), -): - return await core_template_operator.get_core_templates_simple( - db=db, - template_type=template_type, - offset=offset, - limit=limit, - search=search, - sort=sort, - all=all, - ) diff --git a/app/subscription/client_templates.py b/app/subscription/client_templates.py new file mode 100644 index 000000000..aae8e3bd7 --- /dev/null +++ b/app/subscription/client_templates.py @@ -0,0 +1,19 @@ +from aiocache import cached + +from app.db import GetDB +from app.db.crud.client_template import get_client_template_values + + +@cached() +async def subscription_client_templates() -> dict[str, str]: + async with GetDB() as db: + return await get_client_template_values(db) + + +async def refresh_client_templates_cache() -> None: + await subscription_client_templates.cache.clear() + + +async def handle_client_template_message(_: dict) -> None: + """Handle client template update messages from NATS router.""" + await refresh_client_templates_cache() diff --git a/app/subscription/core_templates.py b/app/subscription/core_templates.py deleted file mode 100644 index 6bf56941c..000000000 --- a/app/subscription/core_templates.py +++ /dev/null @@ -1,19 +0,0 @@ -from aiocache import cached - -from app.db import GetDB -from app.db.crud.core_template import get_core_template_values - - -@cached() -async def subscription_core_templates() -> dict[str, str]: - async with GetDB() as db: - return await get_core_template_values(db) - - -async def refresh_core_templates_cache() -> None: - await subscription_core_templates.cache.clear() - - -async def handle_core_template_message(_: dict) -> None: - """Handle core template update messages from NATS router.""" - await refresh_core_templates_cache() diff --git a/app/subscription/share.py b/app/subscription/share.py index 2af5037ca..544a360d8 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -12,7 +12,7 @@ from app.models.subscription import SubscriptionInboundData from app.models.user import UsersResponseWithInbounds from app.utils.system import get_public_ip, get_public_ipv6, readable_size -from app.subscription.core_templates import subscription_core_templates +from app.subscription.client_templates import subscription_client_templates from . import ( ClashConfiguration, @@ -36,30 +36,30 @@ def _build_subscription_config( config_format: str, - core_templates: dict[str, str], + client_templates: dict[str, str], ) -> StandardLinks | XrayConfiguration | SingBoxConfiguration | ClashConfiguration | ClashMetaConfiguration | OutlineConfiguration | None: common_kwargs = { - "user_agent_template_content": core_templates["USER_AGENT_TEMPLATE"], - "grpc_user_agent_template_content": core_templates["GRPC_USER_AGENT_TEMPLATE"], + "user_agent_template_content": client_templates["USER_AGENT_TEMPLATE"], + "grpc_user_agent_template_content": client_templates["GRPC_USER_AGENT_TEMPLATE"], } if config_format == "links": return StandardLinks(**common_kwargs) if config_format in ("clash", "clash_meta"): return ClashMetaConfiguration( - clash_template_content=core_templates["CLASH_SUBSCRIPTION_TEMPLATE"], + clash_template_content=client_templates["CLASH_SUBSCRIPTION_TEMPLATE"], **common_kwargs, ) if config_format == "sing_box": return SingBoxConfiguration( - singbox_template_content=core_templates["SINGBOX_SUBSCRIPTION_TEMPLATE"], + singbox_template_content=client_templates["SINGBOX_SUBSCRIPTION_TEMPLATE"], **common_kwargs, ) if config_format == "outline": return OutlineConfiguration() if config_format == "xray": return XrayConfiguration( - xray_template_content=core_templates["XRAY_SUBSCRIPTION_TEMPLATE"], + xray_template_content=client_templates["XRAY_SUBSCRIPTION_TEMPLATE"], **common_kwargs, ) return None @@ -72,8 +72,8 @@ async def generate_subscription( reverse: bool = False, randomize_order: bool = False, ) -> str: - core_templates = await subscription_core_templates() - conf = _build_subscription_config(config_format, core_templates) + client_templates = await subscription_client_templates() + conf = _build_subscription_config(config_format, client_templates) if conf is None: raise ValueError(f'Unsupported format "{config_format}"') @@ -83,7 +83,7 @@ async def generate_subscription( user, format_variables, conf, - core_templates, + client_templates, reverse, randomize_order=randomize_order, ) @@ -280,7 +280,7 @@ async def _prepare_download_settings( format_variables: dict, inbounds: list[str], proxies: dict, - core_templates: dict[str, str], + client_templates: dict[str, str], conf: StandardLinks | XrayConfiguration | SingBoxConfiguration @@ -300,9 +300,9 @@ async def _prepare_download_settings( if isinstance(conf, StandardLinks): xc = XrayConfiguration( - xray_template_content=core_templates["XRAY_SUBSCRIPTION_TEMPLATE"], - user_agent_template_content=core_templates["USER_AGENT_TEMPLATE"], - grpc_user_agent_template_content=core_templates["GRPC_USER_AGENT_TEMPLATE"], + xray_template_content=client_templates["XRAY_SUBSCRIPTION_TEMPLATE"], + user_agent_template_content=client_templates["USER_AGENT_TEMPLATE"], + grpc_user_agent_template_content=client_templates["GRPC_USER_AGENT_TEMPLATE"], ) return xc._download_config(download_copy, link_format=True) @@ -318,7 +318,7 @@ async def process_inbounds_and_tags( | ClashConfiguration | ClashMetaConfiguration | OutlineConfiguration, - core_templates: dict[str, str], + client_templates: dict[str, str], reverse=False, randomize_order: bool = False, ) -> list | str: @@ -345,7 +345,7 @@ async def process_inbounds_and_tags( format_variables, user.inbounds, proxy_settings, - core_templates, + client_templates, conf, ) if hasattr(inbound_copy.transport_config, "download_settings"): diff --git a/tests/api/conftest.py b/tests/api/conftest.py index e8d60607f..382130c16 100644 --- a/tests/api/conftest.py +++ b/tests/api/conftest.py @@ -12,7 +12,7 @@ def mock_db_session(monkeypatch: pytest.MonkeyPatch): db_session = MagicMock(spec=TestSession) monkeypatch.setattr("app.settings.GetDB", db_session) - monkeypatch.setattr("app.subscription.core_templates.GetDB", GetTestDB) + monkeypatch.setattr("app.subscription.client_templates.GetDB", GetTestDB) return db_session diff --git a/tests/api/helpers.py b/tests/api/helpers.py index cb1634423..5d9ae255b 100644 --- a/tests/api/helpers.py +++ b/tests/api/helpers.py @@ -76,7 +76,7 @@ def delete_core(access_token: str, core_id: int) -> None: assert response.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_403_FORBIDDEN) -def create_core_template( +def create_client_template( access_token: str, *, name: str | None = None, @@ -85,18 +85,18 @@ def create_core_template( is_default: bool = False, ) -> dict: payload = { - "name": name or unique_name("core_template"), + "name": name or unique_name("client_template"), "template_type": template_type, "content": content, "is_default": is_default, } - response = client.post("/api/core_template", headers=auth_headers(access_token), json=payload) + response = client.post("/api/client_template", headers=auth_headers(access_token), json=payload) assert response.status_code == status.HTTP_201_CREATED return response.json() -def delete_core_template(access_token: str, template_id: int) -> None: - response = client.delete(f"/api/core_template/{template_id}", headers=auth_headers(access_token)) +def delete_client_template(access_token: str, template_id: int) -> None: + response = client.delete(f"/api/client_template/{template_id}", headers=auth_headers(access_token)) assert response.status_code in (status.HTTP_204_NO_CONTENT, status.HTTP_403_FORBIDDEN) diff --git a/tests/api/test_core_template.py b/tests/api/test_client_template.py similarity index 78% rename from tests/api/test_core_template.py rename to tests/api/test_client_template.py index f06f4f652..92d48c2f5 100644 --- a/tests/api/test_core_template.py +++ b/tests/api/test_client_template.py @@ -1,11 +1,11 @@ from fastapi import status from tests.api import client -from tests.api.helpers import create_core_template, unique_name +from tests.api.helpers import create_client_template, unique_name -def test_core_template_create_and_get(access_token): - created = create_core_template( +def test_client_template_create_and_get(access_token): + created = create_client_template( access_token, name=unique_name("tmpl_clash"), template_type="clash_subscription", @@ -19,21 +19,21 @@ def test_core_template_create_and_get(access_token): assert isinstance(created["is_system"], bool) response = client.get( - f"/api/core_template/{created['id']}", + f"/api/client_template/{created['id']}", headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_200_OK assert response.json()["id"] == created["id"] -def test_core_template_can_switch_default(access_token): - first = create_core_template( +def test_client_template_can_switch_default(access_token): + first = create_client_template( access_token, name=unique_name("tmpl_sb_first"), template_type="singbox_subscription", content='{"outbounds": [{"type": "direct", "tag": "a"}],inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', ) - second = create_core_template( + second = create_client_template( access_token, name=unique_name("tmpl_sb_second"), template_type="singbox_subscription", @@ -42,11 +42,11 @@ def test_core_template_can_switch_default(access_token): ) first_after = client.get( - f"/api/core_template/{first['id']}", + f"/api/client_template/{first['id']}", headers={"Authorization": f"Bearer {access_token}"}, ).json() second_after = client.get( - f"/api/core_template/{second['id']}", + f"/api/client_template/{second['id']}", headers={"Authorization": f"Bearer {access_token}"}, ).json() @@ -54,9 +54,9 @@ def test_core_template_can_switch_default(access_token): assert second_after["is_default"] is True -def test_core_template_cannot_delete_first_template(access_token): +def test_client_template_cannot_delete_first_template(access_token): response = client.get( - "/api/core_templates", + "/api/client_templates", params={"template_type": "grpc_user_agent"}, headers={"Authorization": f"Bearer {access_token}"}, ) @@ -66,7 +66,7 @@ def test_core_template_cannot_delete_first_template(access_token): if templates: first = min(templates, key=lambda template: template["id"]) else: - first = create_core_template( + first = create_client_template( access_token, name=unique_name("tmpl_grpc_first"), template_type="grpc_user_agent", @@ -74,15 +74,15 @@ def test_core_template_cannot_delete_first_template(access_token): ) response = client.delete( - f"/api/core_template/{first['id']}", + f"/api/client_template/{first['id']}", headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_403_FORBIDDEN -def test_core_template_can_delete_non_first_template(access_token): +def test_client_template_can_delete_non_first_template(access_token): response = client.get( - "/api/core_templates", + "/api/client_templates", params={"template_type": "grpc_user_agent"}, headers={"Authorization": f"Bearer {access_token}"}, ) @@ -90,14 +90,14 @@ def test_core_template_can_delete_non_first_template(access_token): templates = response.json()["templates"] if not templates: - create_core_template( + create_client_template( access_token, name=unique_name("tmpl_grpc_seed_first"), template_type="grpc_user_agent", content='{"list": ["grpc-agent-seed"]}', ) - second = create_core_template( + second = create_client_template( access_token, name=unique_name("tmpl_grpc_second"), template_type="grpc_user_agent", @@ -105,7 +105,7 @@ def test_core_template_can_delete_non_first_template(access_token): ) response = client.delete( - f"/api/core_template/{second['id']}", + f"/api/client_template/{second['id']}", headers={"Authorization": f"Bearer {access_token}"}, ) assert response.status_code == status.HTTP_204_NO_CONTENT From affff60275e9d15098a6e3f3d7e0863c73d74a4b Mon Sep 17 00:00:00 2001 From: Mohammad Date: Fri, 27 Feb 2026 19:01:46 +0330 Subject: [PATCH 10/10] fix test --- tests/api/test_client_template.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/api/test_client_template.py b/tests/api/test_client_template.py index 92d48c2f5..f68908d68 100644 --- a/tests/api/test_client_template.py +++ b/tests/api/test_client_template.py @@ -31,13 +31,13 @@ def test_client_template_can_switch_default(access_token): access_token, name=unique_name("tmpl_sb_first"), template_type="singbox_subscription", - content='{"outbounds": [{"type": "direct", "tag": "a"}],inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', + content='{"outbounds": [{"type": "direct", "tag": "a"}],"inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', ) second = create_client_template( access_token, name=unique_name("tmpl_sb_second"), template_type="singbox_subscription", - content='{"outbounds": [{"type": "direct", "tag": "a"}],inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', + content='{"outbounds": [{"type": "direct", "tag": "a"}],"inbounds":[{"type": "socks5","tag":"b","settings":{"clients":[{"username":"user","password":"pass"}]}}]}', is_default=True, )