From d1b4793397f4622ebed614140104b280f977fc12 Mon Sep 17 00:00:00 2001 From: Oleg German Date: Mon, 16 Feb 2026 22:53:15 +0300 Subject: [PATCH 1/2] feat: add per-host subscription template overrides Allow hosts to override the Xray subscription template via a new `subscription_templates` field. Templates are loaded from CUSTOM_TEMPLATES_DIRECTORY and validated on create/update. Backend: - Add `subscription_templates` JSON column to ProxyHost (with migration) - Add SubscriptionTemplates Pydantic model - Add GET /api/host/subscription-templates endpoint - Add shared get_subscription_templates() helper (DRY listing + validation) - Exclude default templates from the list (respects env overrides) - Wire template override into Xray subscription rendering Frontend: - Add template select dropdown in host modal (hidden when no custom dir) - Add useGetSubscriptionTemplates hook and API types - Add i18n keys for en, ru, zh, fa Tests: - Add 9 API tests covering list, auth, CRUD with templates, validation Co-authored-by: Cursor --- app/core/hosts.py | 3 + app/db/crud/general.py | 2 +- ...5a6_add_subscription_templates_to_hosts.py | 30 +++ app/db/models.py | 1 + app/models/host.py | 15 ++ app/models/subscription.py | 3 + app/operation/host.py | 26 ++ app/routers/host.py | 10 + app/subscription/xray.py | 17 +- app/templates/__init__.py | 40 ++- dashboard/public/statics/locales/en.json | 5 +- dashboard/public/statics/locales/fa.json | 5 +- dashboard/public/statics/locales/ru.json | 5 +- dashboard/public/statics/locales/zh.json | 5 +- .../src/components/dialogs/host-modal.tsx | 47 +++- dashboard/src/components/forms/host-form.ts | 11 + dashboard/src/components/hosts/hosts-list.tsx | 2 + dashboard/src/service/api/index.ts | 45 ++++ tests/api/test_subscription_templates.py | 244 ++++++++++++++++++ 19 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py create mode 100644 tests/api/test_subscription_templates.py diff --git a/app/core/hosts.py b/app/core/hosts.py index 3b8cf2a25..3b7e500f8 100644 --- a/app/core/hosts.py +++ b/app/core/hosts.py @@ -286,6 +286,9 @@ async def _prepare_subscription_inbound_data( fragment_settings=host.fragment_settings.model_dump() if host.fragment_settings else None, noise_settings=host.noise_settings.model_dump() if host.noise_settings else None, finalmask=finalmask, + subscription_templates=host.subscription_templates.model_dump(exclude_none=True) + if host.subscription_templates + else None, priority=host.priority, status=list(host.status) if host.status else None, ) diff --git a/app/db/crud/general.py b/app/db/crud/general.py index 6eceebe87..ca44f9e32 100644 --- a/app/db/crud/general.py +++ b/app/db/crud/general.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone, timedelta from typing import Optional -from sqlalchemy import DateTime, String, cast, func, or_, select, text +from sqlalchemy import String, func, or_, select, text from sqlalchemy.ext.asyncio import AsyncSession from app.db.models import JWT, System diff --git a/app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py b/app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py new file mode 100644 index 000000000..64dacb0b9 --- /dev/null +++ b/app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py @@ -0,0 +1,30 @@ +"""add subscription_templates to hosts + +Revision ID: f1c2d3e4b5a6 +Revises: ee97c01bfbaf +Create Date: 2026-02-08 00:00:00.000000 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "f1c2d3e4b5a6" +down_revision = "20e2a5cf1e40" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column("hosts", sa.Column("subscription_templates", sa.JSON(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column("hosts", "subscription_templates") + # ### end Alembic commands ### + diff --git a/app/db/models.py b/app/db/models.py index eabe15bb1..75252f484 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -469,6 +469,7 @@ class ProxyHost(Base): http_headers: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) transport_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) mux_settings: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) + subscription_templates: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON(none_as_null=True), default=None) status: Mapped[Optional[list[UserStatus]]] = mapped_column( EnumArray(UserStatus, 60), default=list, server_default="" ) diff --git a/app/models/host.py b/app/models/host.py index 4f792b09f..b30e508b0 100644 --- a/app/models/host.py +++ b/app/models/host.py @@ -208,6 +208,20 @@ class TransportSettings(BaseModel): websocket_settings: WebSocketSettings | None = Field(default=None) +class SubscriptionTemplates(BaseModel): + """ + Per-host subscription template overrides. + + Keys are subscription formats (e.g. xray). Values are template paths resolved via the + existing template loader (`CUSTOM_TEMPLATES_DIRECTORY` first, then `app/templates`). + """ + + xray: str | None = Field(default=None) + + # Allow future keys without requiring immediate backend changes + model_config = ConfigDict(extra="allow") + + class FormatVariables(dict): def __missing__(self, key): return key.join("{}") @@ -232,6 +246,7 @@ class BaseHost(BaseModel): mux_settings: MuxSettings | None = Field(default=None) fragment_settings: FragmentSettings | None = Field(default=None) noise_settings: NoiseSettings | None = Field(default=None) + subscription_templates: SubscriptionTemplates | None = Field(default=None) random_user_agent: bool = Field(default=False) use_sni_as_host: bool = Field(default=False) vless_route: str | None = Field(default=None, pattern=r"^$|^[0-9a-fA-F]{4}$") diff --git a/app/models/subscription.py b/app/models/subscription.py index f891d3dfa..e08d34eed 100644 --- a/app/models/subscription.py +++ b/app/models/subscription.py @@ -249,6 +249,9 @@ class SubscriptionInboundData(BaseModel): noise_settings: dict[str, Any] | None = Field(None) finalmask: dict[str, Any] | None = Field(None) + # Per-host subscription template overrides (extensible; e.g. {"xray": "xray/custom.json"}) + subscription_templates: dict[str, Any] | None = Field(None) + # Priority and status priority: int = Field(0) status: list[str] | None = Field(None) diff --git a/app/operation/host.py b/app/operation/host.py index 51d4fbe40..69d63bcf0 100644 --- a/app/operation/host.py +++ b/app/operation/host.py @@ -13,6 +13,7 @@ remove_host, ) from app.core.hosts import host_manager +from app.templates import get_subscription_templates from app.utils.logger import get_logger from app import notification @@ -25,6 +26,28 @@ class HostOperation(BaseOperation): async def get_hosts(self, db: AsyncSession, offset: int = 0, limit: int = 0) -> list[BaseHost]: return await get_hosts(db=db, offset=offset, limit=limit) + async def validate_subscription_templates(self, host: CreateHost, db: AsyncSession): + """ + Validate per-host subscription template overrides. + + Checks that provided template paths are among the available templates + returned by get_subscription_templates(). + """ + st = getattr(host, "subscription_templates", None) + if not st or not getattr(st, "xray", None): + return + + template_path = (st.xray or "").strip() + if not template_path: + return await self.raise_error("subscription_templates.xray cannot be empty", 400, db=db) + + available = get_subscription_templates() + valid_xray = available.get("xray", []) + if template_path not in valid_xray: + return await self.raise_error( + f'Xray template "{template_path}" is not available. Valid options: {valid_xray}', 400, db=db + ) + async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: int | None = None) -> ProxyHost: if ( host.transport_settings @@ -45,6 +68,7 @@ async def validate_ds_host(self, db: AsyncSession, host: CreateHost, host_id: in async def create_host(self, db: AsyncSession, new_host: CreateHost, admin: AdminDetails) -> BaseHost: await self.validate_ds_host(db, new_host) + await self.validate_subscription_templates(new_host, db) await self.check_inbound_tags([new_host.inbound_tag]) @@ -63,6 +87,7 @@ async def modify_host( self, db: AsyncSession, host_id: int, modified_host: CreateHost, admin: AdminDetails ) -> BaseHost: await self.validate_ds_host(db, modified_host, host_id) + await self.validate_subscription_templates(modified_host, db) if modified_host.inbound_tag: await self.check_inbound_tags([modified_host.inbound_tag]) @@ -96,6 +121,7 @@ async def modify_hosts( ) -> list[BaseHost]: for host in modified_hosts: await self.validate_ds_host(db, host, host.id) + await self.validate_subscription_templates(host, db) old_host: ProxyHost | None = None if host.id is not None: diff --git a/app/routers/host.py b/app/routers/host.py index f7a5fcb2e..974170785 100644 --- a/app/routers/host.py +++ b/app/routers/host.py @@ -5,6 +5,7 @@ from app.models.host import BaseHost, CreateHost from app.operation import OperatorType from app.operation.host import HostOperation +from app.templates import get_subscription_templates from app.utils import responses from .authentication import check_sudo_admin @@ -13,6 +14,15 @@ router = APIRouter(tags=["Host"], prefix="/api/host", responses={401: responses._401, 403: responses._403}) +@router.get("/subscription-templates", response_model=dict[str, list[str]]) +async def list_subscription_templates(_: AdminDetails = Depends(check_sudo_admin)): + """ + List available subscription template files grouped by format (xray, clash, singbox). + Scans both CUSTOM_TEMPLATES_DIRECTORY and built-in templates. + """ + return get_subscription_templates() + + @router.get("/{host_id}", response_model=BaseHost) async def get_host(host_id: int, db: AsyncSession = Depends(get_db), _: AdminDetails = Depends(check_sudo_admin)): """ diff --git a/app/subscription/xray.py b/app/subscription/xray.py index fa633d9f2..3d1a3acda 100644 --- a/app/subscription/xray.py +++ b/app/subscription/xray.py @@ -22,7 +22,7 @@ class XrayConfiguration(BaseSubscription): def __init__(self): super().__init__() self.config = [] - self.template = render_template(XRAY_SUBSCRIPTION_TEMPLATE) + self._template_cache: dict[str, str] = {} # Registry for transport handlers self.transport_handlers = { @@ -47,8 +47,14 @@ def __init__(self): "shadowsocks": self._build_shadowsocks, } - def add_config(self, remarks, outbounds): - json_template = json.loads(self.template) + def _get_template(self, template_path: str) -> str: + if template_path not in self._template_cache: + self._template_cache[template_path] = render_template(template_path) + return self._template_cache[template_path] + + def add_config(self, remarks, outbounds, template_path: str | None = None): + template_path = template_path or XRAY_SUBSCRIPTION_TEMPLATE + json_template = json.loads(self._get_template(template_path)) json_template["remarks"] = remarks json_template["outbounds"] = outbounds + json_template["outbounds"] self.config.append(json_template) @@ -80,7 +86,10 @@ def add(self, remark: str, address: str, inbound: SubscriptionInboundData, setti # Shadowsocks returns just a dict all_outbounds = [result] - self.add_config(remarks=remark, outbounds=all_outbounds) + template_path = None + if inbound.subscription_templates and isinstance(inbound.subscription_templates, dict): + template_path = inbound.subscription_templates.get("xray") + self.add_config(remarks=remark, outbounds=all_outbounds, template_path=template_path) # ========== Transport Handlers (Registry Methods) ========== diff --git a/app/templates/__init__.py b/app/templates/__init__.py index 02a9d0b54..0e36afc23 100644 --- a/app/templates/__init__.py +++ b/app/templates/__init__.py @@ -1,9 +1,15 @@ +import os from datetime import datetime as dt, timezone as tz from typing import Union import jinja2 -from config import CUSTOM_TEMPLATES_DIRECTORY +from config import ( + CLASH_SUBSCRIPTION_TEMPLATE, + CUSTOM_TEMPLATES_DIRECTORY, + SINGBOX_SUBSCRIPTION_TEMPLATE, + XRAY_SUBSCRIPTION_TEMPLATE, +) from .filters import CUSTOM_FILTERS @@ -19,3 +25,35 @@ def render_template(template: str, context: Union[dict, None] = None) -> str: return env.get_template(template).render(context or {}) + + +DEFAULT_TEMPLATES = { + XRAY_SUBSCRIPTION_TEMPLATE, + CLASH_SUBSCRIPTION_TEMPLATE, + SINGBOX_SUBSCRIPTION_TEMPLATE, +} + + +def get_subscription_templates() -> dict[str, list[str]]: + """List available custom subscription template files grouped by format. + + Only scans CUSTOM_TEMPLATES_DIRECTORY (if configured). + Default templates are excluded since they are already the global fallback. + """ + if not CUSTOM_TEMPLATES_DIRECTORY: + return {"xray": [], "clash": [], "singbox": []} + + formats = {"xray": ".json", "clash": ".yml", "singbox": ".json"} + result: dict[str, list[str]] = {} + for fmt, ext in formats.items(): + templates: list[str] = [] + fmt_dir = os.path.join(CUSTOM_TEMPLATES_DIRECTORY, fmt) + if os.path.isdir(fmt_dir): + for root, _, files in os.walk(fmt_dir): + for f in sorted(files): + if f.endswith(ext): + rel = os.path.relpath(os.path.join(root, f), CUSTOM_TEMPLATES_DIRECTORY) + if rel not in DEFAULT_TEMPLATES: + templates.append(rel) + result[fmt] = sorted(templates) + return result diff --git a/dashboard/public/statics/locales/en.json b/dashboard/public/statics/locales/en.json index b8bc349d6..2c2b60d51 100644 --- a/dashboard/public/statics/locales/en.json +++ b/dashboard/public/statics/locales/en.json @@ -1074,7 +1074,10 @@ "routingSettings": "Routing", "vlessRoute": "VLESS Route", "vlessRoutePlaceholder": "e.g. abcd", - "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing." + "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.", + "subscriptionTemplates": "Subscription Templates", + "subscriptionTemplates.xray": "Xray Template", + "subscriptionTemplates.xray.info": "Override the Xray subscription template for this host. Select \"None\" to use the global default." }, "inbound": "Inbound", "inbounds": "Inbounds", diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 79421c264..39bed0c90 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -941,7 +941,10 @@ "routingSettings": "Routing", "vlessRoute": "VLESS Route", "vlessRoutePlaceholder": "e.g. abcd", - "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing." + "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.", + "subscriptionTemplates": "قالب‌های اشتراک", + "subscriptionTemplates.xray": "قالب Xray", + "subscriptionTemplates.xray.info": "قالب اشتراک Xray را برای این هاست تغییر دهید. برای استفاده از قالب پیش‌فرض «پیش‌فرض» را انتخاب کنید." }, "inbound": "ورودی", "inbounds": "ورودی‌ها", diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 8ac902a19..0a5223b50 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1232,7 +1232,10 @@ "routingSettings": "Routing", "vlessRoute": "VLESS Route", "vlessRoutePlaceholder": "e.g. abcd", - "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing." + "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.", + "subscriptionTemplates": "Шаблоны подписок", + "subscriptionTemplates.xray": "Шаблон Xray", + "subscriptionTemplates.xray.info": "Переопределить шаблон подписки Xray для этого хоста. Выберите «По умолчанию» для использования глобального шаблона." }, "enable": "Включить", "host": { diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index a14118259..58e8dc9e0 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1047,7 +1047,10 @@ "routingSettings": "Routing", "vlessRoute": "VLESS Route", "vlessRoutePlaceholder": "e.g. abcd", - "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing." + "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.", + "subscriptionTemplates": "订阅模板", + "subscriptionTemplates.xray": "Xray 模板", + "subscriptionTemplates.xray.info": "覆盖此主机的 Xray 订阅模板。选择「默认」以使用全局模板。" }, "inbound": "入站", "inbounds": "入站", diff --git a/dashboard/src/components/dialogs/host-modal.tsx b/dashboard/src/components/dialogs/host-modal.tsx index da94a6127..354611152 100644 --- a/dashboard/src/components/dialogs/host-modal.tsx +++ b/dashboard/src/components/dialogs/host-modal.tsx @@ -12,7 +12,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import { VariablesList, VariablesPopover } from '@/components/ui/variables-popover' import useDirDetection from '@/hooks/use-dir-detection' import { cn } from '@/lib/utils' -import { UserStatus, getHosts, getInbounds } from '@/service/api' +import { UserStatus, getHosts, getInbounds, useGetSubscriptionTemplates } from '@/service/api' import { queryClient } from '@/utils/query-client' import { useQuery } from '@tanstack/react-query' import { Cable, Check, ChevronsLeftRightEllipsis, Copy, Edit, GlobeLock, Info, Loader2, Lock, Network, Plus, Route, Trash2, X } from 'lucide-react' @@ -428,6 +428,8 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub const dir = useDirDetection() const [_isSubmitting, setIsSubmitting] = useState(false) const xPaddingObfsEnabled = form.watch('transport_settings.xhttp_settings.x_padding_obfs_mode') === true + const { data: subscriptionTemplates } = useGetSubscriptionTemplates() + const hasCustomTemplates = subscriptionTemplates && Object.values(subscriptionTemplates).some((list: string[]) => list.length > 0) // Optimized noise settings handlers with useCallback for performance const addNoiseSetting = useCallback(() => { @@ -648,6 +650,49 @@ const HostModal: React.FC = ({ isDialogOpen, onOpenChange, onSub )} /> + {hasCustomTemplates && ( + ( + +
+ {t('hostsDialog.subscriptionTemplates.xray')} + + + + + +

