mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 15:50:59 +00:00
feat(sub): add template variables to subscription metadata (#6163)
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useRef } from 'react';
|
||||
import { Button, Input, Popover, Tooltip } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import type { TextAreaRef } from 'antd/es/input/TextArea';
|
||||
import { CodeOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { hasRemarkTokens, previewRemark, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import { hasRemarkTokens, previewRemark, SUBSCRIPTION_METADATA_VARIABLES, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import RemarkVarPicker from './RemarkVarPicker';
|
||||
|
||||
interface RemarkTemplateFieldProps {
|
||||
@@ -13,19 +14,31 @@ interface RemarkTemplateFieldProps {
|
||||
onChange?: (value: string) => void;
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
rows?: number;
|
||||
metadataOnly?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* RemarkTemplateField is a text input augmented with a {{VAR}} template picker
|
||||
* (insert-at-caret) and a live, sample-based preview of the expanded result.
|
||||
* Used for the global subscription Remark Template.
|
||||
* Used for subscription text fields that support Remark Template variables.
|
||||
*/
|
||||
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder }: RemarkTemplateFieldProps) {
|
||||
export default function RemarkTemplateField({ value = '', onChange, maxLength, placeholder, multiline = false, rows, metadataOnly = false }: RemarkTemplateFieldProps) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const textAreaRef = useRef<TextAreaRef>(null);
|
||||
const variables = metadataOnly ? SUBSCRIPTION_METADATA_VARIABLES : undefined;
|
||||
|
||||
function getTextElement() {
|
||||
if (multiline) {
|
||||
return textAreaRef.current?.resizableTextArea?.textArea ?? null;
|
||||
}
|
||||
return inputRef.current?.input ?? null;
|
||||
}
|
||||
|
||||
function insertToken(token: string) {
|
||||
const el = inputRef.current?.input;
|
||||
const el = getTextElement();
|
||||
const start = el?.selectionStart ?? value.length;
|
||||
const end = el?.selectionEnd ?? value.length;
|
||||
const insert = wrapToken(token);
|
||||
@@ -39,31 +52,47 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
|
||||
});
|
||||
}
|
||||
|
||||
const pickerButton = (
|
||||
<Popover
|
||||
content={<RemarkVarPicker onPick={insertToken} variables={variables} />}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={t('pages.hosts.remarkVars.title')}
|
||||
>
|
||||
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
suffix={
|
||||
<Popover
|
||||
content={<RemarkVarPicker onPick={insertToken} />}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
title={t('pages.hosts.remarkVars.title')}
|
||||
>
|
||||
<Tooltip title={t('pages.hosts.remarkVars.title')}>
|
||||
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ marginInlineEnd: -7 }} />
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
}
|
||||
/>
|
||||
{multiline ? (
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
|
||||
<Input.TextArea
|
||||
ref={textAreaRef}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
{pickerButton}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
suffix={pickerButton}
|
||||
/>
|
||||
)}
|
||||
{hasRemarkTokens(value) && (
|
||||
<div style={{ fontSize: 12, marginTop: 4, opacity: 0.7 }}>
|
||||
{t('pages.hosts.remarkVars.preview')}:{' '}
|
||||
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value) || '—'}</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{previewRemark(value, variables, metadataOnly) || '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,31 +2,33 @@ import { Tag, Tooltip, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
|
||||
import type { RemarkVar } from '@/lib/remark/remarkVariables';
|
||||
import { activateOnKey } from '@/utils/a11y';
|
||||
|
||||
interface RemarkVarPickerProps {
|
||||
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
|
||||
onPick: (token: string) => void;
|
||||
variables?: RemarkVar[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RemarkVarPicker is the grouped, tooltipped chip list of {{VAR}} tokens used by
|
||||
* the global remark-template field.
|
||||
*/
|
||||
export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
|
||||
export default function RemarkVarPicker({ onPick, variables = REMARK_VARIABLES }: RemarkVarPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={{ maxWidth: 460, maxHeight: 'min(70vh, 640px)', overflowY: 'auto' }}>
|
||||
<Typography.Paragraph type="secondary" style={{ fontSize: 12, marginBottom: 8 }}>
|
||||
{t('pages.hosts.remarkVars.intro')}
|
||||
</Typography.Paragraph>
|
||||
{REMARK_VAR_GROUPS.map((group) => (
|
||||
{REMARK_VAR_GROUPS.filter((group) => variables.some((v) => v.group === group)).map((group) => (
|
||||
<div key={group} style={{ marginBottom: 8 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', opacity: 0.6, marginBottom: 4 }}>
|
||||
{t(`pages.hosts.remarkVars.groups.${group}`)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
||||
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
|
||||
{variables.filter((v) => v.group === group).map((v) => (
|
||||
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
|
||||
<Tag
|
||||
role="button"
|
||||
|
||||
@@ -51,6 +51,14 @@ export const REMARK_VARIABLES: RemarkVar[] = [
|
||||
{ token: 'SECURITY', group: 'connection', sample: 'TLS' },
|
||||
];
|
||||
|
||||
export const SUBSCRIPTION_METADATA_VARIABLES: RemarkVar[] = REMARK_VARIABLES.filter((v) => (
|
||||
v.token === 'EMAIL'
|
||||
|| v.token === 'ID'
|
||||
|| v.token === 'SHORT_ID'
|
||||
|| v.token === 'TELEGRAM_ID'
|
||||
|| v.token === 'SUB_ID'
|
||||
));
|
||||
|
||||
const SAMPLE_BY_TOKEN: Record<string, string> = Object.fromEntries(
|
||||
REMARK_VARIABLES.map((v) => [v.token, v.sample]),
|
||||
);
|
||||
@@ -70,9 +78,14 @@ export function hasRemarkTokens(template: string): boolean {
|
||||
/**
|
||||
* previewRemark renders a template against the sample values, mirroring the
|
||||
* backend substitution closely enough for an at-a-glance preview. Unknown
|
||||
* tokens collapse to empty, just like the server.
|
||||
* tokens collapse to empty by default; metadata fields can keep unsupported
|
||||
* tokens literal because the backend does the same for backwards compatibility.
|
||||
*/
|
||||
export function previewRemark(template: string): string {
|
||||
export function previewRemark(template: string, variables: RemarkVar[] = REMARK_VARIABLES, keepUnknown = false): string {
|
||||
if (!hasRemarkTokens(template)) return template;
|
||||
return template.replace(TOKEN_RE, (_m, tok: string) => SAMPLE_BY_TOKEN[tok] ?? '');
|
||||
const allowed = new Set(variables.map((v) => v.token));
|
||||
return template.replace(TOKEN_RE, (match, tok: string) => {
|
||||
if (!allowed.has(tok)) return keepUnknown ? match : '';
|
||||
return SAMPLE_BY_TOKEN[tok] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,19 +118,36 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
||||
children: (
|
||||
<>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subTitle')} description={t('pages.settings.subTitleDesc')}>
|
||||
<Input value={allSetting.subTitle} onChange={(e) => updateSetting({ subTitle: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subTitle}
|
||||
onChange={(v) => updateSetting({ subTitle: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subSupportUrl')} description={t('pages.settings.subSupportUrlDesc')}>
|
||||
<Input value={allSetting.subSupportUrl} placeholder="https://example.com"
|
||||
onChange={(e) => updateSetting({ subSupportUrl: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subSupportUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subSupportUrl: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subProfileUrl')} description={t('pages.settings.subProfileUrlDesc')}>
|
||||
<Input value={allSetting.subProfileUrl} placeholder="https://example.com"
|
||||
onChange={(e) => updateSetting({ subProfileUrl: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem paddings="small" title={t('pages.settings.subAnnounce')} description={t('pages.settings.subAnnounceDesc')}>
|
||||
<Input.TextArea value={allSetting.subAnnounce}
|
||||
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subAnnounce}
|
||||
onChange={(v) => updateSetting({ subAnnounce: v })}
|
||||
multiline
|
||||
rows={3}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import RemarkTemplateField from '@/components/form/RemarkTemplateField';
|
||||
import { previewRemark, SUBSCRIPTION_METADATA_VARIABLES } from '@/lib/remark/remarkVariables';
|
||||
|
||||
describe('RemarkTemplateField', () => {
|
||||
it('inserts a {{TOKEN}} when a variable chip is clicked', async () => {
|
||||
@@ -23,4 +24,32 @@ describe('RemarkTemplateField', () => {
|
||||
// Sample expansion of {{EMAIL}} is "john".
|
||||
expect(screen.getByText('john')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports token insertion in multiline fields', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<RemarkTemplateField value="Hello " onChange={onChange} multiline rows={3} />);
|
||||
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
fireEvent.click(await screen.findByText('{{SUB_ID}}'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange.mock.calls[0][0]).toBe('Hello {{SUB_ID}}');
|
||||
});
|
||||
|
||||
it('limits the picker to client identity tokens for metadata fields', async () => {
|
||||
render(<RemarkTemplateField value="" onChange={() => {}} metadataOnly />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(await screen.findByText('{{EMAIL}}')).toBeTruthy();
|
||||
expect(screen.queryByText('{{INBOUND}}')).toBeNull();
|
||||
expect(screen.queryByText('{{TRAFFIC_LEFT}}')).toBeNull();
|
||||
});
|
||||
|
||||
it('previews metadata fields with metadata-safe tokens only', () => {
|
||||
expect(previewRemark('{{EMAIL}}/{{TRAFFIC_LEFT}}', SUBSCRIPTION_METADATA_VARIABLES, true)).toBe('john/{{TRAFFIC_LEFT}}');
|
||||
});
|
||||
});
|
||||
|
||||
+27
-19
@@ -354,7 +354,9 @@ func (a *SUBController) buildSubPageData(c *gin.Context) (PageData, bool) {
|
||||
basePath = "/"
|
||||
}
|
||||
basePathStr := basePath.(string)
|
||||
page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, a.subTitle, a.subSupportUrl)
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, "")
|
||||
page := subReq.BuildPageData(subId, hostHeader, traffic, lastOnline, subs, emails, subURL, subJsonURL, subClashURL, basePathStr, metadata.Title, metadata.SupportURL)
|
||||
page.SubAnnounce = metadata.Announce
|
||||
return page, true
|
||||
}
|
||||
|
||||
@@ -415,11 +417,9 @@ func (a *SUBController) subs(c *gin.Context) {
|
||||
|
||||
// Add headers
|
||||
header := fmt.Sprintf("upload=%d; download=%d; total=%d; expire=%d", traffic.Up, traffic.Down, traffic.Total, traffic.ExpiryTime/1000)
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return subReq }, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
|
||||
if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
|
||||
result.WriteString(a.subIncyRoutingRules)
|
||||
@@ -605,7 +605,7 @@ func (a *SUBController) subPageContext(page PageData) map[string]any {
|
||||
"links": page.Result,
|
||||
"emails": page.Emails,
|
||||
"datepicker": datepicker,
|
||||
"announce": a.subAnnounce,
|
||||
"announce": page.SubAnnounce,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -731,11 +731,15 @@ func (a *SUBController) serveJsonBody(c *gin.Context, alwaysReturnArray bool, co
|
||||
if len(jsonSub) == 0 {
|
||||
return false
|
||||
}
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
var subReq *SubService
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
if subReq == nil {
|
||||
subReq = a.subService.ForRequest(host)
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.json"`)
|
||||
}
|
||||
@@ -775,16 +779,20 @@ func (a *SUBController) serveClashBody(c *gin.Context, rawDownload bool) bool {
|
||||
if len(clashSub) == 0 {
|
||||
return false
|
||||
}
|
||||
profileUrl := a.subProfileUrl
|
||||
if profileUrl == "" {
|
||||
profileUrl = fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
}
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
profileURL := fmt.Sprintf("%s://%s%s", scheme, hostWithPort, c.Request.RequestURI)
|
||||
var subReq *SubService
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
if subReq == nil {
|
||||
subReq = a.subService.ForRequest(host)
|
||||
}
|
||||
return subReq
|
||||
}, subId, profileURL)
|
||||
a.ApplyCommonHeaders(c, header, a.updateInterval, metadata.Title, metadata.SupportURL, metadata.ProfileURL, metadata.Announce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
|
||||
if rawDownload {
|
||||
c.Writer.Header().Set("Content-Disposition", `attachment; filename="subscription.yaml"`)
|
||||
} else if a.subTitle != "" {
|
||||
} else if metadata.Title != "" {
|
||||
// Clash clients commonly use Content-Disposition to choose the imported profile name.
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(a.subTitle)))
|
||||
c.Writer.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename*=UTF-8''%s`, url.PathEscape(metadata.Title)))
|
||||
}
|
||||
c.Data(200, "application/yaml; charset=utf-8", []byte(clashSub))
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
)
|
||||
|
||||
type subPlaceholderData struct {
|
||||
SubID string
|
||||
Context remarkContext
|
||||
HasCtx bool
|
||||
Escape bool
|
||||
}
|
||||
|
||||
type renderedSubMetadata struct {
|
||||
Title string
|
||||
SupportURL string
|
||||
ProfileURL string
|
||||
Announce string
|
||||
}
|
||||
|
||||
func renderSubPlaceholders(value string, data subPlaceholderData) string {
|
||||
if value == "" || !strings.Contains(value, "{") {
|
||||
return value
|
||||
}
|
||||
|
||||
ctx := data.Context
|
||||
if !data.HasCtx {
|
||||
ctx = remarkContext{
|
||||
client: model.Client{
|
||||
SubID: data.SubID,
|
||||
},
|
||||
}
|
||||
}
|
||||
if ctx.client.SubID == "" {
|
||||
ctx.client.SubID = data.SubID
|
||||
}
|
||||
return strings.TrimSpace(expandSubMetadataVars(value, ctx, data.Escape))
|
||||
}
|
||||
|
||||
var subMetadataTokens = map[string]bool{
|
||||
"EMAIL": true,
|
||||
"ID": true,
|
||||
"SHORT_ID": true,
|
||||
"TELEGRAM_ID": true,
|
||||
"SUB_ID": true,
|
||||
}
|
||||
|
||||
func expandSubMetadataVars(template string, ctx remarkContext, escape bool) string {
|
||||
return remarkVarRe.ReplaceAllStringFunc(template, func(match string) string {
|
||||
token := match[2 : len(match)-2]
|
||||
if !subMetadataTokens[token] {
|
||||
return match
|
||||
}
|
||||
value := remarkVarValue(token, ctx)
|
||||
if escape {
|
||||
return url.QueryEscape(value)
|
||||
}
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
func subMetadataUsesPlaceholders(values ...string) bool {
|
||||
for _, value := range values {
|
||||
if strings.Contains(value, "{") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *SUBController) metadataForSubRequest(getSubReq func() *SubService, subID string, fallbackProfileURL string) renderedSubMetadata {
|
||||
var context remarkContext
|
||||
var hasContext bool
|
||||
if subMetadataUsesPlaceholders(a.subTitle, a.subSupportUrl, a.subProfileUrl, a.subAnnounce) {
|
||||
var err error
|
||||
subReq := getSubReq()
|
||||
context, hasContext, err = subReq.subscriptionTemplateContextBySubID(subID)
|
||||
if err != nil {
|
||||
logger.Warning("sub: load template contexts for subscription metadata:", err)
|
||||
}
|
||||
}
|
||||
profileURL := a.subProfileUrl
|
||||
if profileURL == "" {
|
||||
profileURL = fallbackProfileURL
|
||||
} else {
|
||||
profileURL = renderSubPlaceholders(profileURL, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true})
|
||||
}
|
||||
data := subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext}
|
||||
return renderedSubMetadata{
|
||||
Title: renderSubPlaceholders(a.subTitle, data),
|
||||
SupportURL: renderSubPlaceholders(a.subSupportUrl, subPlaceholderData{SubID: subID, Context: context, HasCtx: hasContext, Escape: true}),
|
||||
ProfileURL: profileURL,
|
||||
Announce: renderSubPlaceholders(a.subAnnounce, data),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SubService) subscriptionTemplateContextBySubID(subID string) (remarkContext, bool, error) {
|
||||
if subID == "" {
|
||||
return remarkContext{}, false, nil
|
||||
}
|
||||
var rec model.ClientRecord
|
||||
err := database.GetDB().Where("sub_id = ?", subID).Order("id ASC").First(&rec).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return remarkContext{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return remarkContext{}, false, err
|
||||
}
|
||||
return remarkContext{client: *rec.ToClient()}, true, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package sub
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
)
|
||||
|
||||
func TestRenderSubPlaceholders(t *testing.T) {
|
||||
data := subPlaceholderData{
|
||||
SubID: "sub-123",
|
||||
Context: remarkContext{client: model.Client{
|
||||
Email: "Ilnur",
|
||||
ID: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
SubID: "sub-123",
|
||||
TgID: 42,
|
||||
Enable: true,
|
||||
}},
|
||||
HasCtx: true,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tmpl string
|
||||
data subPlaceholderData
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "identity tokens",
|
||||
tmpl: "{{EMAIL}}/{{ID}}/{{SHORT_ID}}/{{SUB_ID}}/{{TELEGRAM_ID}}",
|
||||
data: data,
|
||||
want: "Ilnur/abcdef12-3456-7890-abcd-ef1234567890/abcdef12/sub-123/42",
|
||||
},
|
||||
{
|
||||
name: "no template",
|
||||
tmpl: "isVPN",
|
||||
data: subPlaceholderData{SubID: "sub-123"},
|
||||
want: "isVPN",
|
||||
},
|
||||
{
|
||||
name: "unsupported tokens stay literal",
|
||||
tmpl: "{{SUB_ID}}/{{INBOUND}}/{{TRAFFIC_LEFT}}/{{PROTOCOL}}/{EMAIL}",
|
||||
data: subPlaceholderData{SubID: "sub-123"},
|
||||
want: "sub-123/{{INBOUND}}/{{TRAFFIC_LEFT}}/{{PROTOCOL}}/{EMAIL}",
|
||||
},
|
||||
{
|
||||
name: "URL values are escaped",
|
||||
tmpl: "https://support.example/?email={{EMAIL}}&sub={{SUB_ID}}",
|
||||
data: subPlaceholderData{
|
||||
SubID: "sub id",
|
||||
Context: remarkContext{client: model.Client{
|
||||
Email: "john doe@example.com",
|
||||
SubID: "sub id",
|
||||
}},
|
||||
HasCtx: true,
|
||||
Escape: true,
|
||||
},
|
||||
want: "https://support.example/?email=john+doe%40example.com&sub=sub+id",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := renderSubPlaceholders(tt.tmpl, tt.data); got != tt.want {
|
||||
t.Fatalf("renderSubPlaceholders() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataForSubRequestDoesNotExpandFallbackProfileURL(t *testing.T) {
|
||||
a := &SUBController{
|
||||
subTitle: "isVPN",
|
||||
subSupportUrl: "https://support.example/",
|
||||
}
|
||||
fallback := "https://sub.example.com/sub/sub-123?x={{EMAIL}}"
|
||||
|
||||
metadata := a.metadataForSubRequest(func() *SubService {
|
||||
t.Fatal("metadataForSubRequest loaded a subscription context without configured placeholders")
|
||||
return nil
|
||||
}, "sub-123", fallback)
|
||||
|
||||
if metadata.ProfileURL != fallback {
|
||||
t.Fatalf("ProfileURL = %q, want untouched fallback %q", metadata.ProfileURL, fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadataForSubRequestUsesStableClientIdentity(t *testing.T) {
|
||||
initSubDB(t)
|
||||
db := database.GetDB()
|
||||
first := model.ClientRecord{
|
||||
Email: "john doe@example.com",
|
||||
SubID: "sub-123",
|
||||
UUID: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
TgID: 42,
|
||||
Enable: true,
|
||||
}
|
||||
second := model.ClientRecord{
|
||||
Email: "jane@example.com",
|
||||
SubID: "sub-123",
|
||||
UUID: "fedcba98-3456-7890-abcd-ef1234567890",
|
||||
TgID: 99,
|
||||
Enable: true,
|
||||
}
|
||||
if err := db.Create(&first).Error; err != nil {
|
||||
t.Fatalf("seed first client: %v", err)
|
||||
}
|
||||
if err := db.Create(&second).Error; err != nil {
|
||||
t.Fatalf("seed second client: %v", err)
|
||||
}
|
||||
|
||||
a := &SUBController{
|
||||
subTitle: "isVPN — {{EMAIL}}",
|
||||
subSupportUrl: "https://support.example/?email={{EMAIL}}&tg={{TELEGRAM_ID}}",
|
||||
subProfileUrl: "https://profile.example/account/{{ID}}",
|
||||
subAnnounce: "Subscription {{SUB_ID}}",
|
||||
}
|
||||
metadata := a.metadataForSubRequest(func() *SubService { return &SubService{} }, "sub-123", "https://fallback.example/{{EMAIL}}")
|
||||
|
||||
if metadata.Title != "isVPN — john doe@example.com" {
|
||||
t.Fatalf("Title = %q", metadata.Title)
|
||||
}
|
||||
if metadata.SupportURL != "https://support.example/?email=john+doe%40example.com&tg=42" {
|
||||
t.Fatalf("SupportURL = %q", metadata.SupportURL)
|
||||
}
|
||||
if metadata.ProfileURL != "https://profile.example/account/abcdef12-3456-7890-abcd-ef1234567890" {
|
||||
t.Fatalf("ProfileURL = %q", metadata.ProfileURL)
|
||||
}
|
||||
if metadata.Announce != "Subscription sub-123" {
|
||||
t.Fatalf("Announce = %q", metadata.Announce)
|
||||
}
|
||||
}
|
||||
@@ -2488,6 +2488,7 @@ type PageData struct {
|
||||
SubClashUrl string
|
||||
SubTitle string
|
||||
SubSupportUrl string
|
||||
SubAnnounce string
|
||||
Result []string
|
||||
Emails []string
|
||||
}
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "تعبير User-Agent لعملاء Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "تعبير Go RE2 منتظم يُطابَق مع وكيل المستخدم (User-Agent) للتعرف على عملاء Clash/Mihomo في رابط الاشتراك القياسي. اتركه فارغًا لاستخدام النمط الافتراضي. أعد تشغيل اللوحة بعد التغيير.",
|
||||
"subTitle": "عنوان الاشتراك",
|
||||
"subTitleDesc": "العنوان اللي هيظهر في عميل VPN",
|
||||
"subTitleDesc": "العنوان اللي هيظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "رابط الدعم",
|
||||
"subSupportUrlDesc": "رابط الدعم الفني المعروض في عميل VPN",
|
||||
"subSupportUrlDesc": "رابط الدعم الفني المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "رابط الملف الشخصي",
|
||||
"subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN",
|
||||
"subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "إعلان",
|
||||
"subAnnounceDesc": "نص الإعلان المعروض في عميل VPN",
|
||||
"subAnnounceDesc": "نص الإعلان المعروض في عميل VPN. يدعم رموز هوية العميل: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "مجلد قالب الاشتراك",
|
||||
"subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.",
|
||||
"subThemeDirDocs": "دليل القالب ↗",
|
||||
|
||||
@@ -1238,13 +1238,13 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent regex",
|
||||
"subClashUserAgentRegexDesc": "Go RE2 regular expression matched against the client's User-Agent to recognize Clash/Mihomo clients on the standard subscription URL. Leave empty to use the default pattern. Restart the panel after changes.",
|
||||
"subTitle": "Subscription Title",
|
||||
"subTitleDesc": "Title shown in VPN client",
|
||||
"subTitleDesc": "Title shown in VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "Support URL",
|
||||
"subSupportUrlDesc": "Technical support link shown in the VPN client",
|
||||
"subSupportUrlDesc": "Technical support link shown in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "Profile URL",
|
||||
"subProfileUrlDesc": "A link to your website displayed in the VPN client",
|
||||
"subProfileUrlDesc": "A link to your website displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Announce",
|
||||
"subAnnounceDesc": "The announcement text displayed in the VPN client",
|
||||
"subAnnounceDesc": "The announcement text displayed in the VPN client. Supports client identity tokens: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Sub Theme Directory",
|
||||
"subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.",
|
||||
"subThemeDirDocs": "Template guide ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Expresión User-Agent de Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "Expresión regular Go RE2 que se compara con el User-Agent del cliente para reconocer clientes Clash/Mihomo en la URL de suscripción estándar. Déjala vacía para usar el patrón predeterminado. Reinicia el panel después de cambiarla.",
|
||||
"subTitle": "Título de la Suscripción",
|
||||
"subTitleDesc": "Título mostrado en el cliente VPN",
|
||||
"subTitleDesc": "Título mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "URL de soporte",
|
||||
"subSupportUrlDesc": "Enlace de soporte técnico mostrado en el cliente VPN",
|
||||
"subSupportUrlDesc": "Enlace de soporte técnico mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "URL del perfil",
|
||||
"subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN",
|
||||
"subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Anuncio",
|
||||
"subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN",
|
||||
"subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN. Admite tokens de identidad del cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Directorio del tema de suscripción",
|
||||
"subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.",
|
||||
"subThemeDirDocs": "Guía de plantillas ↗",
|
||||
|
||||
@@ -1121,13 +1121,13 @@
|
||||
"subClashUserAgentRegex": "عبارت User-Agent برای Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "عبارت منظم Go RE2 که با عامل کاربر (User-Agent) کلاینت مطابقت داده میشود تا کلاینتهای Clash/Mihomo در آدرس استاندارد اشتراک شناسایی شوند. برای استفاده از الگوی پیشفرض خالی بگذارید. پس از تغییر، پنل را راهاندازی مجدد کنید.",
|
||||
"subTitle": "عنوان اشتراک",
|
||||
"subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN",
|
||||
"subTitleDesc": "عنوان نمایش داده شده در کلاینت VPN. از توکنهای هویت کلاینت پشتیبانی میکند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "آدرس پشتیبانی",
|
||||
"subSupportUrlDesc": "لینک پشتیبانی فنی که در کلاینت VPN نمایش داده میشود",
|
||||
"subSupportUrlDesc": "لینک پشتیبانی فنی که در کلاینت VPN نمایش داده میشود. از توکنهای هویت کلاینت پشتیبانی میکند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "آدرس پروفایل",
|
||||
"subProfileUrlDesc": "لینک وبسایت شما که در کلاینت VPN نمایش داده میشود",
|
||||
"subProfileUrlDesc": "لینک وبسایت شما که در کلاینت VPN نمایش داده میشود. از توکنهای هویت کلاینت پشتیبانی میکند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "اعلان",
|
||||
"subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده میشود",
|
||||
"subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده میشود. از توکنهای هویت کلاینت پشتیبانی میکند: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "پوشه قالب صفحه اشتراک",
|
||||
"subThemeDirDesc": "مسیر مطلق پوشهای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیشفرض خالی بگذارید.",
|
||||
"subThemeDirDocs": "راهنمای قالب ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Regex User-Agent Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "Ekspresi reguler Go RE2 yang dicocokkan dengan User-Agent klien untuk mengenali klien Clash/Mihomo pada URL langganan standar. Kosongkan untuk memakai pola bawaan. Mulai ulang panel setelah mengubahnya.",
|
||||
"subTitle": "Judul Langganan",
|
||||
"subTitleDesc": "Judul yang ditampilkan di klien VPN",
|
||||
"subTitleDesc": "Judul yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "URL Dukungan",
|
||||
"subSupportUrlDesc": "Tautan dukungan teknis yang ditampilkan di klien VPN",
|
||||
"subSupportUrlDesc": "Tautan dukungan teknis yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "URL Profil",
|
||||
"subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN",
|
||||
"subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Pengumuman",
|
||||
"subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN",
|
||||
"subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN. Mendukung token identitas klien: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Direktori Tema Langganan",
|
||||
"subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.",
|
||||
"subThemeDirDocs": "Panduan templat ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表現",
|
||||
"subClashUserAgentRegexDesc": "標準サブスクリプション URL で Clash/Mihomo クライアントを識別するため、クライアントの User-Agent と照合する Go RE2 正規表現です。空欄の場合は既定のパターンを使用します。変更後にパネルを再起動してください。",
|
||||
"subTitle": "サブスクリプションタイトル",
|
||||
"subTitleDesc": "VPNクライアントに表示されるタイトル",
|
||||
"subTitleDesc": "VPNクライアントに表示されるタイトル。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
|
||||
"subSupportUrl": "サポートURL",
|
||||
"subSupportUrlDesc": "VPNクライアントに表示されるテクニカルサポートへのリンク",
|
||||
"subSupportUrlDesc": "VPNクライアントに表示されるテクニカルサポートへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
|
||||
"subProfileUrl": "プロフィールURL",
|
||||
"subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク",
|
||||
"subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
|
||||
"subAnnounce": "お知らせ",
|
||||
"subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト",
|
||||
"subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト。クライアント識別トークンをサポートします: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}。",
|
||||
"subThemeDir": "サブスクリプションテーマディレクトリ",
|
||||
"subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。",
|
||||
"subThemeDirDocs": "テンプレートガイド ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Expressão User-Agent do Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "Expressão regular Go RE2 comparada com o User-Agent do cliente para reconhecer clientes Clash/Mihomo na URL de assinatura padrão. Deixe em branco para usar o padrão predefinido. Reinicie o painel após alterá-la.",
|
||||
"subTitle": "Título da Assinatura",
|
||||
"subTitleDesc": "Título exibido no cliente VPN",
|
||||
"subTitleDesc": "Título exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "URL de Suporte",
|
||||
"subSupportUrlDesc": "Link de suporte técnico exibido no cliente VPN",
|
||||
"subSupportUrlDesc": "Link de suporte técnico exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "URL de Perfil",
|
||||
"subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN",
|
||||
"subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Anúncio",
|
||||
"subAnnounceDesc": "O texto do anúncio exibido no cliente VPN",
|
||||
"subAnnounceDesc": "O texto do anúncio exibido no cliente VPN. Suporta tokens de identidade do cliente: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Diretório do tema de assinatura",
|
||||
"subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.",
|
||||
"subThemeDirDocs": "Guia de modelos ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Регулярное выражение User-Agent Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "Регулярное выражение Go RE2, сопоставляемое с User-Agent клиента для распознавания клиентов Clash/Mihomo на стандартном URL подписки. Оставьте поле пустым, чтобы использовать шаблон по умолчанию. После изменения перезапустите панель.",
|
||||
"subTitle": "Заголовок подписки",
|
||||
"subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте",
|
||||
"subTitleDesc": "Название подписки, которое видит клиент в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "URL поддержки",
|
||||
"subSupportUrlDesc": "Ссылка на техническую поддержку, отображаемая в VPN-клиенте",
|
||||
"subSupportUrlDesc": "Ссылка на техническую поддержку, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "URL профиля",
|
||||
"subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте",
|
||||
"subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Объявление",
|
||||
"subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте",
|
||||
"subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте. Поддерживает токены идентификации клиента: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Каталог темы подписки",
|
||||
"subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.",
|
||||
"subThemeDirDocs": "Руководство по шаблонам ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent düzenli ifadesi",
|
||||
"subClashUserAgentRegexDesc": "Standart abonelik URL'sinde Clash/Mihomo istemcilerini tanımak için istemcinin User-Agent değeriyle eşleştirilen Go RE2 düzenli ifadesi. Varsayılan deseni kullanmak için boş bırakın. Değişiklikten sonra paneli yeniden başlatın.",
|
||||
"subTitle": "Abonelik Başlığı",
|
||||
"subTitleDesc": "VPN istemcisinde gösterilen başlık.",
|
||||
"subTitleDesc": "VPN istemcisinde gösterilen başlık. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "Destek URL'si",
|
||||
"subSupportUrlDesc": "VPN istemcisinde gösterilen teknik destek bağlantısı.",
|
||||
"subSupportUrlDesc": "VPN istemcisinde gösterilen teknik destek bağlantısı. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "Profil URL'si",
|
||||
"subProfileUrlDesc": "VPN istemcisinde görüntülenen web sitenize giden bağlantı.",
|
||||
"subProfileUrlDesc": "VPN istemcisinde görüntülenen web sitenize giden bağlantı. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Duyuru",
|
||||
"subAnnounceDesc": "VPN istemcisinde görüntülenen duyuru metni",
|
||||
"subAnnounceDesc": "VPN istemcisinde görüntülenen duyuru metni. İstemci kimlik tokenlarını destekler: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Abonelik Tema Dizini",
|
||||
"subThemeDirDesc": "Abonelik sayfası için özel bir şablon (index.html/sub.html) içeren klasörün mutlak yolu (örn. /etc/3x-ui/sub_templates/my-theme/). Varsayılan sayfayı kullanmak için boş bırakın.",
|
||||
"subThemeDirDocs": "Şablon kılavuzu ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Регулярний вираз User-Agent Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "Регулярний вираз Go RE2, який зіставляється з User-Agent клієнта для розпізнавання клієнтів Clash/Mihomo на стандартній URL-адресі підписки. Залиште поле порожнім для стандартного шаблону. Після зміни перезапустіть панель.",
|
||||
"subTitle": "Назва Підписки",
|
||||
"subTitleDesc": "Назва, яка відображається у VPN-клієнті",
|
||||
"subTitleDesc": "Назва, яка відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "URL підтримки",
|
||||
"subSupportUrlDesc": "Посилання на технічну підтримку, що відображається у VPN-клієнті",
|
||||
"subSupportUrlDesc": "Посилання на технічну підтримку, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "URL профілю",
|
||||
"subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті",
|
||||
"subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Оголошення",
|
||||
"subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті",
|
||||
"subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті. Підтримує токени ідентифікації клієнта: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Каталог теми підписки",
|
||||
"subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.",
|
||||
"subThemeDirDocs": "Посібник із шаблонів ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Biểu thức User-Agent Clash/Mihomo",
|
||||
"subClashUserAgentRegexDesc": "Biểu thức chính quy Go RE2 được so khớp với User-Agent của ứng dụng để nhận diện ứng dụng Clash/Mihomo trên URL đăng ký tiêu chuẩn. Để trống để dùng mẫu mặc định. Khởi động lại bảng điều khiển sau khi thay đổi.",
|
||||
"subTitle": "Tiêu đề Đăng ký",
|
||||
"subTitleDesc": "Tiêu đề hiển thị trong ứng dụng VPN",
|
||||
"subTitleDesc": "Tiêu đề hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subSupportUrl": "URL Hỗ trợ",
|
||||
"subSupportUrlDesc": "Liên kết hỗ trợ kỹ thuật hiển thị trong ứng dụng VPN",
|
||||
"subSupportUrlDesc": "Liên kết hỗ trợ kỹ thuật hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subProfileUrl": "URL Hồ sơ",
|
||||
"subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN",
|
||||
"subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subAnnounce": "Thông báo",
|
||||
"subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN",
|
||||
"subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN. Hỗ trợ token định danh khách hàng: {{EMAIL}}, {{ID}}, {{SHORT_ID}}, {{SUB_ID}}, {{TELEGRAM_ID}}.",
|
||||
"subThemeDir": "Thư mục giao diện Đăng ký",
|
||||
"subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.",
|
||||
"subThemeDirDocs": "Hướng dẫn mẫu ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正则表达式",
|
||||
"subClashUserAgentRegexDesc": "用于与客户端 User-Agent 进行匹配,从而在标准订阅 URL 上识别 Clash/Mihomo 客户端的 Go RE2 正则表达式。留空则使用默认规则。更改后请重启面板。",
|
||||
"subTitle": "订阅标题",
|
||||
"subTitleDesc": "在 VPN 客户端中显示的标题",
|
||||
"subTitleDesc": "在 VPN 客户端中显示的标题。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subSupportUrl": "支持链接",
|
||||
"subSupportUrlDesc": "VPN 客户端中显示的技术支持链接",
|
||||
"subSupportUrlDesc": "VPN 客户端中显示的技术支持链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subProfileUrl": "个人资料链接",
|
||||
"subProfileUrlDesc": "VPN 客户端中显示的网站链接",
|
||||
"subProfileUrlDesc": "VPN 客户端中显示的网站链接。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subAnnounce": "公告",
|
||||
"subAnnounceDesc": "VPN 客户端中显示的公告文本",
|
||||
"subAnnounceDesc": "VPN 客户端中显示的公告文本。支持客户端身份令牌:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subThemeDir": "订阅主题目录",
|
||||
"subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
|
||||
"subThemeDirDocs": "模板指南 ↗",
|
||||
|
||||
@@ -1117,13 +1117,13 @@
|
||||
"subClashUserAgentRegex": "Clash/Mihomo User-Agent 正規表示式",
|
||||
"subClashUserAgentRegexDesc": "用於與用戶端 User-Agent 進行比對,以便在標準訂閱 URL 上識別 Clash/Mihomo 用戶端的 Go RE2 正規表示式。留空則使用預設規則。變更後請重新啟動面板。",
|
||||
"subTitle": "訂閱標題",
|
||||
"subTitleDesc": "在 VPN 客戶端中顯示的標題",
|
||||
"subTitleDesc": "在 VPN 客戶端中顯示的標題。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subSupportUrl": "支援連結",
|
||||
"subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結",
|
||||
"subSupportUrlDesc": "VPN 用戶端中顯示的技術支援連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subProfileUrl": "個人資料連結",
|
||||
"subProfileUrlDesc": "VPN 用戶端中顯示的網站連結",
|
||||
"subProfileUrlDesc": "VPN 用戶端中顯示的網站連結。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subAnnounce": "公告",
|
||||
"subAnnounceDesc": "VPN 用戶端中顯示的公告文字",
|
||||
"subAnnounceDesc": "VPN 用戶端中顯示的公告文字。支援用戶端身分權杖:{{EMAIL}}、{{ID}}、{{SHORT_ID}}、{{SUB_ID}}、{{TELEGRAM_ID}}。",
|
||||
"subThemeDir": "訂閱主題目錄",
|
||||
"subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。",
|
||||
"subThemeDirDocs": "範本指南 ↗",
|
||||
|
||||
Reference in New Issue
Block a user