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/app_factory.py b/app/app_factory.py index d7df2e648..fa6358937 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.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 @@ -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_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_client_templates: + router.register_handler(MessageTopic.CLIENT_TEMPLATE, handle_client_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_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 9197113af..f59f23678 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 .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 @@ -10,6 +11,7 @@ __all__ = [ "get_admin", "get_core_config_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/client_template.py b/app/db/crud/client_template.py new file mode 100644 index 000000000..c5c8e8dc6 --- /dev/null +++ b/app/db/crud/client_template.py @@ -0,0 +1,232 @@ +from collections import defaultdict +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 ClientTemplate +from app.models.client_template import ClientTemplateCreate, ClientTemplateModify, ClientTemplateType + +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", +} + +ClientTemplateSortingOptionsSimple = Enum( + "ClientTemplateSortingOptionsSimple", + { + "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(), + }, +) + + +async def get_client_template_values(db: AsyncSession) -> dict[str, str]: + try: + rows = ( + await db.execute( + select( + ClientTemplate.id, + ClientTemplate.template_type, + ClientTemplate.content, + ClientTemplate.is_default, + ).order_by(ClientTemplate.template_type.asc(), ClientTemplate.id.asc()) + ) + ).all() + except SQLAlchemyError: + return {} + + 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 values + + +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_client_templates( + db: AsyncSession, + template_type: ClientTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, +) -> tuple[list[ClientTemplate], int]: + query = select(ClientTemplate) + if template_type is not None: + 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(ClientTemplate.template_type.asc(), ClientTemplate.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_client_templates_simple( + db: AsyncSession, + offset: int | None = None, + limit: int | None = None, + search: str | 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(ClientTemplate.id, ClientTemplate.name, ClientTemplate.template_type, ClientTemplate.is_default) + + if search: + stmt = stmt.where(ClientTemplate.name.ilike(f"%{search.strip()}%")) + + if template_type is not None: + stmt = stmt.where(ClientTemplate.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(ClientTemplate.template_type.asc(), ClientTemplate.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_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: ClientTemplateType, + exclude_id: int | None = None, +) -> ClientTemplate | None: + stmt = ( + select(ClientTemplate) + .where(ClientTemplate.template_type == template_type.value) + .order_by(ClientTemplate.id.asc()) + ) + if exclude_id is not None: + stmt = stmt.where(ClientTemplate.id != exclude_id) + return (await db.execute(stmt)).scalars().first() + + +async def set_default_template(db: AsyncSession, db_template: ClientTemplate) -> ClientTemplate: + await db.execute( + update(ClientTemplate) + .where(ClientTemplate.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_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 = client_template.is_default or is_first_for_type + + if should_be_default: + await db.execute( + update(ClientTemplate) + .where(ClientTemplate.template_type == client_template.template_type.value) + .values(is_default=False) + ) + + 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, + ) + 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_client_template( + db: AsyncSession, + 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(ClientTemplate) + .where(ClientTemplate.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_client_template(db: AsyncSession, db_template: ClientTemplate) -> None: + await db.delete(db_template) + await db.commit() diff --git a/app/templates/user_agent/default.json b/app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py similarity index 56% rename from app/templates/user_agent/default.json rename to app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py index abb85644c..4bc6e2f25 100644 --- a/app/templates/user_agent/default.json +++ b/app/db/migrations/versions/e8c6a4f1d2b7_add_client_templates_table.py @@ -1,4 +1,234 @@ -{ +"""add client_templates table + +Revision ID: e8c6a4f1d2b7 +Revises: 20e2a5cf1e40 +Create Date: 2026-02-20 15:45:00.000000 + +""" + +import os +from pathlib import Path + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "e8c6a4f1d2b7" +down_revision = "2f3179c6dc49" +branch_labels = None +depends_on = None + + +PROJECT_ROOT = Path(__file__).resolve().parents[4] + + +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": [ + { + "protocol": "freedom", + "tag": "DIRECT" + }, + { + "protocol": "blackhole", + "tag": "BLOCK" + } + ], + "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 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", @@ -101,4 +331,153 @@ "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 +}""" + +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( + "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), + 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_client_templates_template_type", "client_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( + "client_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": clash_template_content, + "is_default": True, + "is_system": True, + }, + { + "name": "Default Xray Subscription", + "template_type": "xray_subscription", + "content": xray_template_content, + "is_default": True, + "is_system": True, + }, + { + "name": "Default Singbox Subscription", + "template_type": "singbox_subscription", + "content": singbox_template_content, + "is_default": True, + "is_system": True, + }, + { + "name": "Default User-Agent Template", + "template_type": "user_agent", + "content": user_agent_template_content, + "is_default": True, + "is_system": True, + }, + { + "name": "Default gRPC User-Agent Template", + "template_type": "grpc_user_agent", + "content": grpc_user_agent_template_content, + "is_default": True, + "is_system": True, + }, + ], + ) + + +def downgrade() -> None: + 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 026ad903e..1deecd020 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -13,6 +13,7 @@ ForeignKey, Index, String, + Text, Table, UniqueConstraint, and_, @@ -721,6 +722,21 @@ class CoreConfig(Base): fallbacks_inbound_tags: Mapped[Optional[set[str]]] = mapped_column(StringArray(2048), default_factory=set) +class ClientTemplate(Base): + __tablename__ = "client_templates" + __table_args__ = ( + UniqueConstraint("template_type", "name"), + Index("ix_client_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/client_template.py b/app/models/client_template.py new file mode 100644 index 000000000..62a5f5586 --- /dev/null +++ b/app/models/client_template.py @@ -0,0 +1,94 @@ +from enum import StrEnum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class ClientTemplateType(StrEnum): + clash_subscription = "clash_subscription" + xray_subscription = "xray_subscription" + singbox_subscription = "singbox_subscription" + user_agent = "user_agent" + grpc_user_agent = "grpc_user_agent" + + +class ClientTemplateBase(BaseModel): + name: str = Field(max_length=64) + template_type: ClientTemplateType + 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 ClientTemplateCreate(ClientTemplateBase): + pass + + +class ClientTemplateModify(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 ClientTemplateResponse(BaseModel): + id: int + name: str + template_type: ClientTemplateType + content: str + is_default: bool + is_system: bool + + model_config = ConfigDict(from_attributes=True) + + +class ClientTemplateResponseList(BaseModel): + count: int + templates: list[ClientTemplateResponse] = [] + + model_config = ConfigDict(from_attributes=True) + + +class ClientTemplateSimple(BaseModel): + id: int + name: str + template_type: ClientTemplateType + is_default: bool + + model_config = ConfigDict(from_attributes=True) + + +class ClientTemplatesSimpleResponse(BaseModel): + templates: list[ClientTemplateSimple] + total: int diff --git a/app/nats/message.py b/app/nats/message.py index 63e4658c7..ff84d7a33 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" + CLIENT_TEMPLATE = "client_template" NODE = "node" # For future use diff --git a/app/operation/__init__.py b/app/operation/__init__.py index 51eec682f..6fcb2e234 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_client_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, 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 @@ -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_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/client_template.py b/app/operation/client_template.py new file mode 100644 index 000000000..8a7e01afe --- /dev/null +++ b/app/operation/client_template.py @@ -0,0 +1,201 @@ +import json + +import yaml +from sqlalchemy.exc import IntegrityError + +from app.db import AsyncSession +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_client_template, + remove_client_template, + set_default_template, +) +from app.models.admin import AdminDetails +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.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("client-template-operation") + + +class ClientTemplateOperation(BaseOperation): + @staticmethod + 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: ClientTemplateType, content: str) -> None: + try: + if template_type == ClientTemplateType.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 (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 (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): + 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) + + async def create_client_template( + self, + db: AsyncSession, + new_template: ClientTemplateCreate, + admin: AdminDetails, + ) -> ClientTemplateResponse: + await self._validate_template_content(new_template.template_type, new_template.content) + + try: + 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'Client template "{db_template.name}" ({db_template.template_type}) created by admin "{admin.username}"' + ) + await self._sync_client_template_cache() + return ClientTemplateResponse.model_validate(db_template) + + async def get_client_templates( + self, + db: AsyncSession, + template_type: ClientTemplateType | None = None, + offset: int | None = None, + limit: int | None = None, + ) -> 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_client_templates_simple( + self, + db: AsyncSession, + offset: int | None = None, + limit: int | None = None, + search: str | None = None, + template_type: ClientTemplateType | None = None, + sort: str | None = None, + all: bool = False, + ) -> ClientTemplatesSimpleResponse: + sort_list = [] + if sort is not None: + opts = sort.strip(",").split(",") + for opt in opts: + try: + 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_client_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 = [ + ClientTemplateSimple(id=row[0], name=row[1], template_type=row[2], is_default=row[3]) for row in rows + ] + return ClientTemplatesSimpleResponse(templates=templates, total=total) + + async def modify_client_template( + self, + db: AsyncSession, + template_id: int, + modified_template: ClientTemplateModify, + admin: AdminDetails, + ) -> ClientTemplateResponse: + db_template = await self.get_validated_client_template(db, template_id) + + if modified_template.content is not None: + await self._validate_template_content( + ClientTemplateType(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_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'Client template "{db_template.name}" ({db_template.template_type}) modified by admin "{admin.username}"' + ) + await self._sync_client_template_cache() + return ClientTemplateResponse.model_validate(db_template) + + 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_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) + + replacement = None + if db_template.is_default: + replacement = await get_first_template_by_type(db, template_type, exclude_id=db_template.id) + + await remove_client_template(db, db_template) + + if replacement is not None: + await set_default_template(db, replacement) + + 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/operation/subscription.py b/app/operation/subscription.py index e2d0e048a..93a321c31 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, user: UsersResponseWithInbounds, client_type: ConfigFormat + ) -> tuple[str, str]: # Get client configuration config = client_config.get(client_type) sub_settings = await subscription_settings() diff --git a/app/routers/__init__.py b/app/routers/__init__.py index ccf06c431..03df78fbe 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, client_template, group, home, host, node, settings, subscription, system, user, user_template api_router = APIRouter() @@ -11,6 +11,7 @@ settings.router, group.router, core.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/subscription/base.py b/app/subscription/base.py index 7841f4c7a..8c5cf15bb 100644 --- a/app/subscription/base.py +++ b/app/subscription/base.py @@ -4,20 +4,24 @@ 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 class BaseSubscription: - def __init__(self): + def __init__( + self, + user_agent_template_content: str | None = None, + grpc_user_agent_template_content: str | None = None, + ): + 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..b8416e67c 100644 --- a/app/subscription/clash.py +++ b/app/subscription/clash.py @@ -10,18 +10,24 @@ 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 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 self.data = { "proxies": [], "proxy-groups": [], @@ -55,7 +61,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 +297,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/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/links.py b/app/subscription/links.py index 7a4025b08..6d6b948ae 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..544a360d8 100644 --- a/app/subscription/share.py +++ b/app/subscription/share.py @@ -12,6 +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.client_templates import subscription_client_templates from . import ( ClashConfiguration, @@ -33,15 +34,35 @@ "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, + client_templates: dict[str, str], +) -> StandardLinks | XrayConfiguration | SingBoxConfiguration | ClashConfiguration | ClashMetaConfiguration | OutlineConfiguration | None: + common_kwargs = { + "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=client_templates["CLASH_SUBSCRIPTION_TEMPLATE"], + **common_kwargs, + ) + if config_format == "sing_box": + return SingBoxConfiguration( + 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=client_templates["XRAY_SUBSCRIPTION_TEMPLATE"], + **common_kwargs, + ) + return None async def generate_subscription( @@ -51,13 +72,21 @@ async def generate_subscription( reverse: bool = False, randomize_order: bool = False, ) -> str: - conf = config_format_handler.get(config_format, None) + 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}"') 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, + client_templates, + reverse, + randomize_order=randomize_order, + ) if as_base64: config = base64.b64encode(config.encode()).decode() @@ -251,6 +280,7 @@ async def _prepare_download_settings( format_variables: dict, inbounds: list[str], proxies: dict, + client_templates: dict[str, str], conf: StandardLinks | XrayConfiguration | SingBoxConfiguration @@ -269,7 +299,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=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) return download_copy @@ -284,6 +318,7 @@ async def process_inbounds_and_tags( | ClashConfiguration | ClashMetaConfiguration | OutlineConfiguration, + client_templates: dict[str, str], reverse=False, randomize_order: bool = False, ) -> list | str: @@ -310,6 +345,7 @@ async def process_inbounds_and_tags( format_variables, user.inbounds, proxy_settings, + client_templates, conf, ) if hasattr(inbound_copy.transport_config, "download_settings"): diff --git a/app/subscription/singbox.py b/app/subscription/singbox.py index e4b6ac0b7..55a4646bb 100644 --- a/app/subscription/singbox.py +++ b/app/subscription/singbox.py @@ -8,17 +8,24 @@ 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 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, + ) + 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 b4f5965a7..558ab2713 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -11,18 +11,25 @@ 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 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) + 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/README.md b/app/templates/clash/README.md deleted file mode 100644 index 56e05057b..000000000 --- a/app/templates/clash/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Clash Template - -## Usage - -- Can be used to send completely prepared config to users depend on your usage. - -## Config Template - -- With the config template, you can change things like routing and rules. - -## How To Use - -First of all, you need to set a directory for all of your templates (home, subscription page, etc.). - -```shell -CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/" -``` - -Make sure you put all of your templates in this folder.\ -If you are using Docker, make sure Docker has access to this folder.\ -Then, we need to make a directory for our Clash template. - -```shell -mkdir -p /var/lib/pasarguard/templates/clash -``` - -After that, put your templates (config and settings) in the directory.\ -Now, change these variables with your files' names. - -```shell -CLASH_SUBSCRIPTION_TEMPLATE = "clash/default.yml" -``` - -Now, restart your PasarGuard and enjoy. - -If you have already changed your env variables, and you want to just update the template files, there is no need to restart PasarGuard. - -## Docs - -you can use these docs to find out how to modify template files - -[Mihomo Docs](https://wiki.metacubex.one/en/) 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/README.md b/app/templates/singbox/README.md deleted file mode 100644 index 7daa7ced1..000000000 --- a/app/templates/singbox/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# Sing-box Template - -## Usage - -- Can be used to send completely prepared config to users depend on your usage. - -## Config Template - -- With the config template, you can change things like routing and rules. - -## How To Use - -First of all, you need to set a directory for all of your templates (home, subscription page, etc.). - -```shell -CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/" -``` - -Make sure you put all of your templates in this folder.\ -If you are using Docker, make sure Docker has access to this folder.\ -Then, we need to make a directory for our Sing-box template. - -```shell -mkdir -p /var/lib/pasarguard/templates/singbox -``` - -After that, put your templates (config and settings) in the directory.\ -Now, change these variables with your files' names. - -```shell -SINGBOX_SUBSCRIPTION_TEMPLATE="singbox/default.json" -``` - -Now, restart your PasarGuard and enjoy. - -If you have already changed your env variables, and you want to just update the template files, there is no need to restart PasarGuard. - -## Docs - -you can use sing-box official documentation to find out how to modify template files - -[Sing-Box documentation](https://sing-box.sagernet.org/configuration/) 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/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/README.md b/app/templates/xray/README.md deleted file mode 100644 index e4cbc67f1..000000000 --- a/app/templates/xray/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# Xray Template - -## Usage - -- Can be used to send completely prepared config to users and avoid application default values. - -## Config Template - -- With the config template, you can change things like routing and rules. - -## How To Use - -First of all, you need to set a directory for all of your templates (home, subscription page, etc.). - -```shell -CUSTOM_TEMPLATES_DIRECTORY="/var/lib/pasarguard/templates/" -``` - -Make sure you put all of your templates in this folder.\ -If you are using Docker, make sure Docker has access to this folder.\ -Then, we need to make a directory for our Xray template. - -```shell -mkdir -p /var/lib/pasarguard/templates/xray -``` - -After that, put your templates (config and settings) in the directory.\ -Now, change these variables with your files' names. - -```shell -XRAY_SUBSCRIPTION_TEMPLATE="xray/default.json" -``` - -Now, restart your PasarGuard and enjoy. - -If you have already changed your env variables, and you want to just update the template files, there is no need to restart PasarGuard. - -## Docs - -you can use these docs to find out how to modify template files - -[Xray Docs](https://xtls.github.io/en/) \ -[Xray Examples](https://github.com/XTLS/Xray-examples) \ -[Xray Examples](https://github.com/chika0801/Xray-examples) Unofficial 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/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index 558bcd743..15f070ed4 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -644,7 +644,8 @@ "warning": "Warning", "remove": "Remove", "modify": "Modify", - "templates.userTemplates": "Templates", + "templates.userTemplates": "User Templates", + "templates.clientTemplates": "Client Templates", "editUserTemplateModal.title": "Modify User Template", "userTemplateModal.title": "Create User Template", "templates": { @@ -684,6 +685,30 @@ "noTemplatesDescription": "Get started by creating your first user template.", "noSearchResults": "No templates match your search criteria. Try adjusting your search terms." }, + "clientTemplates": { + "title": "Client Templates", + "description": "Manage subscription and user-agent templates", + "addTemplate": "Create Template", + "editTemplate": "Edit Client Template", + "templateType": "Template Type", + "selectType": "Select type", + "content": "Content", + "contentPlaceholder": "Template content...", + "namePlaceholder": "Template name", + "isDefault": "Set as default", + "createSuccess": "Template \"{{name}}\" created successfully", + "updateSuccess": "Template \"{{name}}\" updated successfully", + "deleteSuccess": "Template \"{{name}}\" deleted successfully", + "deleteFailed": "Failed to delete template \"{{name}}\"", + "duplicateSuccess": "Template \"{{name}}\" duplicated successfully", + "duplicateFailed": "Failed to duplicate template \"{{name}}\"", + "saveFailed": "Failed to save template", + "deleteTitle": "Delete Client Template", + "deletePrompt": "Are you sure you want to delete {{name}}? This action cannot be undone.", + "noTemplates": "No client templates", + "noTemplatesDescription": "Create a client template to customize subscription output formats.", + "noSearchResults": "No client templates match your search." + }, "core.configuration": "Configuration", "core.generalErrorMessage": "Something went wrong, please check the configuration", "core.logs": "Logs", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 5ed20abdf..876dcc186 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -532,7 +532,8 @@ "warning": "هشدار", "remove": "حذف", "modify": "ویرایش", - "templates.userTemplates": "قالب‌ها", + "templates.userTemplates": "قالب‌های کاربر", + "templates.clientTemplates": "قالب‌های کلاینت", "editUserTemplateModal.title": "ویرایش قالب کاربر", "userTemplateModal.title": "ایجاد قالب کاربر", "templates": { @@ -572,6 +573,30 @@ "noTemplatesDescription": "با ایجاد اولین قالب کاربر شروع کنید.", "noSearchResults": "هیچ قالبی با معیارهای جستجوی شما مطابقت ندارد. لطفاً عبارات جستجوی خود را تغییر دهید." }, + "clientTemplates": { + "title": "قالب‌های کلاینت", + "description": "مدیریت قالب‌های اشتراک و عامل کاربر", + "addTemplate": "ایجاد قالب", + "editTemplate": "ویرایش قالب کلاینت", + "templateType": "نوع قالب", + "selectType": "انتخاب نوع", + "content": "محتوا", + "contentPlaceholder": "محتوای قالب...", + "namePlaceholder": "نام قالب", + "isDefault": "تنظیم به عنوان پیش‌فرض", + "createSuccess": "قالب «{{name}}» با موفقیت ایجاد شد", + "updateSuccess": "قالب «{{name}}» با موفقیت به‌روزرسانی شد", + "deleteSuccess": "قالب «{{name}}» با موفقیت حذف شد", + "deleteFailed": "حذف قالب «{{name}}» ناموفق بود", + "duplicateSuccess": "قالب «{{name}}» با موفقیت تکثیر شد", + "duplicateFailed": "تکثیر قالب «{{name}}» ناموفق بود", + "saveFailed": "ذخیره قالب ناموفق بود", + "deleteTitle": "حذف قالب کلاینت", + "deletePrompt": "آیا مطمئن هستید که می‌خواهید {{name}} را حذف کنید؟ این عمل قابل بازگشت نیست.", + "noTemplates": "قالب کلاینتی وجود ندارد", + "noTemplatesDescription": "یک قالب کلاینت ایجاد کنید تا فرمت‌های خروجی اشتراک را سفارشی کنید.", + "noSearchResults": "هیچ قالب کلاینتی با جستجوی شما مطابقت ندارد." + }, "core.configuration": "پیکربندی", "core.generalErrorMessage": "مشکلی پیش آمده، لطفا پیکربندی را بررسی کنید", "core.logs": "گزارش", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 1e6370eeb..f4d149246 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -630,7 +630,8 @@ "warning": "Предупреждение", "remove": "Удалить", "modify": "Изменить", - "templates.userTemplates": "Шаблоны", + "templates.userTemplates": "Шаблоны пользователей", + "templates.clientTemplates": "Шаблоны клиентов", "editUserTemplateModal.title": "Редактировать шаблон пользователя", "userTemplateModal.title": "Создать шаблон пользователя", "templates": { @@ -670,6 +671,30 @@ "noTemplatesDescription": "Начните с создания первого шаблона пользователя.", "noSearchResults": "Нет шаблонов, соответствующих вашим критериям поиска. Попробуйте изменить условия поиска." }, + "clientTemplates": { + "title": "Шаблоны клиентов", + "description": "Управление шаблонами подписок и пользовательских агентов", + "addTemplate": "Создать шаблон", + "editTemplate": "Редактировать шаблон клиента", + "templateType": "Тип шаблона", + "selectType": "Выберите тип", + "content": "Содержимое", + "contentPlaceholder": "Содержимое шаблона...", + "namePlaceholder": "Название шаблона", + "isDefault": "Установить по умолчанию", + "createSuccess": "Шаблон «{{name}}» успешно создан", + "updateSuccess": "Шаблон «{{name}}» успешно обновлён", + "deleteSuccess": "Шаблон «{{name}}» успешно удалён", + "deleteFailed": "Не удалось удалить шаблон «{{name}}»", + "duplicateSuccess": "Шаблон «{{name}}» успешно продублирован", + "duplicateFailed": "Не удалось продублировать шаблон «{{name}}»", + "saveFailed": "Не удалось сохранить шаблон", + "deleteTitle": "Удалить шаблон клиента", + "deletePrompt": "Вы уверены, что хотите удалить {{name}}? Это действие нельзя отменить.", + "noTemplates": "Нет шаблонов клиентов", + "noTemplatesDescription": "Создайте шаблон клиента для настройки форматов вывода подписок.", + "noSearchResults": "Нет шаблонов клиентов, соответствующих вашему запросу." + }, "core.configuration": "Конфигурация", "core.generalErrorMessage": "Что-то пошло не так, пожалуйста, проверьте конфигурацию", "core.logs": "Логи", diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 6dace73b1..4a414dfd7 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -644,7 +644,8 @@ "warning": "警告", "remove": "删除", "modify": "修改", - "templates.userTemplates": "模板", + "templates.userTemplates": "用户模板", + "templates.clientTemplates": "客户端模板", "editUserTemplateModal.title": "编辑用户模板", "userTemplateModal.title": "创建用户模板", "templates": { @@ -684,6 +685,30 @@ "noTemplatesDescription": "开始创建您的第一个用户模板。", "noSearchResults": "没有模板匹配您的搜索条件。请尝试调整搜索词。" }, + "clientTemplates": { + "title": "客户端模板", + "description": "管理订阅和用户代理模板", + "addTemplate": "创建模板", + "editTemplate": "编辑客户端模板", + "templateType": "模板类型", + "selectType": "选择类型", + "content": "内容", + "contentPlaceholder": "模板内容...", + "namePlaceholder": "模板名称", + "isDefault": "设为默认", + "createSuccess": "模板「{{name}}」创建成功", + "updateSuccess": "模板「{{name}}」更新成功", + "deleteSuccess": "模板「{{name}}」删除成功", + "deleteFailed": "删除模板「{{name}}」失败", + "duplicateSuccess": "模板「{{name}}」复制成功", + "duplicateFailed": "复制模板「{{name}}」失败", + "saveFailed": "保存模板失败", + "deleteTitle": "删除客户端模板", + "deletePrompt": "确定要删除 {{name}} 吗?此操作无法撤销。", + "noTemplates": "暂无客户端模板", + "noTemplatesDescription": "创建客户端模板以自定义订阅输出格式。", + "noSearchResults": "没有匹配的客户端模板。" + }, "core.configuration": "配置", "core.generalErrorMessage": "配置有误, 请检查", "core.logs": "日志", diff --git a/dashboard/src/components/common/mobile-yaml-ace-editor.tsx b/dashboard/src/components/common/mobile-yaml-ace-editor.tsx new file mode 100644 index 000000000..96bc2d7de --- /dev/null +++ b/dashboard/src/components/common/mobile-yaml-ace-editor.tsx @@ -0,0 +1,43 @@ +import AceEditor from 'react-ace' +import 'ace-builds/src-noconflict/mode-yaml' +import 'ace-builds/src-noconflict/theme-monokai' +import 'ace-builds/src-noconflict/theme-tomorrow_night' + +interface MobileYamlAceEditorProps { + value: string + theme?: string + onChange: (value: string) => void + onLoad?: (editor: any) => void +} + +export default function MobileYamlAceEditor({ value, theme, onChange, onLoad }: MobileYamlAceEditorProps) { + return ( + + ) +} diff --git a/dashboard/src/components/dialogs/client-template-modal.tsx b/dashboard/src/components/dialogs/client-template-modal.tsx new file mode 100644 index 000000000..24549c33e --- /dev/null +++ b/dashboard/src/components/dialogs/client-template-modal.tsx @@ -0,0 +1,337 @@ +import { useTheme } from '@/components/common/theme-provider' +import type { ClientTemplateFormValues } from '@/components/forms/client-template-form' +import { DEFAULT_TEMPLATE_CONTENT } from '@/components/forms/client-template-form' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import { LoaderButton } from '@/components/ui/loader-button' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Switch } from '@/components/ui/switch' +import useDirDetection from '@/hooks/use-dir-detection' +import { useIsMobile } from '@/hooks/use-mobile' +import { cn } from '@/lib/utils' +import { ClientTemplateType, useCreateClientTemplate, useModifyClientTemplate } from '@/service/api' +import { queryClient } from '@/utils/query-client' +import { Maximize2, Minimize2 } from 'lucide-react' +import { Suspense, lazy, useCallback, useEffect, useState } from 'react' +import { UseFormReturn } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +const MonacoEditor = lazy(() => import('@monaco-editor/react')) +const MobileJsonAceEditor = lazy(() => import('@/components/common/mobile-json-ace-editor')) +const MobileYamlAceEditor = lazy(() => import('@/components/common/mobile-yaml-ace-editor')) + +const TEMPLATE_TYPE_LABELS: Record = { + [ClientTemplateType.clash_subscription]: 'Clash Subscription', + [ClientTemplateType.xray_subscription]: 'Xray Subscription', + [ClientTemplateType.singbox_subscription]: 'SingBox Subscription', + [ClientTemplateType.user_agent]: 'User Agent', + [ClientTemplateType.grpc_user_agent]: 'gRPC User Agent', +} + +const isYamlType = (templateType: string) => templateType === ClientTemplateType.clash_subscription + +interface ValidationResult { + isValid: boolean + error?: string +} + +interface ClientTemplateModalProps { + isDialogOpen: boolean + onOpenChange: (open: boolean) => void + form: UseFormReturn + editingTemplate: boolean + editingTemplateId?: number +} + +const monacoEditorOptions = { + minimap: { enabled: false }, + fontSize: 13, + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + lineNumbers: 'on' as const, + scrollBeyondLastLine: false, + automaticLayout: true, + formatOnPaste: true, + formatOnType: true, + renderWhitespace: 'none' as const, + wordWrap: 'on' as const, + folding: true, + scrollbar: { + vertical: 'visible' as const, + horizontal: 'visible' as const, + useShadows: false, + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, +} + +export default function ClientTemplateModal({ isDialogOpen, onOpenChange, form, editingTemplate, editingTemplateId }: ClientTemplateModalProps) { + const { t } = useTranslation() + const dir = useDirDetection() + const isMobile = useIsMobile() + const { resolvedTheme } = useTheme() + const createClientTemplate = useCreateClientTemplate() + const modifyClientTemplate = useModifyClientTemplate() + const [isEditorExpanded, setIsEditorExpanded] = useState(false) + const [validation, setValidation] = useState({ isValid: true }) + + const templateType = form.watch('template_type') + const isYaml = isYamlType(templateType) + + const validateContent = useCallback( + (value: string) => { + if (!value.trim()) { + setValidation({ isValid: false, error: 'Content is required' }) + return false + } + if (isYaml) { + setValidation({ isValid: true }) + return true + } + try { + JSON.parse(value) + setValidation({ isValid: true }) + return true + } catch (e) { + const msg = e instanceof Error ? e.message : 'Invalid JSON' + setValidation({ isValid: false, error: msg }) + return false + } + }, + [isYaml], + ) + + useEffect(() => { + setValidation({ isValid: true }) + if (!editingTemplate) { + form.setValue('content', DEFAULT_TEMPLATE_CONTENT[templateType as ClientTemplateType] ?? '') + } + }, [templateType]) // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + if (!isDialogOpen) { + setIsEditorExpanded(false) + setValidation({ isValid: true }) + } + }, [isDialogOpen]) + + const handleSubmit = form.handleSubmit(async values => { + const isContentYaml = isYamlType(values.template_type) + + let finalContent: string + if (isContentYaml) { + finalContent = values.content + } else { + try { + finalContent = JSON.stringify(JSON.parse(values.content), null, 2) + } catch { + setValidation({ isValid: false, error: 'Invalid JSON' }) + toast.error('Invalid JSON content') + return + } + } + + try { + if (editingTemplate && editingTemplateId !== undefined) { + await modifyClientTemplate.mutateAsync({ + templateId: editingTemplateId, + data: { name: values.name, content: finalContent, is_default: values.is_default }, + }) + toast.success(t('success', { defaultValue: 'Success' }), { + description: t('clientTemplates.updateSuccess', { name: values.name, defaultValue: 'Template "{{name}}" updated successfully' }), + }) + } else { + await createClientTemplate.mutateAsync({ + data: { name: values.name, template_type: values.template_type, content: finalContent, is_default: values.is_default }, + }) + toast.success(t('success', { defaultValue: 'Success' }), { + description: t('clientTemplates.createSuccess', { name: values.name, defaultValue: 'Template "{{name}}" created successfully' }), + }) + } + queryClient.invalidateQueries({ queryKey: ['/api/client_templates'] }) + onOpenChange(false) + } catch (error: any) { + const detail = error?.response?._data?.detail || error?.response?.data?.detail || error?.message + toast.error(t('error', { defaultValue: 'Error' }), { + description: typeof detail === 'string' ? detail : t('clientTemplates.saveFailed', { defaultValue: 'Failed to save template' }), + }) + } + }) + + const isPending = createClientTemplate.isPending || modifyClientTemplate.isPending + + const renderEditor = (field: { value: string; onChange: (v: string) => void }) => { + const language = isYaml ? 'yaml' : 'json' + const handleChange = (v: string) => { + field.onChange(v) + validateContent(v) + } + + if (isMobile) { + return isYaml ? ( + }> + + + ) : ( + }> + + + ) + } + + return ( + }> + handleChange(v ?? '')} + options={monacoEditorOptions} + /> + + ) + } + + const title = editingTemplate ? t('clientTemplates.editTemplate', { defaultValue: 'Edit Client Template' }) : t('clientTemplates.addTemplate', { defaultValue: 'Add Client Template' }) + + return ( + + + + {title} + + +
+ + + {/* ── Left panel: code editor ── */} +
+
+ + {t('clientTemplates.content', { defaultValue: 'Content' })} + {isYaml ? 'YAML' : 'JSON'} + + +
+
+ ( + + +
{renderEditor(field)}
+
+ {!validation.isValid && validation.error && ( +
+ {validation.error} +
+ )} +
+ )} + /> +
+
+ + {/* ── Right panel: fields + submit ── */} +
+
+ ( + + {t('name')} + + + + + + )} + /> + + ( + + {t('clientTemplates.templateType', { defaultValue: 'Template Type' })} + + + + )} + /> + + ( + + {t('clientTemplates.isDefault', { defaultValue: 'Set as default' })} + + + + + )} + /> + + {/* Mobile-only editor */} + {isMobile && ( + ( + + + {t('clientTemplates.content', { defaultValue: 'Content' })} + {isYaml ? 'YAML' : 'JSON'} + + +
+ {renderEditor(field)} +
+
+ {!validation.isValid && validation.error && {validation.error}} +
+ )} + /> + )} +
+ +
+
+ + + {editingTemplate ? t('modify') : t('create')} + +
+
+
+ +
+ +
+
+ ) +} diff --git a/dashboard/src/components/forms/client-template-form.ts b/dashboard/src/components/forms/client-template-form.ts new file mode 100644 index 000000000..613c25828 --- /dev/null +++ b/dashboard/src/components/forms/client-template-form.ts @@ -0,0 +1,207 @@ +import { ClientTemplateType } from '@/service/api' +import { z } from 'zod' + +export const clientTemplateFormSchema = z.object({ + name: z.string().min(1, 'Name is required').max(64), + template_type: z.enum([ + ClientTemplateType.clash_subscription, + ClientTemplateType.xray_subscription, + ClientTemplateType.singbox_subscription, + ClientTemplateType.user_agent, + ClientTemplateType.grpc_user_agent, + ]), + content: z.string().min(1, 'Content is required'), + is_default: z.boolean().optional(), +}) + +export type ClientTemplateFormValues = z.infer +const DEFAULT_USER_AGENT_TEMPLATE = { + list: [], +} +export const DEFAULT_TEMPLATE_CONTENT: Record = { + [ClientTemplateType.clash_subscription]: `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`, + + [ClientTemplateType.xray_subscription]: JSON.stringify( + { + 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: [ + { + protocol: 'freedom', + tag: 'DIRECT', + }, + { + protocol: 'blackhole', + tag: 'BLOCK', + }, + ], + dns: { + servers: ['1.1.1.1', '8.8.8.8'], + }, + routing: { + domainStrategy: 'AsIs', + rules: [], + }, + }, + null, + 2, + ), + + [ClientTemplateType.singbox_subscription]: JSON.stringify( + { + 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 }, + }, + }, + null, + 2, + ), + + [ClientTemplateType.user_agent]: JSON.stringify(DEFAULT_USER_AGENT_TEMPLATE, null, 2), + + [ClientTemplateType.grpc_user_agent]: JSON.stringify(DEFAULT_USER_AGENT_TEMPLATE, null, 2), +} + +export const clientTemplateFormDefaultValues: Partial = { + name: '', + template_type: ClientTemplateType.xray_subscription, + content: DEFAULT_TEMPLATE_CONTENT[ClientTemplateType.xray_subscription], + is_default: false, +} diff --git a/dashboard/src/components/layout/sidebar.tsx b/dashboard/src/components/layout/sidebar.tsx index 238ed5ee6..6200073d0 100644 --- a/dashboard/src/components/layout/sidebar.tsx +++ b/dashboard/src/components/layout/sidebar.tsx @@ -36,6 +36,7 @@ import { ListTodo, Lock, MessageCircle, + Monitor, Palette, PieChart, RssIcon, @@ -177,8 +178,20 @@ export function AppSidebar({ ...props }: React.ComponentProps) { }, { title: 'templates.title', - url: '/templates', + url: '/templates/user', icon: LayoutTemplate, + items: [ + { + title: 'templates.userTemplates', + url: '/templates/user', + icon: Users2, + }, + { + title: 'templates.clientTemplates', + url: '/templates/client', + icon: Monitor, + }, + ], }, { title: 'bulk.title', diff --git a/dashboard/src/components/templates/client-template-actions-menu.tsx b/dashboard/src/components/templates/client-template-actions-menu.tsx new file mode 100644 index 000000000..26406e0c3 --- /dev/null +++ b/dashboard/src/components/templates/client-template-actions-menu.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' +import { Button } from '@/components/ui/button' +import { Copy, EllipsisVertical, Pen, Trash2 } from 'lucide-react' +import useDirDetection from '@/hooks/use-dir-detection' +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog' +import { cn } from '@/lib/utils' +import { toast } from 'sonner' +import { createClientTemplate, useRemoveClientTemplate, ClientTemplateResponse } from '@/service/api' +import { queryClient } from '@/utils/query-client' + +interface ClientTemplateActionsMenuProps { + template: ClientTemplateResponse + onEdit: (template: ClientTemplateResponse) => void + className?: string +} + +const DeleteAlertDialog = ({ + template, + isOpen, + onClose, + onConfirm, +}: { + template: ClientTemplateResponse + isOpen: boolean + onClose: () => void + onConfirm: () => void +}) => { + const { t } = useTranslation() + const dir = useDirDetection() + + return ( + + + + {t('clientTemplates.deleteTitle', { defaultValue: 'Delete Client Template' })} + + {{name}}? This action cannot be undone.`, + }), + }} + /> + + + + {t('cancel')} + + {t('remove')} + + + + + ) +} + +export default function ClientTemplateActionsMenu({ template, onEdit, className }: ClientTemplateActionsMenuProps) { + const { t } = useTranslation() + const dir = useDirDetection() + const removeClientTemplateMutation = useRemoveClientTemplate() + const [isDeleteDialogOpen, setDeleteDialogOpen] = useState(false) + + const handleDeleteClick = (event: Event) => { + event.preventDefault() + event.stopPropagation() + setDeleteDialogOpen(true) + } + + const handleConfirmDelete = async () => { + try { + await removeClientTemplateMutation.mutateAsync({ templateId: template.id }) + toast.success(t('success', { defaultValue: 'Success' }), { + description: t('clientTemplates.deleteSuccess', { + name: template.name, + defaultValue: 'Template "{{name}}" has been deleted successfully', + }), + }) + setDeleteDialogOpen(false) + queryClient.invalidateQueries({ queryKey: ['/api/client_templates'] }) + } catch { + toast.error(t('error', { defaultValue: 'Error' }), { + description: t('clientTemplates.deleteFailed', { + name: template.name, + defaultValue: 'Failed to delete template "{{name}}"', + }), + }) + } + } + + const handleDuplicate = async () => { + try { + await createClientTemplate({ + name: `${template.name} (copy)`, + template_type: template.template_type, + content: template.content, + is_default: false, + }) + toast.success(t('success', { defaultValue: 'Success' }), { + description: t('clientTemplates.duplicateSuccess', { + name: template.name, + defaultValue: 'Template "{{name}}" has been duplicated successfully', + }), + }) + queryClient.invalidateQueries({ queryKey: ['/api/client_templates'] }) + } catch { + toast.error(t('error', { defaultValue: 'Error' }), { + description: t('clientTemplates.duplicateFailed', { + name: template.name, + defaultValue: 'Failed to duplicate template "{{name}}"', + }), + }) + } + } + + return ( +
e.stopPropagation()}> + + + + + + { + e.stopPropagation() + onEdit(template) + }} + > + + {t('edit')} + + {!template.is_system && ( + <> + { + e.stopPropagation() + handleDuplicate() + }} + > + + {t('duplicate')} + + + { + e.stopPropagation() + handleDeleteClick(e) + }} + > + + {t('delete')} + + + )} + + + + setDeleteDialogOpen(false)} onConfirm={handleConfirmDelete} /> +
+ ) +} diff --git a/dashboard/src/components/templates/client-template.tsx b/dashboard/src/components/templates/client-template.tsx new file mode 100644 index 000000000..890bb446c --- /dev/null +++ b/dashboard/src/components/templates/client-template.tsx @@ -0,0 +1,43 @@ +import { Card, CardTitle } from '../ui/card' +import { useTranslation } from 'react-i18next' +import { ClientTemplateResponse } from '@/service/api' +import ClientTemplateActionsMenu from './client-template-actions-menu' +import { Badge } from '../ui/badge' +import { Shield } from 'lucide-react' + +const ClientTemplate = ({ + template, + onEdit, +}: { + template: ClientTemplateResponse + onEdit: (template: ClientTemplateResponse) => void +}) => { + const { t } = useTranslation() + + return ( + +
+
onEdit(template)}> + + {template.name} + {template.is_default && {t('default', { defaultValue: 'Default' })}} + {template.is_system && ( + + + {t('system', { defaultValue: 'System' })} + + )} + +
+ + {template.template_type.replace(/_/g, ' ')} + +
+
+ +
+
+ ) +} + +export default ClientTemplate diff --git a/dashboard/src/components/templates/use-client-templates-list-columns.tsx b/dashboard/src/components/templates/use-client-templates-list-columns.tsx new file mode 100644 index 000000000..31dd8bc8a --- /dev/null +++ b/dashboard/src/components/templates/use-client-templates-list-columns.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { ListColumn } from '@/components/common/list-generator' +import { ClientTemplateResponse } from '@/service/api' +import ClientTemplateActionsMenu from '@/components/templates/client-template-actions-menu' +import { Badge } from '@/components/ui/badge' +import { Shield } from 'lucide-react' + +const TEMPLATE_TYPE_LABELS: Record = { + clash_subscription: 'Clash', + xray_subscription: 'Xray', + singbox_subscription: 'SingBox', + user_agent: 'User Agent', + grpc_user_agent: 'gRPC UA', +} + +interface UseClientTemplatesListColumnsProps { + onEdit: (template: ClientTemplateResponse) => void +} + +export const useClientTemplatesListColumns = ({ onEdit }: UseClientTemplatesListColumnsProps) => { + const { t } = useTranslation() + + return useMemo[]>( + () => [ + { + id: 'name', + header: t('name', { defaultValue: 'Name' }), + width: '2.5fr', + cell: template => ( +
{ + event.stopPropagation() + onEdit(template) + }} + > + {template.name} + {template.is_default && ( + + {t('default', { defaultValue: 'Default' })} + + )} + {template.is_system && ( + + + {t('system', { defaultValue: 'System' })} + + )} +
+ ), + }, + { + id: 'type', + header: t('clientTemplates.templateType', { defaultValue: 'Type' }), + width: '1fr', + cell: template => ( + + {TEMPLATE_TYPE_LABELS[template.template_type] || template.template_type.replace(/_/g, ' ')} + + ), + hideOnMobile: true, + }, + { + id: 'actions', + header: '', + width: '24px', + align: 'end', + hideOnMobile: false, + cell: template => , + }, + ], + [t, onEdit], + ) +} diff --git a/dashboard/src/pages/_dashboard.bulk.create.tsx b/dashboard/src/pages/_dashboard.bulk.create.tsx index dc9fe7448..efdf39358 100644 --- a/dashboard/src/pages/_dashboard.bulk.create.tsx +++ b/dashboard/src/pages/_dashboard.bulk.create.tsx @@ -225,7 +225,7 @@ export default function BulkCreateUsersPage() { {t('bulk.create.noTemplatesDesc')}

- diff --git a/dashboard/src/pages/_dashboard.templates.client.tsx b/dashboard/src/pages/_dashboard.templates.client.tsx new file mode 100644 index 000000000..19095d259 --- /dev/null +++ b/dashboard/src/pages/_dashboard.templates.client.tsx @@ -0,0 +1,168 @@ +import ClientTemplate from '@/components/templates/client-template' +import { useGetClientTemplates, ClientTemplateResponse } from '@/service/api' +import { Skeleton } from '@/components/ui/skeleton' +import { Card, CardContent } from '@/components/ui/card' +import ClientTemplateModal from '@/components/dialogs/client-template-modal' +import { clientTemplateFormDefaultValues, clientTemplateFormSchema, type ClientTemplateFormValues } from '@/components/forms/client-template-form' +import { useState, useMemo, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { useTranslation } from 'react-i18next' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { RefreshCw, Search, X } from 'lucide-react' +import useDirDetection from '@/hooks/use-dir-detection' +import { cn } from '@/lib/utils' +import ViewToggle from '@/components/common/view-toggle' +import { ListGenerator } from '@/components/common/list-generator' +import { useClientTemplatesListColumns } from '@/components/templates/use-client-templates-list-columns' +import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode' + +export default function ClientTemplates() { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingTemplate, setEditingTemplate] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [viewMode, setViewMode] = usePersistedViewMode('view-mode:client-templates') + const { data, isLoading, isFetching, refetch } = useGetClientTemplates() + const form = useForm({ + resolver: zodResolver(clientTemplateFormSchema), + defaultValues: clientTemplateFormDefaultValues as ClientTemplateFormValues, + }) + const { t } = useTranslation() + const dir = useDirDetection() + + useEffect(() => { + const handleOpenDialog = () => { + setEditingTemplate(null) + form.reset(clientTemplateFormDefaultValues as ClientTemplateFormValues) + setIsDialogOpen(true) + } + window.addEventListener('openClientTemplateDialog', handleOpenDialog) + return () => window.removeEventListener('openClientTemplateDialog', handleOpenDialog) + }, [form]) + + const handleEdit = (template: ClientTemplateResponse) => { + setEditingTemplate(template) + form.reset({ + name: template.name, + template_type: template.template_type, + content: template.content, + is_default: template.is_default, + }) + setIsDialogOpen(true) + } + + const filteredTemplates = useMemo(() => { + const templates = data?.templates || [] + if (!searchQuery.trim()) return templates + const query = searchQuery.toLowerCase().trim() + return templates.filter((t: ClientTemplateResponse) => t.name?.toLowerCase().includes(query) || t.template_type?.toLowerCase().includes(query)) + }, [data, searchQuery]) + + const listColumns = useClientTemplatesListColumns({ onEdit: handleEdit }) + + const isCurrentlyLoading = isLoading || (isFetching && !data) + const isEmpty = !isCurrentlyLoading && filteredTemplates.length === 0 && !searchQuery.trim() + const isSearchEmpty = !isCurrentlyLoading && filteredTemplates.length === 0 && searchQuery.trim() !== '' + + return ( +
+
+
+
+ + setSearchQuery(e.target.value)} className={cn('pl-8 pr-10', dir === 'rtl' && 'pl-10 pr-8')} /> + {searchQuery && ( + + )} +
+
+ + +
+
+ + {(isCurrentlyLoading || filteredTemplates.length > 0) && ( + template.id} + isLoading={isCurrentlyLoading} + loadingRows={6} + className="gap-3" + onRowClick={handleEdit} + mode={viewMode} + showEmptyState={false} + gridClassName="transform-gpu animate-slide-up" + gridStyle={{ animationDuration: '500ms', animationDelay: '100ms', animationFillMode: 'both' }} + renderGridItem={template => } + renderGridSkeleton={i => ( + +
+
+
+ +
+
+ +
+
+ +
+
+ )} + /> + )} + + {isEmpty && !isCurrentlyLoading && ( + + +
+

{t('clientTemplates.noTemplates', { defaultValue: 'No client templates' })}

+

+ {t('clientTemplates.noTemplatesDescription', { defaultValue: 'Create a client template to customize subscription output formats.' })} +

+
+
+
+ )} + + {isSearchEmpty && !isCurrentlyLoading && ( + + +
+

{t('noResults')}

+

{t('clientTemplates.noSearchResults', { defaultValue: 'No client templates match your search.' })}

+
+
+
+ )} +
+ + { + if (!open) { + setEditingTemplate(null) + form.reset(clientTemplateFormDefaultValues as ClientTemplateFormValues) + } + setIsDialogOpen(open) + }} + form={form} + editingTemplate={!!editingTemplate} + editingTemplateId={editingTemplate?.id} + /> +
+ ) +} diff --git a/dashboard/src/pages/_dashboard.templates.tsx b/dashboard/src/pages/_dashboard.templates.tsx index 50ad522ea..52e9b30b9 100644 --- a/dashboard/src/pages/_dashboard.templates.tsx +++ b/dashboard/src/pages/_dashboard.templates.tsx @@ -1,233 +1,87 @@ -import UserTemplate from '../components/templates/user-template' -import { useGetUserTemplates, useModifyUserTemplate, UserTemplateResponse } from '@/service/api' import PageHeader from '@/components/layout/page-header' -import { Plus, RefreshCw } from 'lucide-react' -import { Separator } from '@/components/ui/separator.tsx' -import { Skeleton } from '@/components/ui/skeleton' -import { Card, CardContent } from '@/components/ui/card' - -import UserTemplateModal from '@/components/dialogs/user-template-modal.tsx' -import { userTemplateFormDefaultValues, userTemplateFormSchema, type UserTemplatesFromValueInput } from '@/components/forms/user-template-form' -import { useState, useMemo } from 'react' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' -import { queryClient } from '@/utils/query-client.ts' -import { toast } from 'sonner' +import PageTransition from '@/components/layout/page-transition' +import { getDocsUrl } from '@/utils/docs-url' +import { LayoutTemplate, LucideIcon, Plus, User } from 'lucide-react' +import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Input } from '@/components/ui/input' -import { Button } from '@/components/ui/button' -import { Search, X } from 'lucide-react' -import useDirDetection from '@/hooks/use-dir-detection' -import { cn } from '@/lib/utils' -import ViewToggle from '@/components/common/view-toggle' -import { ListGenerator } from '@/components/common/list-generator' -import { useUserTemplatesListColumns } from '@/components/templates/use-user-templates-list-columns' -import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode' - -export default function UserTemplates() { - const [isDialogOpen, setIsDialogOpen] = useState(false) - const [editingUserTemplate, setEditingUserTemplate] = useState(null) - const [searchQuery, setSearchQuery] = useState('') - const [viewMode, setViewMode] = usePersistedViewMode('view-mode:templates') - const { data: userTemplates, isLoading, isFetching, refetch } = useGetUserTemplates() - const form = useForm({ - resolver: zodResolver(userTemplateFormSchema), - defaultValues: userTemplateFormDefaultValues, - }) - const { t } = useTranslation() - const modifyUserTemplateMutation = useModifyUserTemplate() - const dir = useDirDetection() - const handleEdit = (userTemplate: UserTemplateResponse) => { - setEditingUserTemplate(userTemplate) - form.reset({ - name: userTemplate.name || undefined, - status: userTemplate.status || undefined, - data_limit: userTemplate.data_limit || undefined, - expire_duration: userTemplate.expire_duration || undefined, - method: userTemplate.extra_settings?.method || undefined, - flow: userTemplate.extra_settings?.flow || undefined, - groups: userTemplate.group_ids || undefined, - username_prefix: userTemplate.username_prefix || undefined, - username_suffix: userTemplate.username_suffix || undefined, - on_hold_timeout: typeof userTemplate.on_hold_timeout === 'number' ? userTemplate.on_hold_timeout : undefined, - data_limit_reset_strategy: userTemplate.data_limit_reset_strategy || undefined, - reset_usages: userTemplate.reset_usages || false, - }) +import { Outlet, useLocation, useNavigate } from 'react-router' - setIsDialogOpen(true) - } +interface Tab { + id: string + label: string + icon: LucideIcon + url: string +} - const handleToggleStatus = async (template: UserTemplateResponse) => { - try { - await modifyUserTemplateMutation.mutateAsync({ - templateId: template.id, - data: { - name: template.name, - data_limit: template.data_limit, - expire_duration: template.expire_duration, - username_prefix: template.username_prefix, - username_suffix: template.username_suffix, - group_ids: template.group_ids, - status: template.status, - reset_usages: template.reset_usages, - is_disabled: !template.is_disabled, - data_limit_reset_strategy: template.data_limit_reset_strategy, - on_hold_timeout: template.on_hold_timeout, - extra_settings: template.extra_settings, - }, - }) +const tabs: Tab[] = [ + { id: 'templates.userTemplates', label: 'templates.userTemplates', icon: User, url: '/templates/user' }, + { id: 'templates.clientTemplates', label: 'templates.clientTemplates', icon: LayoutTemplate, url: '/templates/client' }, +] - toast.success(t('success', { defaultValue: 'Success' }), { - description: t(template.is_disabled ? 'templates.enableSuccess' : 'templates.disableSuccess', { - name: template.name, - defaultValue: `Template "{name}" has been ${template.is_disabled ? 'enabled' : 'disabled'} successfully`, - }), - }) +export default function TemplatesLayout() { + const location = useLocation() + const navigate = useNavigate() + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState(tabs[0].id) - // Invalidate the groups query to refresh the list - queryClient.invalidateQueries({ - queryKey: ['/api/user_templates'], - }) - } catch (error) { - toast.error(t('error', { defaultValue: 'Error' }), { - description: t(template.is_disabled ? 'templates.enableFailed' : 'templates.disableFailed', { - name: template.name, - defaultValue: `Failed to ${template.is_disabled ? 'enable' : 'disable'} Template "{name}"`, - }), - }) + useEffect(() => { + const currentTab = tabs.find(tab => location.pathname === tab.url) + if (currentTab) { + setActiveTab(currentTab.id) } - } - - const filteredTemplates = useMemo(() => { - if (!userTemplates || !searchQuery.trim()) return userTemplates - const query = searchQuery.toLowerCase().trim() - return userTemplates.filter( - (template: UserTemplateResponse) => - template.name?.toLowerCase().includes(query) || template.username_prefix?.toLowerCase().includes(query) || template.username_suffix?.toLowerCase().includes(query), - ) - }, [userTemplates, searchQuery]) + }, [location.pathname]) - const listColumns = useUserTemplatesListColumns({ onEdit: handleEdit, onToggleStatus: handleToggleStatus }) - - const handleRefreshClick = async () => { - await refetch() + const getPageHeaderProps = () => { + if (location.pathname === '/templates/client') { + return { + title: 'clientTemplates.title', + description: 'clientTemplates.description', + buttonIcon: Plus, + buttonText: 'clientTemplates.addTemplate', + onButtonClick: () => { + window.dispatchEvent(new CustomEvent('openClientTemplateDialog')) + }, + } + } + return { + title: 'templates.title', + description: 'templates.description', + buttonIcon: Plus, + buttonText: 'templates.addTemplate', + onButtonClick: () => { + window.dispatchEvent(new CustomEvent('openUserTemplateDialog')) + }, + } } - const isCurrentlyLoading = isLoading || (isFetching && !userTemplates) - const isEmpty = !isCurrentlyLoading && (!filteredTemplates || filteredTemplates.length === 0) && !searchQuery.trim() - const isSearchEmpty = !isCurrentlyLoading && (!filteredTemplates || filteredTemplates.length === 0) && searchQuery.trim() !== '' - return ( -
-
- { - setIsDialogOpen(true) - }} - /> - -
- -
- {/* Search Input */} -
-
- - setSearchQuery(e.target.value)} className={cn('pl-8 pr-10', dir === 'rtl' && 'pl-10 pr-8')} /> - {searchQuery && ( - - )} -
-
- - -
-
- - {(isCurrentlyLoading || (filteredTemplates && filteredTemplates.length > 0)) && ( - template.id} - isLoading={isCurrentlyLoading} - loadingRows={6} - className="gap-3" - onRowClick={handleEdit} - mode={viewMode} - showEmptyState={false} - gridClassName="transform-gpu animate-slide-up" - gridStyle={{ animationDuration: '500ms', animationDelay: '100ms', animationFillMode: 'both' }} - renderGridItem={template => } - renderGridSkeleton={i => ( - -
-
-
- - -
-
- - -
-
- -
-
- )} - /> - )} - {isEmpty && !isCurrentlyLoading && ( - - -
-

{t('templates.noTemplates')}

-

{t('templates.noTemplatesDescription')}

+
+ + {t(tab.label, { defaultValue: tab.label === 'templates.userTemplates' ? 'User Templates' : 'Client Templates' })}
- - - )} - {isSearchEmpty && !isCurrentlyLoading && ( - - -
-

{t('noResults')}

-

{t('templates.noSearchResults')}

-
-
-
- )} + + ))} +
+
+ + + +
- - { - if (!open) { - setEditingUserTemplate(null) - form.reset(userTemplateFormDefaultValues) - } - setIsDialogOpen(open) - }} - form={form} - editingUserTemplate={!!editingUserTemplate} - editingUserTemplateId={editingUserTemplate?.id} - />
) } - diff --git a/dashboard/src/pages/_dashboard.templates.user.tsx b/dashboard/src/pages/_dashboard.templates.user.tsx new file mode 100644 index 000000000..86b7b843f --- /dev/null +++ b/dashboard/src/pages/_dashboard.templates.user.tsx @@ -0,0 +1,221 @@ +import UserTemplate from '../components/templates/user-template' +import { useGetUserTemplates, useModifyUserTemplate, UserTemplateResponse } from '@/service/api' +import { Skeleton } from '@/components/ui/skeleton' +import { Card, CardContent } from '@/components/ui/card' + +import UserTemplateModal from '@/components/dialogs/user-template-modal.tsx' +import { userTemplateFormDefaultValues, userTemplateFormSchema, type UserTemplatesFromValueInput } from '@/components/forms/user-template-form' +import { useState, useMemo, useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { queryClient } from '@/utils/query-client.ts' +import { toast } from 'sonner' +import { useTranslation } from 'react-i18next' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { RefreshCw, Search, X } from 'lucide-react' +import useDirDetection from '@/hooks/use-dir-detection' +import { cn } from '@/lib/utils' +import ViewToggle from '@/components/common/view-toggle' +import { ListGenerator } from '@/components/common/list-generator' +import { useUserTemplatesListColumns } from '@/components/templates/use-user-templates-list-columns' +import { usePersistedViewMode } from '@/hooks/use-persisted-view-mode' + +export default function UserTemplates() { + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingUserTemplate, setEditingUserTemplate] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [viewMode, setViewMode] = usePersistedViewMode('view-mode:templates') + const { data: userTemplates, isLoading, isFetching, refetch } = useGetUserTemplates() + const form = useForm({ + resolver: zodResolver(userTemplateFormSchema), + defaultValues: userTemplateFormDefaultValues, + }) + const { t } = useTranslation() + const modifyUserTemplateMutation = useModifyUserTemplate() + const dir = useDirDetection() + + useEffect(() => { + const handleOpenDialog = () => { + setEditingUserTemplate(null) + form.reset(userTemplateFormDefaultValues) + setIsDialogOpen(true) + } + window.addEventListener('openUserTemplateDialog', handleOpenDialog) + return () => window.removeEventListener('openUserTemplateDialog', handleOpenDialog) + }, [form]) + + const handleEdit = (userTemplate: UserTemplateResponse) => { + setEditingUserTemplate(userTemplate) + form.reset({ + name: userTemplate.name || undefined, + status: userTemplate.status || undefined, + data_limit: userTemplate.data_limit || undefined, + expire_duration: userTemplate.expire_duration || undefined, + method: userTemplate.extra_settings?.method || undefined, + flow: userTemplate.extra_settings?.flow || undefined, + groups: userTemplate.group_ids || undefined, + username_prefix: userTemplate.username_prefix || undefined, + username_suffix: userTemplate.username_suffix || undefined, + on_hold_timeout: typeof userTemplate.on_hold_timeout === 'number' ? userTemplate.on_hold_timeout : undefined, + data_limit_reset_strategy: userTemplate.data_limit_reset_strategy || undefined, + reset_usages: userTemplate.reset_usages || false, + }) + + setIsDialogOpen(true) + } + + const handleToggleStatus = async (template: UserTemplateResponse) => { + try { + await modifyUserTemplateMutation.mutateAsync({ + templateId: template.id, + data: { + name: template.name, + data_limit: template.data_limit, + expire_duration: template.expire_duration, + username_prefix: template.username_prefix, + username_suffix: template.username_suffix, + group_ids: template.group_ids, + status: template.status, + reset_usages: template.reset_usages, + is_disabled: !template.is_disabled, + data_limit_reset_strategy: template.data_limit_reset_strategy, + on_hold_timeout: template.on_hold_timeout, + extra_settings: template.extra_settings, + }, + }) + + toast.success(t('success', { defaultValue: 'Success' }), { + description: t(template.is_disabled ? 'templates.enableSuccess' : 'templates.disableSuccess', { + name: template.name, + defaultValue: `Template "{name}" has been ${template.is_disabled ? 'enabled' : 'disabled'} successfully`, + }), + }) + + queryClient.invalidateQueries({ + queryKey: ['/api/user_templates'], + }) + } catch { + toast.error(t('error', { defaultValue: 'Error' }), { + description: t(template.is_disabled ? 'templates.enableFailed' : 'templates.disableFailed', { + name: template.name, + defaultValue: `Failed to ${template.is_disabled ? 'enable' : 'disable'} Template "{name}"`, + }), + }) + } + } + + const filteredTemplates = useMemo(() => { + if (!userTemplates || !searchQuery.trim()) return userTemplates + const query = searchQuery.toLowerCase().trim() + return userTemplates.filter( + (template: UserTemplateResponse) => + template.name?.toLowerCase().includes(query) || template.username_prefix?.toLowerCase().includes(query) || template.username_suffix?.toLowerCase().includes(query), + ) + }, [userTemplates, searchQuery]) + + const listColumns = useUserTemplatesListColumns({ onEdit: handleEdit, onToggleStatus: handleToggleStatus }) + + const isCurrentlyLoading = isLoading || (isFetching && !userTemplates) + const isEmpty = !isCurrentlyLoading && (!filteredTemplates || filteredTemplates.length === 0) && !searchQuery.trim() + const isSearchEmpty = !isCurrentlyLoading && (!filteredTemplates || filteredTemplates.length === 0) && searchQuery.trim() !== '' + + return ( +
+
+
+
+ + setSearchQuery(e.target.value)} className={cn('pl-8 pr-10', dir === 'rtl' && 'pl-10 pr-8')} /> + {searchQuery && ( + + )} +
+
+ + +
+
+ + {(isCurrentlyLoading || (filteredTemplates && filteredTemplates.length > 0)) && ( + template.id} + isLoading={isCurrentlyLoading} + loadingRows={6} + className="gap-3" + onRowClick={handleEdit} + mode={viewMode} + showEmptyState={false} + gridClassName="transform-gpu animate-slide-up" + gridStyle={{ animationDuration: '500ms', animationDelay: '100ms', animationFillMode: 'both' }} + renderGridItem={template => } + renderGridSkeleton={i => ( + +
+
+
+ + +
+
+ + +
+
+ +
+
+ )} + /> + )} + {isEmpty && !isCurrentlyLoading && ( + + +
+

{t('templates.noTemplates')}

+

{t('templates.noTemplatesDescription')}

+
+
+
+ )} + {isSearchEmpty && !isCurrentlyLoading && ( + + +
+

{t('noResults')}

+

{t('templates.noSearchResults')}

+
+
+
+ )} +
+ + { + if (!open) { + setEditingUserTemplate(null) + form.reset(userTemplateFormDefaultValues) + } + setIsDialogOpen(open) + }} + form={form} + editingUserTemplate={!!editingUserTemplate} + editingUserTemplateId={editingUserTemplate?.id} + /> +
+ ) +} diff --git a/dashboard/src/router.tsx b/dashboard/src/router.tsx index 82f672c52..b394a3bbf 100644 --- a/dashboard/src/router.tsx +++ b/dashboard/src/router.tsx @@ -29,7 +29,9 @@ const SubscriptionSettings = lazy(() => import('./pages/_dashboard.settings.subs const TelegramSettings = lazy(() => import('./pages/_dashboard.settings.telegram')) const WebhookSettings = lazy(() => import('./pages/_dashboard.settings.webhook')) const Statistics = lazy(() => import('./pages/_dashboard.statistics')) -const UserTemplates = lazy(() => import('./pages/_dashboard.templates')) +const TemplatesLayout = lazy(() => import('./pages/_dashboard.templates')) +const UserTemplates = lazy(() => import('./pages/_dashboard.templates.user')) +const ClientTemplates = lazy(() => import('./pages/_dashboard.templates.client')) const Users = lazy(() => import('./pages/_dashboard.users')) const Login = lazy(() => import('./pages/login')) @@ -147,9 +149,31 @@ export const router = createHashRouter([ path: '/templates', element: ( }> - + ), + children: [ + { + index: true, + element: , + }, + { + path: '/templates/user', + element: ( + }> + + + ), + }, + { + path: '/templates/client', + element: ( + }> + + + ), + }, + ], }, { path: '/admins', diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index c9d9b792e..a931cc53a 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -176,6 +176,21 @@ export type GetHostsParams = { limit?: number } +export type GetClientTemplatesSimpleParams = { + template_type?: ClientTemplateType | null + offset?: number | null + limit?: number | null + search?: string | null + sort?: string | null + all?: boolean +} + +export type GetClientTemplatesParams = { + template_type?: ClientTemplateType | null + offset?: number | null + limit?: number | null +} + export type GetCoresSimpleParams = { offset?: number | null limit?: number | null @@ -257,6 +272,13 @@ export type XrayMuxSettingsOutputXudpConcurrency = number | null export type XrayMuxSettingsOutputConcurrency = number | null +export interface XrayMuxSettingsOutput { + enabled?: boolean + concurrency?: XrayMuxSettingsOutputConcurrency + xudpConcurrency?: XrayMuxSettingsOutputXudpConcurrency + xudpProxyUDP443?: Xudp +} + export type XrayMuxSettingsInputXudpConcurrency = number | null export type XrayMuxSettingsInputConcurrency = number | null @@ -286,13 +308,6 @@ export const Xudp = { skip: 'skip', } as const -export interface XrayMuxSettingsOutput { - enabled?: boolean - concurrency?: XrayMuxSettingsOutputConcurrency - xudpConcurrency?: XrayMuxSettingsOutputXudpConcurrency - xudpProxyUDP443?: Xudp -} - export type XTLSFlows = (typeof XTLSFlows)[keyof typeof XTLSFlows] // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -444,18 +459,6 @@ export type XHttpSettingsInputXPaddingBytes = string | number | null export type XHttpSettingsInputNoGrpcHeader = boolean | null -export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes] - -// eslint-disable-next-line @typescript-eslint/no-redeclare -export const XHttpModes = { - auto: 'auto', - 'packet-up': 'packet-up', - 'stream-up': 'stream-up', - 'stream-one': 'stream-one', -} as const - -export type XHttpSettingsInputMode = XHttpModes | null - export interface XHttpSettingsInput { mode?: XHttpSettingsInputMode no_grpc_header?: XHttpSettingsInputNoGrpcHeader @@ -479,6 +482,18 @@ export interface XHttpSettingsInput { download_settings?: XHttpSettingsInputDownloadSettings } +export type XHttpModes = (typeof XHttpModes)[keyof typeof XHttpModes] + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const XHttpModes = { + auto: 'auto', + 'packet-up': 'packet-up', + 'stream-up': 'stream-up', + 'stream-one': 'stream-one', +} as const + +export type XHttpSettingsInputMode = XHttpModes | null + export interface WorkersHealth { scheduler: WorkerHealth node: WorkerHealth @@ -1385,6 +1400,17 @@ export interface NotificationEnable { percentage_reached?: boolean } +export type NotificationChannelDiscordWebhookUrl = string | null + +/** + * Channel configuration for sending notifications to a specific entity + */ +export interface NotificationChannel { + telegram_chat_id?: NotificationChannelTelegramChatId + telegram_topic_id?: NotificationChannelTelegramTopicId + discord_webhook_url?: NotificationChannelDiscordWebhookUrl +} + /** * Per-object notification channels */ @@ -1398,21 +1424,10 @@ export interface NotificationChannels { user_template?: NotificationChannel } -export type NotificationChannelDiscordWebhookUrl = string | null - export type NotificationChannelTelegramTopicId = number | null export type NotificationChannelTelegramChatId = number | null -/** - * Channel configuration for sending notifications to a specific entity - */ -export interface NotificationChannel { - telegram_chat_id?: NotificationChannelTelegramChatId - telegram_topic_id?: NotificationChannelTelegramTopicId - discord_webhook_url?: NotificationChannelDiscordWebhookUrl -} - export interface NotFound { detail?: string } @@ -1438,6 +1453,13 @@ export interface NodesResponse { export type NodeUsageStatsListPeriod = Period | null +export interface NodeUsageStatsList { + period?: NodeUsageStatsListPeriod + start: string + end: string + stats: NodeUsageStatsListStats +} + export interface NodeUsageStat { uplink: number downlink: number @@ -1446,13 +1468,6 @@ export interface NodeUsageStat { export type NodeUsageStatsListStats = { [key: string]: NodeUsageStat[] } -export interface NodeUsageStatsList { - period?: NodeUsageStatsListPeriod - start: string - end: string - stats: NodeUsageStatsListStats -} - export type NodeStatus = (typeof NodeStatus)[keyof typeof NodeStatus] // eslint-disable-next-line @typescript-eslint/no-redeclare @@ -1583,6 +1598,8 @@ export type NodeModifyKeepAlive = number | null export type NodeModifyServerCa = string | null +export type NodeModifyConnectionType = NodeConnectionType | null + export type NodeModifyUsageCoefficient = number | null export type NodeModifyPort = number | null @@ -1627,8 +1644,6 @@ export const NodeConnectionType = { rest: 'rest', } as const -export type NodeModifyConnectionType = NodeConnectionType | null - export interface NodeCreate { name: string address: string @@ -2022,11 +2037,6 @@ export interface CoresSimpleResponse { total: number } -export interface CoreResponseList { - count: number - cores?: CoreResponse[] -} - export type CoreResponseConfig = { [key: string]: unknown } export interface CoreResponse { @@ -2038,6 +2048,11 @@ export interface CoreResponse { created_at: string } +export interface CoreResponseList { + count: number + cores?: CoreResponse[] +} + export type CoreCreateFallbacksInboundTags = unknown[] | null export type CoreCreateExcludeInboundTags = unknown[] | null @@ -2071,6 +2086,63 @@ export const ConfigFormat = { block: 'block', } as const +export type ClientTemplateType = (typeof ClientTemplateType)[keyof typeof ClientTemplateType] + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ClientTemplateType = { + clash_subscription: 'clash_subscription', + xray_subscription: 'xray_subscription', + singbox_subscription: 'singbox_subscription', + user_agent: 'user_agent', + grpc_user_agent: 'grpc_user_agent', +} as const + +export interface ClientTemplateSimple { + id: number + name: string + template_type: ClientTemplateType + is_default: boolean +} + +export interface ClientTemplatesSimpleResponse { + templates: ClientTemplateSimple[] + total: number +} + +export interface ClientTemplateResponse { + id: number + name: string + template_type: ClientTemplateType + content: string + is_default: boolean + is_system: boolean +} + +export interface ClientTemplateResponseList { + count: number + templates?: ClientTemplateResponse[] +} + +export type ClientTemplateModifyIsDefault = boolean | null + +export type ClientTemplateModifyContent = string | null + +export type ClientTemplateModifyName = string | null + +export interface ClientTemplateModify { + name?: ClientTemplateModifyName + content?: ClientTemplateModifyContent + is_default?: ClientTemplateModifyIsDefault +} + +export interface ClientTemplateCreate { + /** @maxLength 64 */ + name: string + template_type: ClientTemplateType + content: string + is_default?: boolean +} + export type ClashMuxSettingsBrutal = Brutal | null export type ClashMuxSettingsMinStreams = number | null @@ -4415,6 +4487,327 @@ export const useRestartCore = >, return useMutation(mutationOptions) } +/** + * @summary Create Client Template + */ +export const createClientTemplate = (clientTemplateCreate: BodyType, signal?: AbortSignal) => { + return orvalFetcher({ url: `/api/client_template`, method: 'POST', headers: { 'Content-Type': 'application/json' }, data: clientTemplateCreate, signal }) +} + +export const getCreateClientTemplateMutationOptions = < + TData = Awaited>, + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions }, TContext> +}) => { + const mutationKey = ['createClientTemplate'] + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } } + + const mutationFn: MutationFunction>, { data: BodyType }> = props => { + const { data } = props ?? {} + + return createClientTemplate(data) + } + + return { mutationFn, ...mutationOptions } as UseMutationOptions }, TContext> +} + +export type CreateClientTemplateMutationResult = NonNullable>> +export type CreateClientTemplateMutationBody = BodyType +export type CreateClientTemplateMutationError = ErrorType + +/** + * @summary Create Client Template + */ +export const useCreateClientTemplate = >, TError = ErrorType, TContext = unknown>(options?: { + mutation?: UseMutationOptions }, TContext> +}): UseMutationResult }, TContext> => { + const mutationOptions = getCreateClientTemplateMutationOptions(options) + + return useMutation(mutationOptions) +} + +/** + * @summary Get Client Template + */ +export const getClientTemplate = (templateId: number, signal?: AbortSignal) => { + return orvalFetcher({ url: `/api/client_template/${templateId}`, method: 'GET', signal }) +} + +export const getGetClientTemplateQueryKey = (templateId: number) => { + return [`/api/client_template/${templateId}`] as const +} + +export const getGetClientTemplateQueryOptions = >, TError = ErrorType>( + templateId: number, + options?: { query?: Partial>, TError, TData>> }, +) => { + const { query: queryOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetClientTemplateQueryKey(templateId) + + const queryFn: QueryFunction>> = ({ signal }) => getClientTemplate(templateId, signal) + + return { queryKey, queryFn, enabled: !!templateId, ...queryOptions } as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetClientTemplateQueryResult = NonNullable>> +export type GetClientTemplateQueryError = ErrorType + +export function useGetClientTemplate>, TError = ErrorType>( + templateId: number, + options: { + query: Partial>, TError, TData>> & + Pick>, TError, TData>, 'initialData'> + }, +): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetClientTemplate>, TError = ErrorType>( + templateId: number, + options?: { + query?: Partial>, TError, TData>> & + Pick>, TError, TData>, 'initialData'> + }, +): UseQueryResult & { queryKey: DataTag } +export function useGetClientTemplate>, TError = ErrorType>( + templateId: number, + options?: { query?: Partial>, TError, TData>> }, +): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get Client Template + */ + +export function useGetClientTemplate>, TError = ErrorType>( + templateId: number, + options?: { query?: Partial>, TError, TData>> }, +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getGetClientTemplateQueryOptions(templateId, options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: DataTag } + + query.queryKey = queryOptions.queryKey + + return query +} + +/** + * @summary Modify Client Template + */ +export const modifyClientTemplate = (templateId: number, clientTemplateModify: BodyType) => { + return orvalFetcher({ url: `/api/client_template/${templateId}`, method: 'PUT', headers: { 'Content-Type': 'application/json' }, data: clientTemplateModify }) +} + +export const getModifyClientTemplateMutationOptions = < + TData = Awaited>, + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions }, TContext> +}) => { + const mutationKey = ['modifyClientTemplate'] + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } } + + const mutationFn: MutationFunction>, { templateId: number; data: BodyType }> = props => { + const { templateId, data } = props ?? {} + + return modifyClientTemplate(templateId, data) + } + + return { mutationFn, ...mutationOptions } as UseMutationOptions }, TContext> +} + +export type ModifyClientTemplateMutationResult = NonNullable>> +export type ModifyClientTemplateMutationBody = BodyType +export type ModifyClientTemplateMutationError = ErrorType + +/** + * @summary Modify Client Template + */ +export const useModifyClientTemplate = >, TError = ErrorType, TContext = unknown>(options?: { + mutation?: UseMutationOptions }, TContext> +}): UseMutationResult }, TContext> => { + const mutationOptions = getModifyClientTemplateMutationOptions(options) + + return useMutation(mutationOptions) +} + +/** + * @summary Remove Client Template + */ +export const removeClientTemplate = (templateId: number) => { + return orvalFetcher({ url: `/api/client_template/${templateId}`, method: 'DELETE' }) +} + +export const getRemoveClientTemplateMutationOptions = < + TData = Awaited>, + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions +}) => { + const mutationKey = ['removeClientTemplate'] + const { mutation: mutationOptions } = options + ? options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey } } + + const mutationFn: MutationFunction>, { templateId: number }> = props => { + const { templateId } = props ?? {} + + return removeClientTemplate(templateId) + } + + return { mutationFn, ...mutationOptions } as UseMutationOptions +} + +export type RemoveClientTemplateMutationResult = NonNullable>> + +export type RemoveClientTemplateMutationError = ErrorType + +/** + * @summary Remove Client Template + */ +export const useRemoveClientTemplate = >, TError = ErrorType, TContext = unknown>(options?: { + mutation?: UseMutationOptions +}): UseMutationResult => { + const mutationOptions = getRemoveClientTemplateMutationOptions(options) + + return useMutation(mutationOptions) +} + +/** + * @summary Get Client Templates + */ +export const getClientTemplates = (params?: GetClientTemplatesParams, signal?: AbortSignal) => { + return orvalFetcher({ url: `/api/client_templates`, method: 'GET', params, signal }) +} + +export const getGetClientTemplatesQueryKey = (params?: GetClientTemplatesParams) => { + return [`/api/client_templates`, ...(params ? [params] : [])] as const +} + +export const getGetClientTemplatesQueryOptions = >, TError = ErrorType>( + params?: GetClientTemplatesParams, + options?: { query?: Partial>, TError, TData>> }, +) => { + const { query: queryOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetClientTemplatesQueryKey(params) + + const queryFn: QueryFunction>> = ({ signal }) => getClientTemplates(params, signal) + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetClientTemplatesQueryResult = NonNullable>> +export type GetClientTemplatesQueryError = ErrorType + +export function useGetClientTemplates>, TError = ErrorType>( + params: undefined | GetClientTemplatesParams, + options: { + query: Partial>, TError, TData>> & + Pick>, TError, TData>, 'initialData'> + }, +): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetClientTemplates>, TError = ErrorType>( + params?: GetClientTemplatesParams, + options?: { + query?: Partial>, TError, TData>> & + Pick>, TError, TData>, 'initialData'> + }, +): UseQueryResult & { queryKey: DataTag } +export function useGetClientTemplates>, TError = ErrorType>( + params?: GetClientTemplatesParams, + options?: { query?: Partial>, TError, TData>> }, +): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get Client Templates + */ + +export function useGetClientTemplates>, TError = ErrorType>( + params?: GetClientTemplatesParams, + options?: { query?: Partial>, TError, TData>> }, +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getGetClientTemplatesQueryOptions(params, options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: DataTag } + + query.queryKey = queryOptions.queryKey + + return query +} + +/** + * @summary Get Client Templates Simple + */ +export const getClientTemplatesSimple = (params?: GetClientTemplatesSimpleParams, signal?: AbortSignal) => { + return orvalFetcher({ url: `/api/client_templates/simple`, method: 'GET', params, signal }) +} + +export const getGetClientTemplatesSimpleQueryKey = (params?: GetClientTemplatesSimpleParams) => { + return [`/api/client_templates/simple`, ...(params ? [params] : [])] as const +} + +export const getGetClientTemplatesSimpleQueryOptions = >, TError = ErrorType>( + params?: GetClientTemplatesSimpleParams, + options?: { query?: Partial>, TError, TData>> }, +) => { + const { query: queryOptions } = options ?? {} + + const queryKey = queryOptions?.queryKey ?? getGetClientTemplatesSimpleQueryKey(params) + + const queryFn: QueryFunction>> = ({ signal }) => getClientTemplatesSimple(params, signal) + + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetClientTemplatesSimpleQueryResult = NonNullable>> +export type GetClientTemplatesSimpleQueryError = ErrorType + +export function useGetClientTemplatesSimple>, TError = ErrorType>( + params: undefined | GetClientTemplatesSimpleParams, + options: { + query: Partial>, TError, TData>> & + Pick>, TError, TData>, 'initialData'> + }, +): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetClientTemplatesSimple>, TError = ErrorType>( + params?: GetClientTemplatesSimpleParams, + options?: { + query?: Partial>, TError, TData>> & + Pick>, TError, TData>, 'initialData'> + }, +): UseQueryResult & { queryKey: DataTag } +export function useGetClientTemplatesSimple>, TError = ErrorType>( + params?: GetClientTemplatesSimpleParams, + options?: { query?: Partial>, TError, TData>> }, +): UseQueryResult & { queryKey: DataTag } +/** + * @summary Get Client Templates Simple + */ + +export function useGetClientTemplatesSimple>, TError = ErrorType>( + params?: GetClientTemplatesSimpleParams, + options?: { query?: Partial>, TError, TData>> }, +): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getGetClientTemplatesSimpleQueryOptions(params, options) + + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: DataTag } + + query.queryKey = queryOptions.queryKey + + return query +} + /** * get host by **id** * @summary Get Host diff --git a/tests/api/conftest.py b/tests/api/conftest.py index 835aca985..382130c16 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.client_templates.GetDB", GetTestDB) return db_session diff --git a/tests/api/helpers.py b/tests/api/helpers.py index 93ff38d7c..5d9ae255b 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 @@ -77,6 +76,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_client_template( + access_token: str, + *, + name: str | None = None, + template_type: str = "xray_subscription", + 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 = { + "name": name or unique_name("client_template"), + "template_type": template_type, + "content": content, + "is_default": is_default, + } + 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_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) + + 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_client_template.py b/tests/api/test_client_template.py new file mode 100644 index 000000000..f68908d68 --- /dev/null +++ b/tests/api/test_client_template.py @@ -0,0 +1,111 @@ +from fastapi import status + +from tests.api import client +from tests.api.helpers import create_client_template, unique_name + + +def test_client_template_create_and_get(access_token): + created = create_client_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/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_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_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"}]}}]}', + is_default=True, + ) + + first_after = client.get( + f"/api/client_template/{first['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ).json() + second_after = client.get( + f"/api/client_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_client_template_cannot_delete_first_template(access_token): + response = client.get( + "/api/client_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_client_template( + access_token, + name=unique_name("tmpl_grpc_first"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent"]}', + ) + + response = client.delete( + f"/api/client_template/{first['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_client_template_can_delete_non_first_template(access_token): + response = client.get( + "/api/client_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_client_template( + access_token, + name=unique_name("tmpl_grpc_seed_first"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent-seed"]}', + ) + + second = create_client_template( + access_token, + name=unique_name("tmpl_grpc_second"), + template_type="grpc_user_agent", + content='{"list": ["grpc-agent-2"]}', + ) + + response = client.delete( + f"/api/client_template/{second['id']}", + headers={"Authorization": f"Bearer {access_token}"}, + ) + assert response.status_code == status.HTTP_204_NO_CONTENT