{t('hostsDialog.subscriptionTemplates.xray.info')}

+
+
+
+ + +
+ )} + /> + )} + + subscription_templates?: { + xray?: string | null + [key: string]: any + } security: 'none' | 'tls' | 'inbound_default' alpn?: string[] fingerprint?: string @@ -313,6 +317,12 @@ export const HostFormSchema = z.object({ sni: z.array(z.string()).default([]), path: z.string().default(''), http_headers: z.record(z.string()).default({}), + subscription_templates: z + .object({ + xray: z.string().nullish(), + }) + .passthrough() + .nullish(), security: z.enum(['inbound_default', 'tls', 'none']).default('inbound_default'), alpn: z.array(z.string()).default([]), fingerprint: z.string().default(''), @@ -428,6 +438,7 @@ export const hostFormDefaultValues: HostFormValues = { sni: [], path: '', http_headers: {}, + subscription_templates: undefined, security: 'inbound_default', alpn: [], fingerprint: '', diff --git a/dashboard/src/components/hosts/hosts-list.tsx b/dashboard/src/components/hosts/hosts-list.tsx index b5b7fd12d..8cffcc8dc 100644 --- a/dashboard/src/components/hosts/hosts-list.tsx +++ b/dashboard/src/components/hosts/hosts-list.tsx @@ -87,6 +87,7 @@ export default function HostsList({ data, onAddHost, isDialogOpen, onSubmit, edi sni: Array.isArray(host.sni) ? host.sni : host.sni ? [host.sni] : [], path: host.path || '', http_headers: host.http_headers || {}, + subscription_templates: (host as any).subscription_templates || undefined, security: host.security || 'inbound_default', alpn: Array.isArray(host.alpn) ? host.alpn : host.alpn ? [host.alpn] : [], fingerprint: host.fingerprint || '', @@ -272,6 +273,7 @@ export default function HostsList({ data, onAddHost, isDialogOpen, onSubmit, edi mux_settings: host.mux_settings, transport_settings: host.transport_settings as any, // Type cast needed due to Output/Input mismatch http_headers: host.http_headers || {}, + subscription_templates: (host as any).subscription_templates, } await createHost(newHost) diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index 8284f1d18..bdacada74 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -1919,6 +1919,12 @@ export type CreateHostMuxSettings = MuxSettingsInput | null export type CreateHostTransportSettings = TransportSettingsInput | null +export type CreateHostSubscriptionTemplates = { + xray?: string | null + [key: string]: any +} | null + + export interface CreateHost { id?: CreateHostId remark: string @@ -1938,6 +1944,7 @@ export interface CreateHost { mux_settings?: CreateHostMuxSettings fragment_settings?: CreateHostFragmentSettings noise_settings?: CreateHostNoiseSettings + subscription_templates?: CreateHostSubscriptionTemplates random_user_agent?: boolean use_sni_as_host?: boolean vless_route?: CreateHostVlessRoute @@ -2160,6 +2167,11 @@ export type BaseHostAllowinsecure = boolean | null export type BaseHostAlpn = ProxyHostALPN[] | null +export type BaseHostSubscriptionTemplates = { + xray?: string | null + [key: string]: any +} | null + export type BaseHostPath = string | null export type BaseHostHost = string[] | null @@ -2191,6 +2203,7 @@ export interface BaseHost { mux_settings?: BaseHostMuxSettings fragment_settings?: BaseHostFragmentSettings noise_settings?: BaseHostNoiseSettings + subscription_templates?: BaseHostSubscriptionTemplates random_user_agent?: boolean use_sni_as_host?: boolean vless_route?: BaseHostVlessRoute @@ -4613,6 +4626,38 @@ export const useModifyHosts = >, return useMutation(mutationOptions) } +/** + * List available subscription template files grouped by format (xray, clash, singbox). + * @summary Get Subscription Templates + */ +export type SubscriptionTemplatesResponse = Record + +export const getSubscriptionTemplates = (signal?: AbortSignal) => { + return orvalFetcher({ url: `/api/host/subscription-templates`, method: 'GET', signal }) +} + +export const getGetSubscriptionTemplatesQueryKey = () => { + return [`/api/host/subscription-templates`] as const +} + +export const getGetSubscriptionTemplatesQueryOptions = >, TError = ErrorType>(options?: { + query?: Partial>, TError, TData>> +}) => { + const { query: queryOptions } = options ?? {} + const queryKey = queryOptions?.queryKey ?? getGetSubscriptionTemplatesQueryKey() + const queryFn: QueryFunction>> = ({ signal }) => getSubscriptionTemplates(signal) + return { queryKey, queryFn, ...queryOptions } as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export function useGetSubscriptionTemplates>, TError = ErrorType>(options?: { + query?: Partial>, TError, TData>> +}): UseQueryResult & { queryKey: DataTag } { + const queryOptions = getGetSubscriptionTemplatesQueryOptions(options) + const query = useQuery(queryOptions) as UseQueryResult & { queryKey: DataTag } + query.queryKey = queryOptions.queryKey + return query +} + /** * create a new host diff --git a/tests/api/test_subscription_templates.py b/tests/api/test_subscription_templates.py new file mode 100644 index 000000000..5046e9d7e --- /dev/null +++ b/tests/api/test_subscription_templates.py @@ -0,0 +1,244 @@ +import json +import os +import tempfile + +import pytest +from fastapi import status + +from tests.api import client +from tests.api.helpers import auth_headers, create_core, delete_core, get_inbounds, unique_name + + +@pytest.fixture() +def custom_templates_dir(monkeypatch): + """Create a temporary custom templates directory with a sample xray template.""" + with tempfile.TemporaryDirectory() as tmpdir: + xray_dir = os.path.join(tmpdir, "xray") + os.makedirs(xray_dir) + + template = { + "log": {"loglevel": "warning"}, + "inbounds": [], + "outbounds": [], + "dns": {"servers": ["1.1.1.1"]}, + "routing": {"domainStrategy": "AsIs", "rules": []}, + } + with open(os.path.join(xray_dir, "custom.json"), "w") as f: + json.dump(template, f) + + with open(os.path.join(xray_dir, "another.json"), "w") as f: + json.dump(template, f) + + monkeypatch.setattr("config.CUSTOM_TEMPLATES_DIRECTORY", tmpdir) + monkeypatch.setattr("app.templates.CUSTOM_TEMPLATES_DIRECTORY", tmpdir) + monkeypatch.setattr("app.templates.template_directories", [tmpdir, "app/templates"]) + + import app.templates + + app.templates.env = app.templates.jinja2.Environment( + loader=app.templates.jinja2.FileSystemLoader([tmpdir, "app/templates"]) + ) + app.templates.env.filters.update(app.templates.CUSTOM_FILTERS) + + yield tmpdir + + +@pytest.fixture() +def no_custom_templates_dir(monkeypatch): + """Ensure CUSTOM_TEMPLATES_DIRECTORY is not set.""" + monkeypatch.setattr("config.CUSTOM_TEMPLATES_DIRECTORY", None) + monkeypatch.setattr("app.templates.CUSTOM_TEMPLATES_DIRECTORY", None) + yield + + +# --- GET /api/host/subscription-templates --- + + +def test_subscription_templates_list(access_token, custom_templates_dir): + """List endpoint returns custom templates grouped by format.""" + response = client.get("/api/host/subscription-templates", headers=auth_headers(access_token)) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert "xray" in data + assert "clash" in data + assert "singbox" in data + + assert "xray/custom.json" in data["xray"] + assert "xray/another.json" in data["xray"] + + +def test_subscription_templates_list_excludes_defaults(access_token, custom_templates_dir): + """List endpoint only returns custom templates, not built-in defaults.""" + response = client.get("/api/host/subscription-templates", headers=auth_headers(access_token)) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert "xray/default.json" not in data["xray"] + + +def test_subscription_templates_list_empty_without_custom_dir(access_token, no_custom_templates_dir): + """List endpoint returns empty lists when no custom dir is configured.""" + response = client.get("/api/host/subscription-templates", headers=auth_headers(access_token)) + assert response.status_code == status.HTTP_200_OK + + data = response.json() + assert data["xray"] == [] + assert data["clash"] == [] + assert data["singbox"] == [] + + +def test_subscription_templates_list_requires_auth(): + """List endpoint requires authentication.""" + response = client.get("/api/host/subscription-templates") + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +# --- Host CRUD with subscription_templates --- + + +def test_host_create_with_subscription_template(access_token, custom_templates_dir): + """Host creation accepts a valid custom subscription template.""" + core = create_core(access_token) + inbounds = get_inbounds(access_token) + assert inbounds + + try: + payload = { + "remark": unique_name("host_tpl"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbounds[0], + "priority": 1, + "subscription_templates": {"xray": "xray/custom.json"}, + } + response = client.post("/api/host", headers=auth_headers(access_token), json=payload) + assert response.status_code == status.HTTP_201_CREATED + + data = response.json() + assert data["subscription_templates"] == {"xray": "xray/custom.json"} + + client.delete(f"/api/host/{data['id']}", headers=auth_headers(access_token)) + finally: + delete_core(access_token, core["id"]) + + +def test_host_create_with_null_subscription_template(access_token): + """Host creation accepts null subscription_templates (uses global default).""" + core = create_core(access_token) + inbounds = get_inbounds(access_token) + assert inbounds + + try: + payload = { + "remark": unique_name("host_null_tpl"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbounds[0], + "priority": 1, + "subscription_templates": None, + } + response = client.post("/api/host", headers=auth_headers(access_token), json=payload) + assert response.status_code == status.HTTP_201_CREATED + + data = response.json() + assert data["subscription_templates"] is None + + client.delete(f"/api/host/{data['id']}", headers=auth_headers(access_token)) + finally: + delete_core(access_token, core["id"]) + + +def test_host_create_with_invalid_subscription_template(access_token, custom_templates_dir): + """Host creation rejects a non-existent subscription template.""" + core = create_core(access_token) + inbounds = get_inbounds(access_token) + assert inbounds + + try: + payload = { + "remark": unique_name("host_bad_tpl"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbounds[0], + "priority": 1, + "subscription_templates": {"xray": "xray/nonexistent.json"}, + } + response = client.post("/api/host", headers=auth_headers(access_token), json=payload) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "not available" in response.json()["detail"] + finally: + delete_core(access_token, core["id"]) + + +def test_host_update_subscription_template(access_token, custom_templates_dir): + """Host update can change the subscription template.""" + core = create_core(access_token) + inbounds = get_inbounds(access_token) + assert inbounds + + try: + create_payload = { + "remark": unique_name("host_update_tpl"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbounds[0], + "priority": 1, + "subscription_templates": None, + } + create_resp = client.post("/api/host", headers=auth_headers(access_token), json=create_payload) + assert create_resp.status_code == status.HTTP_201_CREATED + host_id = create_resp.json()["id"] + + update_payload = { + "remark": create_payload["remark"], + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbounds[0], + "priority": 1, + "subscription_templates": {"xray": "xray/another.json"}, + } + update_resp = client.put(f"/api/host/{host_id}", headers=auth_headers(access_token), json=update_payload) + assert update_resp.status_code == status.HTTP_200_OK + assert update_resp.json()["subscription_templates"] == {"xray": "xray/another.json"} + + client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token)) + finally: + delete_core(access_token, core["id"]) + + +def test_host_update_clear_subscription_template(access_token, custom_templates_dir): + """Host update can clear subscription template back to null.""" + core = create_core(access_token) + inbounds = get_inbounds(access_token) + assert inbounds + + try: + create_payload = { + "remark": unique_name("host_clear_tpl"), + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbounds[0], + "priority": 1, + "subscription_templates": {"xray": "xray/custom.json"}, + } + create_resp = client.post("/api/host", headers=auth_headers(access_token), json=create_payload) + assert create_resp.status_code == status.HTTP_201_CREATED + host_id = create_resp.json()["id"] + assert create_resp.json()["subscription_templates"] == {"xray": "xray/custom.json"} + + update_payload = { + "remark": create_payload["remark"], + "address": ["127.0.0.1"], + "port": 443, + "inbound_tag": inbounds[0], + "priority": 1, + "subscription_templates": None, + } + update_resp = client.put(f"/api/host/{host_id}", headers=auth_headers(access_token), json=update_payload) + assert update_resp.status_code == status.HTTP_200_OK + assert update_resp.json()["subscription_templates"] is None + + client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token)) + finally: + delete_core(access_token, core["id"]) From a64b9c2f18ebda23d382bca8003f50e41838b4b7 Mon Sep 17 00:00:00 2001 From: Oleg German Date: Tue, 17 Feb 2026 17:22:00 +0300 Subject: [PATCH 2/2] fix: address PR #278 code review feedback - Fix subscription_templates silently cleared on drag-and-drop reorder - Narrow index signature type from `any` to `string | null` - Fix migration docstring Revises field mismatch - Fix inaccurate router docstring for list endpoint - Translate vlessRoute.info for ru, zh, fa locales - Remove unnecessary `as any` casts in hosts-list - Move host cleanup into finally blocks in tests --- ...5a6_add_subscription_templates_to_hosts.py | 2 +- app/routers/host.py | 4 +- dashboard/public/statics/locales/fa.json | 2 +- dashboard/public/statics/locales/ru.json | 2 +- dashboard/public/statics/locales/zh.json | 2 +- dashboard/src/components/forms/host-form.ts | 4 +- dashboard/src/components/hosts/hosts-list.tsx | 5 +- dashboard/src/service/api/index.ts | 8 +- tests/api/test_subscription_templates.py | 107 +++++++----------- 9 files changed, 55 insertions(+), 81 deletions(-) diff --git a/app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py b/app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py index 64dacb0b9..bd7729d63 100644 --- a/app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py +++ b/app/db/migrations/versions/f1c2d3e4b5a6_add_subscription_templates_to_hosts.py @@ -1,7 +1,7 @@ """add subscription_templates to hosts Revision ID: f1c2d3e4b5a6 -Revises: ee97c01bfbaf +Revises: 20e2a5cf1e40 Create Date: 2026-02-08 00:00:00.000000 """ diff --git a/app/routers/host.py b/app/routers/host.py index 974170785..b6547af9c 100644 --- a/app/routers/host.py +++ b/app/routers/host.py @@ -17,8 +17,8 @@ @router.get("/subscription-templates", response_model=dict[str, list[str]]) async def list_subscription_templates(_: AdminDetails = Depends(check_sudo_admin)): """ - List available subscription template files grouped by format (xray, clash, singbox). - Scans both CUSTOM_TEMPLATES_DIRECTORY and built-in templates. + List available custom subscription template files grouped by format (xray, clash, singbox). + Scans CUSTOM_TEMPLATES_DIRECTORY only; built-in defaults are excluded. """ return get_subscription_templates() diff --git a/dashboard/public/statics/locales/fa.json b/dashboard/public/statics/locales/fa.json index 39bed0c90..11c35f39d 100644 --- a/dashboard/public/statics/locales/fa.json +++ b/dashboard/public/statics/locales/fa.json @@ -941,7 +941,7 @@ "routingSettings": "Routing", "vlessRoute": "VLESS Route", "vlessRoutePlaceholder": "e.g. abcd", - "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.", + "vlessRoute.info": "مقدار مسیر اختیاری برای هاست‌های VLESS. باید دقیقاً ۴ کاراکتر شانزده‌شانزدهی (0-9، a-f، A-F) باشد. برای استفاده از مسیریابی پیش‌فرض خالی بگذارید.", "subscriptionTemplates": "قالب‌های اشتراک", "subscriptionTemplates.xray": "قالب Xray", "subscriptionTemplates.xray.info": "قالب اشتراک Xray را برای این هاست تغییر دهید. برای استفاده از قالب پیش‌فرض «پیش‌فرض» را انتخاب کنید." diff --git a/dashboard/public/statics/locales/ru.json b/dashboard/public/statics/locales/ru.json index 0a5223b50..6bd82e42d 100644 --- a/dashboard/public/statics/locales/ru.json +++ b/dashboard/public/statics/locales/ru.json @@ -1232,7 +1232,7 @@ "routingSettings": "Routing", "vlessRoute": "VLESS Route", "vlessRoutePlaceholder": "e.g. abcd", - "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.", + "vlessRoute.info": "Необязательное значение маршрута для хостов VLESS. Должно быть ровно 4 шестнадцатеричных символа (0-9, a-f, A-F). Оставьте пустым, чтобы использовать маршрут по умолчанию.", "subscriptionTemplates": "Шаблоны подписок", "subscriptionTemplates.xray": "Шаблон Xray", "subscriptionTemplates.xray.info": "Переопределить шаблон подписки Xray для этого хоста. Выберите «По умолчанию» для использования глобального шаблона." diff --git a/dashboard/public/statics/locales/zh.json b/dashboard/public/statics/locales/zh.json index 58e8dc9e0..4b8da8c19 100644 --- a/dashboard/public/statics/locales/zh.json +++ b/dashboard/public/statics/locales/zh.json @@ -1047,7 +1047,7 @@ "routingSettings": "Routing", "vlessRoute": "VLESS Route", "vlessRoutePlaceholder": "e.g. abcd", - "vlessRoute.info": "Optional route value for VLESS hosts. Must be exactly 4 hexadecimal characters (0-9, a-f, A-F). Leave empty to use default routing.", + "vlessRoute.info": "VLESS 主机的可选路由值。必须恰好为 4 个十六进制字符(0-9、a-f、A-F)。留空以使用默认路由。", "subscriptionTemplates": "订阅模板", "subscriptionTemplates.xray": "Xray 模板", "subscriptionTemplates.xray.info": "覆盖此主机的 Xray 订阅模板。选择「默认」以使用全局模板。" diff --git a/dashboard/src/components/forms/host-form.ts b/dashboard/src/components/forms/host-form.ts index 7516e6191..115991b30 100644 --- a/dashboard/src/components/forms/host-form.ts +++ b/dashboard/src/components/forms/host-form.ts @@ -53,8 +53,8 @@ export interface HostFormValues { path?: string http_headers?: Record subscription_templates?: { - xray?: string | null - [key: string]: any + xray: string | null + [key: string]: string | null } security: 'none' | 'tls' | 'inbound_default' alpn?: string[] diff --git a/dashboard/src/components/hosts/hosts-list.tsx b/dashboard/src/components/hosts/hosts-list.tsx index 8cffcc8dc..5a682d860 100644 --- a/dashboard/src/components/hosts/hosts-list.tsx +++ b/dashboard/src/components/hosts/hosts-list.tsx @@ -87,7 +87,7 @@ export default function HostsList({ data, onAddHost, isDialogOpen, onSubmit, edi sni: Array.isArray(host.sni) ? host.sni : host.sni ? [host.sni] : [], path: host.path || '', http_headers: host.http_headers || {}, - subscription_templates: (host as any).subscription_templates || undefined, + subscription_templates: host.subscription_templates ?? undefined, security: host.security || 'inbound_default', alpn: Array.isArray(host.alpn) ? host.alpn : host.alpn ? [host.alpn] : [], fingerprint: host.fingerprint || '', @@ -273,7 +273,7 @@ export default function HostsList({ data, onAddHost, isDialogOpen, onSubmit, edi mux_settings: host.mux_settings, transport_settings: host.transport_settings as any, // Type cast needed due to Output/Input mismatch http_headers: host.http_headers || {}, - subscription_templates: (host as any).subscription_templates, + subscription_templates: host.subscription_templates ?? undefined, } await createHost(newHost) @@ -499,6 +499,7 @@ export default function HostsList({ data, onAddHost, isDialogOpen, onSubmit, edi } : undefined, http_headers: host.http_headers || {}, + subscription_templates: host.subscription_templates ?? undefined, })) // Make the API call to update priorities diff --git a/dashboard/src/service/api/index.ts b/dashboard/src/service/api/index.ts index bdacada74..67290fa5c 100644 --- a/dashboard/src/service/api/index.ts +++ b/dashboard/src/service/api/index.ts @@ -1920,8 +1920,8 @@ export type CreateHostMuxSettings = MuxSettingsInput | null export type CreateHostTransportSettings = TransportSettingsInput | null export type CreateHostSubscriptionTemplates = { - xray?: string | null - [key: string]: any + xray: string | null + [key: string]: string | null } | null @@ -2168,8 +2168,8 @@ export type BaseHostAllowinsecure = boolean | null export type BaseHostAlpn = ProxyHostALPN[] | null export type BaseHostSubscriptionTemplates = { - xray?: string | null - [key: string]: any + xray: string | null + [key: string]: string | null } | null export type BaseHostPath = string | null diff --git a/tests/api/test_subscription_templates.py b/tests/api/test_subscription_templates.py index 5046e9d7e..841d2cfa0 100644 --- a/tests/api/test_subscription_templates.py +++ b/tests/api/test_subscription_templates.py @@ -35,10 +35,12 @@ def custom_templates_dir(monkeypatch): import app.templates - app.templates.env = app.templates.jinja2.Environment( + new_env = app.templates.jinja2.Environment( loader=app.templates.jinja2.FileSystemLoader([tmpdir, "app/templates"]) ) - app.templates.env.filters.update(app.templates.CUSTOM_FILTERS) + new_env.filters.update(app.templates.CUSTOM_FILTERS) + new_env.globals["now"] = app.templates.env.globals["now"] + monkeypatch.setattr("app.templates.env", new_env) yield tmpdir @@ -55,7 +57,7 @@ def no_custom_templates_dir(monkeypatch): def test_subscription_templates_list(access_token, custom_templates_dir): - """List endpoint returns custom templates grouped by format.""" + """List endpoint returns custom templates grouped by format, excluding defaults.""" response = client.get("/api/host/subscription-templates", headers=auth_headers(access_token)) assert response.status_code == status.HTTP_200_OK @@ -66,14 +68,6 @@ def test_subscription_templates_list(access_token, custom_templates_dir): assert "xray/custom.json" in data["xray"] assert "xray/another.json" in data["xray"] - - -def test_subscription_templates_list_excludes_defaults(access_token, custom_templates_dir): - """List endpoint only returns custom templates, not built-in defaults.""" - response = client.get("/api/host/subscription-templates", headers=auth_headers(access_token)) - assert response.status_code == status.HTTP_200_OK - - data = response.json() assert "xray/default.json" not in data["xray"] @@ -94,58 +88,68 @@ def test_subscription_templates_list_requires_auth(): assert response.status_code == status.HTTP_401_UNAUTHORIZED -# --- Host CRUD with subscription_templates --- +# --- Scenario 1: CUSTOM_TEMPLATES_DIRECTORY not set --- -def test_host_create_with_subscription_template(access_token, custom_templates_dir): - """Host creation accepts a valid custom subscription template.""" +def test_host_crud_without_custom_dir(access_token, no_custom_templates_dir): + """Without custom dir: null template succeeds, custom template is rejected.""" core = create_core(access_token) inbounds = get_inbounds(access_token) assert inbounds + host_id = None try: payload = { - "remark": unique_name("host_tpl"), + "remark": unique_name("host_no_dir"), "address": ["127.0.0.1"], "port": 443, "inbound_tag": inbounds[0], "priority": 1, - "subscription_templates": {"xray": "xray/custom.json"}, + "subscription_templates": None, } response = client.post("/api/host", headers=auth_headers(access_token), json=payload) assert response.status_code == status.HTTP_201_CREATED + host_id = response.json()["id"] + assert response.json()["subscription_templates"] is None - data = response.json() - assert data["subscription_templates"] == {"xray": "xray/custom.json"} - - client.delete(f"/api/host/{data['id']}", headers=auth_headers(access_token)) + payload["subscription_templates"] = {"xray": "xray/custom.json"} + response = client.post("/api/host", headers=auth_headers(access_token), json=payload) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "not available" in response.json()["detail"] finally: + if host_id: + client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token)) delete_core(access_token, core["id"]) -def test_host_create_with_null_subscription_template(access_token): - """Host creation accepts null subscription_templates (uses global default).""" +# --- Host CRUD with subscription_templates --- + + +def test_host_create_with_subscription_template(access_token, custom_templates_dir): + """Host creation accepts a valid custom subscription template.""" core = create_core(access_token) inbounds = get_inbounds(access_token) assert inbounds + host_id = None try: payload = { - "remark": unique_name("host_null_tpl"), + "remark": unique_name("host_tpl"), "address": ["127.0.0.1"], "port": 443, "inbound_tag": inbounds[0], "priority": 1, - "subscription_templates": None, + "subscription_templates": {"xray": "xray/custom.json"}, } response = client.post("/api/host", headers=auth_headers(access_token), json=payload) assert response.status_code == status.HTTP_201_CREATED data = response.json() - assert data["subscription_templates"] is None - - client.delete(f"/api/host/{data['id']}", headers=auth_headers(access_token)) + host_id = data["id"] + assert data["subscription_templates"] == {"xray": "xray/custom.json"} finally: + if host_id: + client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token)) delete_core(access_token, core["id"]) @@ -171,12 +175,13 @@ def test_host_create_with_invalid_subscription_template(access_token, custom_tem delete_core(access_token, core["id"]) -def test_host_update_subscription_template(access_token, custom_templates_dir): - """Host update can change the subscription template.""" +def test_host_update_and_clear_subscription_template(access_token, custom_templates_dir): + """Host update can set a custom template and then clear it back to null.""" core = create_core(access_token) inbounds = get_inbounds(access_token) assert inbounds + host_id = None try: create_payload = { "remark": unique_name("host_update_tpl"), @@ -202,43 +207,11 @@ def test_host_update_subscription_template(access_token, custom_templates_dir): assert update_resp.status_code == status.HTTP_200_OK assert update_resp.json()["subscription_templates"] == {"xray": "xray/another.json"} - client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token)) - finally: - delete_core(access_token, core["id"]) - - -def test_host_update_clear_subscription_template(access_token, custom_templates_dir): - """Host update can clear subscription template back to null.""" - core = create_core(access_token) - inbounds = get_inbounds(access_token) - assert inbounds - - try: - create_payload = { - "remark": unique_name("host_clear_tpl"), - "address": ["127.0.0.1"], - "port": 443, - "inbound_tag": inbounds[0], - "priority": 1, - "subscription_templates": {"xray": "xray/custom.json"}, - } - create_resp = client.post("/api/host", headers=auth_headers(access_token), json=create_payload) - assert create_resp.status_code == status.HTTP_201_CREATED - host_id = create_resp.json()["id"] - assert create_resp.json()["subscription_templates"] == {"xray": "xray/custom.json"} - - update_payload = { - "remark": create_payload["remark"], - "address": ["127.0.0.1"], - "port": 443, - "inbound_tag": inbounds[0], - "priority": 1, - "subscription_templates": None, - } - update_resp = client.put(f"/api/host/{host_id}", headers=auth_headers(access_token), json=update_payload) - assert update_resp.status_code == status.HTTP_200_OK - assert update_resp.json()["subscription_templates"] is None - - client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token)) + update_payload["subscription_templates"] = None + clear_resp = client.put(f"/api/host/{host_id}", headers=auth_headers(access_token), json=update_payload) + assert clear_resp.status_code == status.HTTP_200_OK + assert clear_resp.json()["subscription_templates"] is None finally: + if host_id: + client.delete(f"/api/host/{host_id}", headers=auth_headers(access_token)) delete_core(access_token, core["id"])