mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-30 15:07:14 +00:00
feat(settings): let users clear stored secrets from the UI
Redacted secrets (SMTP password, Telegram bot token, LDAP password) are always served blank to the browser, so the update path treats a blank submission as "unchanged" and silently restores the stored value. That made a once-set secret impossible to remove without editing the database — e.g. switching to a passwordless localhost SMTP relay kept sending the old credentials forever. Blank stays "unchanged"; clearing is now its own signal. The update request carries explicit clear flags (request-scoped fields on the controller form, so they are never persisted as settings rows), and preserveRedactedSecrets skips the restore for a flagged secret. Each secret field gets a Clear/Undo button that arms the flag; typing a new value disarms it. The 2FA token keeps its existing behavior: it is already clearable by disabling 2FA. Closes #5724
This commit is contained in:
@@ -103,6 +103,9 @@ export class AllSetting {
|
|||||||
hasWarpSecret = false;
|
hasWarpSecret = false;
|
||||||
hasNordSecret = false;
|
hasNordSecret = false;
|
||||||
hasSmtpPassword = false;
|
hasSmtpPassword = false;
|
||||||
|
clearTgBotToken = false;
|
||||||
|
clearLdapPassword = false;
|
||||||
|
clearSmtpPassword = false;
|
||||||
|
|
||||||
constructor(data?: unknown) {
|
constructor(data?: unknown) {
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SettingListItem } from '@/components/ui';
|
|||||||
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
|
import SecretInput from './SecretInput';
|
||||||
|
|
||||||
interface EmailTabProps {
|
interface EmailTabProps {
|
||||||
allSetting: AllSetting;
|
allSetting: AllSetting;
|
||||||
@@ -72,10 +73,13 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) {
|
|||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
|
<SettingListItem paddings="small" title={t('pages.settings.smtpPassword')}
|
||||||
description={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
|
description={allSetting.hasSmtpPassword && !allSetting.clearSmtpPassword ? t('pages.settings.smtpPasswordConfigured') : t('pages.settings.smtpPasswordDesc')}>
|
||||||
<Input.Password value={allSetting.smtpPassword}
|
<SecretInput value={allSetting.smtpPassword}
|
||||||
placeholder={allSetting.hasSmtpPassword ? t('pages.settings.smtpPasswordPlaceholder') : ''}
|
configured={allSetting.hasSmtpPassword}
|
||||||
onChange={(e) => updateSetting({ smtpPassword: e.target.value })} />
|
clearArmed={allSetting.clearSmtpPassword}
|
||||||
|
placeholder={t('pages.settings.smtpPasswordPlaceholder')}
|
||||||
|
onChange={(v) => updateSetting({ smtpPassword: v })}
|
||||||
|
onClearArmedChange={(armed) => updateSetting({ clearSmtpPassword: armed })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.smtpTo')} description={t('pages.settings.smtpToDesc')}>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { SettingListItem } from '@/components/ui';
|
|||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
import { sanitizePath } from './uriPath';
|
import { sanitizePath } from './uriPath';
|
||||||
|
import SecretInput from './SecretInput';
|
||||||
|
|
||||||
interface ApiMsg<T = unknown> {
|
interface ApiMsg<T = unknown> {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
@@ -329,12 +330,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
|||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('password')}
|
title={t('password')}
|
||||||
description={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
|
description={allSetting.hasLdapPassword && !allSetting.clearLdapPassword ? t('pages.settings.ldap.passwordConfigured') : t('pages.settings.ldap.passwordUnconfigured')}
|
||||||
>
|
>
|
||||||
<Input.Password
|
<SecretInput
|
||||||
value={allSetting.ldapPassword}
|
value={allSetting.ldapPassword}
|
||||||
placeholder={allSetting.hasLdapPassword ? t('pages.settings.ldap.passwordPlaceholder') : ''}
|
configured={allSetting.hasLdapPassword}
|
||||||
onChange={(e) => updateSetting({ ldapPassword: e.target.value })}
|
clearArmed={allSetting.clearLdapPassword}
|
||||||
|
placeholder={t('pages.settings.ldap.passwordPlaceholder')}
|
||||||
|
onChange={(v) => updateSetting({ ldapPassword: v })}
|
||||||
|
onClearArmedChange={(armed) => updateSetting({ clearLdapPassword: armed })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
|
<SettingListItem paddings="small" title={t('pages.settings.ldap.baseDn')}>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Button, Input, Space } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface SecretInputProps {
|
||||||
|
value: string;
|
||||||
|
configured: boolean;
|
||||||
|
clearArmed: boolean;
|
||||||
|
placeholder: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onClearArmedChange: (armed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SecretInput({
|
||||||
|
value,
|
||||||
|
configured,
|
||||||
|
clearArmed,
|
||||||
|
placeholder,
|
||||||
|
onChange,
|
||||||
|
onClearArmedChange,
|
||||||
|
}: SecretInputProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Input.Password
|
||||||
|
value={value}
|
||||||
|
placeholder={configured && !clearArmed ? placeholder : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
onChange(e.target.value);
|
||||||
|
if (clearArmed) onClearArmedChange(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{configured && (
|
||||||
|
<Button
|
||||||
|
danger={clearArmed}
|
||||||
|
onClick={() => {
|
||||||
|
onChange('');
|
||||||
|
onClearArmedChange(!clearArmed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{clearArmed ? t('pages.settings.secretClearUndo') : t('pages.settings.secretClear')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space.Compact>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { SettingListItem } from '@/components/ui';
|
|||||||
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
import { TelegramNotifications } from '@/components/ui/notifications/TelegramNotifications';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from './catTabLabel';
|
import { catTabLabel } from './catTabLabel';
|
||||||
|
import SecretInput from './SecretInput';
|
||||||
|
|
||||||
interface TelegramTabProps {
|
interface TelegramTabProps {
|
||||||
allSetting: AllSetting;
|
allSetting: AllSetting;
|
||||||
@@ -193,12 +194,15 @@ export default function TelegramTab({ allSetting, updateSetting }: TelegramTabPr
|
|||||||
<SettingListItem
|
<SettingListItem
|
||||||
paddings="small"
|
paddings="small"
|
||||||
title={t('pages.settings.telegramToken')}
|
title={t('pages.settings.telegramToken')}
|
||||||
description={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
|
description={allSetting.hasTgBotToken && !allSetting.clearTgBotToken ? t('pages.settings.telegramTokenConfigured') : t('pages.settings.telegramTokenDesc')}
|
||||||
>
|
>
|
||||||
<Input.Password
|
<SecretInput
|
||||||
value={allSetting.tgBotToken}
|
value={allSetting.tgBotToken}
|
||||||
placeholder={allSetting.hasTgBotToken ? t('pages.settings.telegramTokenPlaceholder') : ''}
|
configured={allSetting.hasTgBotToken}
|
||||||
onChange={(e) => updateSetting({ tgBotToken: e.target.value })}
|
clearArmed={allSetting.clearTgBotToken}
|
||||||
|
placeholder={t('pages.settings.telegramTokenPlaceholder')}
|
||||||
|
onChange={(v) => updateSetting({ tgBotToken: v })}
|
||||||
|
onClearArmedChange={(armed) => updateSetting({ clearTgBotToken: armed })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
|||||||
@@ -26,9 +26,16 @@ type updateUserForm struct {
|
|||||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateSettingForm carries the persisted settings plus request-scoped fields
|
||||||
|
// that must never land in the settings table: the 2FA confirmation code and
|
||||||
|
// the explicit clear flags for redacted secrets (a blank secret alone means
|
||||||
|
// "unchanged", so clearing needs its own signal — see #5724).
|
||||||
type updateSettingForm struct {
|
type updateSettingForm struct {
|
||||||
entity.AllSetting
|
entity.AllSetting
|
||||||
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
TwoFactorCode string `json:"twoFactorCode" form:"twoFactorCode"`
|
||||||
|
ClearTgBotToken bool `json:"clearTgBotToken" form:"clearTgBotToken"`
|
||||||
|
ClearLdapPassword bool `json:"clearLdapPassword" form:"clearLdapPassword"`
|
||||||
|
ClearSmtpPassword bool `json:"clearSmtpPassword" form:"clearSmtpPassword"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingController handles settings and user management operations.
|
// SettingController handles settings and user management operations.
|
||||||
@@ -105,7 +112,11 @@ func (a *SettingController) updateSetting(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
err := a.settingService.UpdateAllSetting(allSetting)
|
err := a.settingService.UpdateAllSetting(allSetting, service.SecretClears{
|
||||||
|
TgBotToken: form.ClearTgBotToken,
|
||||||
|
LdapPassword: form.ClearLdapPassword,
|
||||||
|
SmtpPassword: form.ClearSmtpPassword,
|
||||||
|
})
|
||||||
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
|
||||||
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
|
||||||
err = bumpErr
|
err = bumpErr
|
||||||
|
|||||||
@@ -1085,8 +1085,17 @@ func (s *SettingService) SetSmtpMemory(value int) error {
|
|||||||
return s.setInt("smtpMemory", value)
|
return s.setInt("smtpMemory", value)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
// SecretClears marks redacted secrets the user explicitly emptied. Without a
|
||||||
if err := s.preserveRedactedSecrets(allSetting); err != nil {
|
// flag, a blank submitted secret means "unchanged" (the field is always served
|
||||||
|
// blank to the browser) and the stored value is preserved.
|
||||||
|
type SecretClears struct {
|
||||||
|
TgBotToken bool
|
||||||
|
LdapPassword bool
|
||||||
|
SmtpPassword bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||||
|
if err := s.preserveRedactedSecrets(allSetting, clears); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := validateSettingsURLs(allSetting); err != nil {
|
if err := validateSettingsURLs(allSetting); err != nil {
|
||||||
@@ -1132,15 +1141,15 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error {
|
func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting, clears SecretClears) error {
|
||||||
if strings.TrimSpace(allSetting.TgBotToken) == "" {
|
if !clears.TgBotToken && strings.TrimSpace(allSetting.TgBotToken) == "" {
|
||||||
value, err := s.GetTgBotToken()
|
value, err := s.GetTgBotToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
allSetting.TgBotToken = value
|
allSetting.TgBotToken = value
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(allSetting.LdapPassword) == "" {
|
if !clears.LdapPassword && strings.TrimSpace(allSetting.LdapPassword) == "" {
|
||||||
value, err := s.GetLdapPassword()
|
value, err := s.GetLdapPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1154,7 +1163,7 @@ func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting)
|
|||||||
}
|
}
|
||||||
allSetting.TwoFactorToken = value
|
allSetting.TwoFactorToken = value
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(allSetting.SmtpPassword) == "" {
|
if !clears.SmtpPassword && strings.TrimSpace(allSetting.SmtpPassword) == "" {
|
||||||
value, err := s.GetSmtpPassword()
|
value, err := s.GetSmtpPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
settings := &view.AllSetting
|
settings := &view.AllSetting
|
||||||
if err := s.UpdateAllSetting(settings); err != nil {
|
if err := s.UpdateAllSetting(settings, SecretClears{}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
||||||
@@ -94,6 +94,54 @@ func TestUpdateAllSettingPreservesRedactedSecrets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdateAllSettingClearsFlaggedSecrets(t *testing.T) {
|
||||||
|
setupSettingTestDB(t)
|
||||||
|
s := &SettingService{}
|
||||||
|
if err := s.saveSetting("tgBotToken", "telegram-secret"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.saveSetting("ldapPassword", "ldap-secret"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.saveSetting("smtpPassword", "smtp-secret"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := s.GetAllSettingView()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{SmtpPassword: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := s.GetSmtpPassword(); got != "" {
|
||||||
|
t.Fatalf("smtp password = %q, want cleared", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.GetTgBotToken(); got != "telegram-secret" {
|
||||||
|
t.Fatalf("tg token = %q, unflagged secret must stay preserved", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.GetLdapPassword(); got != "ldap-secret" {
|
||||||
|
t.Fatalf("ldap password = %q, unflagged secret must stay preserved", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err = s.GetAllSettingView()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if view.HasSmtpPassword {
|
||||||
|
t.Fatal("hasSmtpPassword must report false after clearing")
|
||||||
|
}
|
||||||
|
if err := s.UpdateAllSetting(&view.AllSetting, SecretClears{TgBotToken: true, LdapPassword: true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := s.GetTgBotToken(); got != "" {
|
||||||
|
t.Fatalf("tg token = %q, want cleared", got)
|
||||||
|
}
|
||||||
|
if got, _ := s.GetLdapPassword(); got != "" {
|
||||||
|
t.Fatalf("ldap password = %q, want cleared", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
func TestSanitizePublicHTTPURLBlocksPrivateAddressUnlessAllowed(t *testing.T) {
|
||||||
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
if _, err := SanitizePublicHTTPURL("http://127.0.0.1:8080/hook", false); err == nil {
|
||||||
t.Fatal("expected localhost URL to be blocked")
|
t.Fatal("expected localhost URL to be blocked")
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
|
"remarkTemplateDesc": "عند تعيينه، يحل هذا محل نموذج الملاحظة لكل رابط اشتراك — اكتب صيغتك الخاصة باستخدام رموز المتغيرات (استخدم الزر لإدراجها). اتركه فارغاً لاستخدام النموذج أعلاه.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
|
"pathLeadingSlash": "يجب أن يبدأ المسار بالرمز /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "مسح",
|
||||||
|
"secretClearUndo": "تراجع عن المسح"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "إعدادات Xray",
|
"title": "إعدادات Xray",
|
||||||
|
|||||||
@@ -1515,7 +1515,9 @@
|
|||||||
"eventMemoryHigh": "Memory high (%)",
|
"eventMemoryHigh": "Memory high (%)",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "Path must start with /"
|
"pathLeadingSlash": "Path must start with /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Clear",
|
||||||
|
"secretClearUndo": "Undo clear"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Xray Configs",
|
"title": "Xray Configs",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
|
"remarkTemplateDesc": "Cuando se define, esto reemplaza el modelo de notas para cada enlace de suscripción — escribe tu propio formato con los tokens de variable (usa el botón para insertarlos). Déjalo vacío para usar el modelo anterior.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "La ruta debe comenzar con /"
|
"pathLeadingSlash": "La ruta debe comenzar con /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Borrar",
|
||||||
|
"secretClearUndo": "Deshacer borrado"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Xray Configuración",
|
"title": "Xray Configuración",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"eventMemoryHigh": "مصرف حافظه بالا (%)",
|
"eventMemoryHigh": "مصرف حافظه بالا (%)",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "مسیر باید با / شروع شود"
|
"pathLeadingSlash": "مسیر باید با / شروع شود"
|
||||||
}
|
},
|
||||||
|
"secretClear": "پاک کردن",
|
||||||
|
"secretClearUndo": "لغو پاک کردن"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "پیکربندی ایکسری",
|
"title": "پیکربندی ایکسری",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
|
"remarkTemplateDesc": "Jika diatur, ini menggantikan model catatan untuk setiap tautan langganan — tulis format Anda sendiri dengan token variabel (gunakan tombol untuk menyisipkannya). Biarkan kosong untuk memakai model di atas.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "Path harus diawali dengan /"
|
"pathLeadingSlash": "Path harus diawali dengan /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Hapus",
|
||||||
|
"secretClearUndo": "Batalkan hapus"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Konfigurasi Xray",
|
"title": "Konfigurasi Xray",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
|
"remarkTemplateDesc": "設定すると、すべてのサブスクリプションリンクの備考モデルを置き換えます — 変数トークンを使って独自の形式を記述してください(ボタンで挿入できます)。空欄にすると上記のモデルが使用されます。",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "パスは / で始まる必要があります"
|
"pathLeadingSlash": "パスは / で始まる必要があります"
|
||||||
}
|
},
|
||||||
|
"secretClear": "クリア",
|
||||||
|
"secretClearUndo": "クリアを取り消す"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Xray 設定",
|
"title": "Xray 設定",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
|
"remarkTemplateDesc": "Quando definido, isto substitui o modelo de observação de cada link de assinatura — escreva seu próprio formato com os tokens de variáveis (use o botão para inseri-los). Deixe vazio para usar o modelo acima.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "O caminho deve começar com /"
|
"pathLeadingSlash": "O caminho deve começar com /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Limpar",
|
||||||
|
"secretClearUndo": "Desfazer limpeza"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Configurações Xray",
|
"title": "Configurações Xray",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
|
"remarkTemplateDesc": "Если задан, заменяет модель примечания для каждой ссылки подписки — задайте собственный формат с помощью токенов переменных (используйте кнопку для их вставки). Оставьте пустым, чтобы использовать модель выше.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "Путь должен начинаться с /"
|
"pathLeadingSlash": "Путь должен начинаться с /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Очистить",
|
||||||
|
"secretClearUndo": "Отменить очистку"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"importRules": "Импорт правил",
|
"importRules": "Импорт правил",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
|
"remarkTemplateDesc": "Ayarlandığında, her abonelik bağlantısının açıklama modelinin yerini alır — değişken belirteçleriyle kendi formatınızı yazın (eklemek için düğmeyi kullanın). Yukarıdaki modeli kullanmak için boş bırakın.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "Yol / ile başlamalıdır"
|
"pathLeadingSlash": "Yol / ile başlamalıdır"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Temizle",
|
||||||
|
"secretClearUndo": "Temizlemeyi geri al"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Xray Yapılandırmaları",
|
"title": "Xray Yapılandırmaları",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
|
"remarkTemplateDesc": "Якщо задано, це замінює модель примітки для кожного посилання підписки — напишіть власний формат із токенами змінних (використовуйте кнопку для їх вставлення). Залиште порожнім, щоб використовувати модель вище.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "Шлях має починатися з /"
|
"pathLeadingSlash": "Шлях має починатися з /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Очистити",
|
||||||
|
"secretClearUndo": "Скасувати очищення"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Xray конфігурації",
|
"title": "Xray конфігурації",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
|
"remarkTemplateDesc": "Khi được đặt, mục này thay thế mô hình ghi chú cho mọi liên kết đăng ký — hãy viết định dạng riêng của bạn bằng các token biến (dùng nút để chèn chúng). Để trống để dùng mô hình ở trên.",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
|
"pathLeadingSlash": "Đường dẫn phải bắt đầu bằng /"
|
||||||
}
|
},
|
||||||
|
"secretClear": "Xóa",
|
||||||
|
"secretClearUndo": "Hoàn tác xóa"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Cài đặt Xray",
|
"title": "Cài đặt Xray",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
|
"remarkTemplateDesc": "设置后,将替换每个订阅链接的备注模型 — 使用变量标记编写您自己的格式(用按钮插入它们)。留空则使用上方的模型。",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "路径必须以 / 开头"
|
"pathLeadingSlash": "路径必须以 / 开头"
|
||||||
}
|
},
|
||||||
|
"secretClear": "清除",
|
||||||
|
"secretClearUndo": "撤销清除"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"importRules": "导入规则",
|
"importRules": "导入规则",
|
||||||
|
|||||||
@@ -1399,7 +1399,9 @@
|
|||||||
"remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
|
"remarkTemplateDesc": "設定後,這將取代每個訂閱連結的備註模型——使用變數標記撰寫您自己的格式(使用按鈕來插入)。留空則使用上方的模型。",
|
||||||
"validation": {
|
"validation": {
|
||||||
"pathLeadingSlash": "路徑必須以 / 開頭"
|
"pathLeadingSlash": "路徑必須以 / 開頭"
|
||||||
}
|
},
|
||||||
|
"secretClear": "清除",
|
||||||
|
"secretClearUndo": "復原清除"
|
||||||
},
|
},
|
||||||
"xray": {
|
"xray": {
|
||||||
"title": "Xray 配置",
|
"title": "Xray 配置",
|
||||||
|
|||||||
Reference in New Issue
Block a user