From 87ebcc7a6f68f10c8b1d3f330a84105e32519116 Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:03:21 +0800 Subject: [PATCH] feat(ui): tag settings that sit at their shipped default value (#6128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): tag settings that sit at their shipped default value A field showing 2096 reads identically whether the install never set it or the operator saved 2096 — newcomers cannot tell which knobs they have touched, and after the cleared-port fix (#6121) a port can never visually return to an unset state. Add a small grey tag next to numeric settings whose current value equals the shipped default. The tag deliberately compares values, not provenance: a stored 2096 and a fallback 2096 behave identically, so they read identically, and the tag reacts live as the user types. The backing endpoint filters defaultValueMap through the AllSetting field set, so per-install material (secret, panelGuid, node mTLS keys) and redacted credential fields never leave the server; a test pins that. Co-Authored-By: Claude Fable 5 * fix(ui): keep the default tag out of the accessible name, pin the defaults contract From review, in order of severity: The badge was rendered inside the element whose id feeds the control's aria-labelledby, so a visible tag changed every field's accessible name ('Panel Port Default'). The title text now carries the id on its own span and the badge sits beside it. The same default values live in three places: the Go defaultValueMap, the frontend AllSetting class, and the tag's verdict. A new contract test parses the Go map's string literals and asserts every shared key matches the AllSetting class default through the tag's own comparison — and on first run it caught two real drifts (tgEnabledEvents / smtpEnabledEvents defaulted to '' in the class but 'login.attempt,cpu.high' on the server), now aligned. matchesFactoryDefault no longer coerces blank or unparsable defaults (Number('') is 0; a junk string is not false). The Go tests are table-driven t.Run subtests and gained the structural invariant: every returned key is an AllSetting json tag outside the credential deny-list. The service doc comment now describes the projection mechanism instead of overclaiming; the i18n key is re-indented and placed at the head of pages.settings in all 13 locales; the fetch falls back to {} when validation fails; and smtpPort gets the tag so plain numeric settings-list fields are covered uniformly. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- frontend/public/openapi.json | 30 +++++++ .../src/api/queries/useFactoryDefaults.ts | 22 +++++ frontend/src/api/queryKeys.ts | 1 + .../src/components/ui/DefaultSettingTag.tsx | 37 +++++++++ .../src/components/ui/SettingListItem.tsx | 9 +- frontend/src/components/ui/index.ts | 1 + frontend/src/models/setting.ts | 4 +- frontend/src/pages/api-docs/endpoints.ts | 5 ++ frontend/src/pages/settings/EmailTab.tsx | 4 +- frontend/src/pages/settings/GeneralTab.tsx | 20 ++--- .../pages/settings/SubscriptionGeneralTab.tsx | 6 +- frontend/src/schemas/setting.ts | 4 + .../src/test/default-setting-tag.test.tsx | 64 ++++++++++++++ .../test/factory-defaults-contract.test.ts | 53 ++++++++++++ frontend/src/test/test-utils.tsx | 14 +++- internal/web/controller/setting.go | 5 ++ internal/web/service/setting.go | 30 +++++++ .../service/setting_factory_defaults_test.go | 83 +++++++++++++++++++ internal/web/translation/ar-EG.json | 1 + internal/web/translation/en-US.json | 1 + internal/web/translation/es-ES.json | 1 + internal/web/translation/fa-IR.json | 1 + internal/web/translation/id-ID.json | 1 + internal/web/translation/ja-JP.json | 1 + internal/web/translation/pt-BR.json | 1 + internal/web/translation/ru-RU.json | 1 + internal/web/translation/tr-TR.json | 1 + internal/web/translation/uk-UA.json | 1 + internal/web/translation/vi-VN.json | 1 + internal/web/translation/zh-CN.json | 1 + internal/web/translation/zh-TW.json | 1 + 31 files changed, 385 insertions(+), 20 deletions(-) create mode 100644 frontend/src/api/queries/useFactoryDefaults.ts create mode 100644 frontend/src/components/ui/DefaultSettingTag.tsx create mode 100644 frontend/src/test/default-setting-tag.test.tsx create mode 100644 frontend/src/test/factory-defaults-contract.test.ts create mode 100644 internal/web/service/setting_factory_defaults_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index e142a0af8..fd4b0bfe0 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -9769,6 +9769,36 @@ } } }, + "/panel/api/setting/factoryDefaults": { + "post": { + "tags": [ + "Settings" + ], + "summary": "Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.", + "operationId": "post_panel_api_setting_factoryDefaults", + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + } + } + } + } + } + } + }, "/panel/api/setting/update": { "post": { "tags": [ diff --git a/frontend/src/api/queries/useFactoryDefaults.ts b/frontend/src/api/queries/useFactoryDefaults.ts new file mode 100644 index 000000000..3091a74ac --- /dev/null +++ b/frontend/src/api/queries/useFactoryDefaults.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; + +import { HttpUtil } from '@/utils'; +import { parseMsg } from '@/utils/zodValidate'; +import { FactoryDefaultsSchema, type FactoryDefaults } from '@/schemas/setting'; +import { keys } from '@/api/queryKeys'; + +async function fetchFactoryDefaults(): Promise { + const msg = await HttpUtil.post('/panel/api/setting/factoryDefaults', undefined, { silent: true }); + if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch factory defaults'); + const validated = parseMsg(msg, FactoryDefaultsSchema, 'setting/factoryDefaults'); + const parsed = FactoryDefaultsSchema.safeParse(validated.obj); + return parsed.success ? parsed.data : {}; +} + +export function useFactoryDefaults() { + return useQuery({ + queryKey: keys.settings.factoryDefaults(), + queryFn: fetchFactoryDefaults, + staleTime: Infinity, + }); +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 4166c28c1..abcb9b1bd 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -17,6 +17,7 @@ export const keys = { root: () => ['settings'] as const, all: () => ['settings', 'all'] as const, defaults: () => ['settings', 'defaults'] as const, + factoryDefaults: () => ['settings', 'factoryDefaults'] as const, }, inbounds: { root: () => ['inbounds'] as const, diff --git a/frontend/src/components/ui/DefaultSettingTag.tsx b/frontend/src/components/ui/DefaultSettingTag.tsx new file mode 100644 index 000000000..d57e6376c --- /dev/null +++ b/frontend/src/components/ui/DefaultSettingTag.tsx @@ -0,0 +1,37 @@ +import { Tag } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import { useFactoryDefaults } from '@/api/queries/useFactoryDefaults'; + +/** + * Value semantics on purpose: the tag answers "does this equal the shipped + * default?", not "has the user ever saved this key?" — a stored 2096 and a + * fallback 2096 behave identically, so they read identically. + */ +export function matchesFactoryDefault(current: unknown, factoryDefault: string | undefined): boolean { + if (factoryDefault === undefined) return false; + if (typeof current === 'number') { + const parsed = Number(factoryDefault); + return factoryDefault.trim() !== '' && !Number.isNaN(parsed) && parsed === current; + } + if (typeof current === 'boolean') { + if (factoryDefault !== 'true' && factoryDefault !== 'false') return false; + return (factoryDefault === 'true') === current; + } + if (typeof current === 'string') return factoryDefault === current; + return false; +} + +interface DefaultSettingTagProps { + settingKey: string; + value: unknown; +} + +export default function DefaultSettingTag({ settingKey, value }: DefaultSettingTagProps) { + const { t } = useTranslation(); + const defaults = useFactoryDefaults(); + + if (!matchesFactoryDefault(value, defaults.data?.[settingKey])) return null; + + return {t('pages.settings.defaultTag')}; +} diff --git a/frontend/src/components/ui/SettingListItem.tsx b/frontend/src/components/ui/SettingListItem.tsx index 770dfbba7..3635a9b5b 100644 --- a/frontend/src/components/ui/SettingListItem.tsx +++ b/frontend/src/components/ui/SettingListItem.tsx @@ -5,6 +5,7 @@ import './SettingListItem.css'; interface SettingListItemProps { paddings?: 'small' | 'default'; title?: ReactNode; + badge?: ReactNode; description?: ReactNode; children?: ReactNode; control?: ReactNode; @@ -13,6 +14,7 @@ interface SettingListItemProps { export default function SettingListItem({ paddings = 'default', title, + badge, description, children, control, @@ -28,7 +30,12 @@ export default function SettingListItem({
- {title &&
{title}
} + {title && ( +
+ {title} + {badge} +
+ )} {description &&
{description}
}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 1e8121b57..c67a5df41 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -1,3 +1,4 @@ export { default as InputAddon } from './InputAddon'; export { default as InfinityIcon } from './InfinityIcon'; export { default as SettingListItem } from './SettingListItem'; +export { default as DefaultSettingTag } from './DefaultSettingTag'; diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index c5f3d5f16..fb607aefe 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -91,7 +91,7 @@ export class AllSetting { ldapDefaultTotalGB = 0; ldapDefaultExpiryDays = 0; ldapDefaultLimitIP = 0; - tgEnabledEvents = ''; + tgEnabledEvents = 'login.attempt,cpu.high'; smtpEnable = false; smtpHost = ''; smtpPort = 587; @@ -101,7 +101,7 @@ export class AllSetting { smtpFromName = ''; smtpTo = ''; smtpEncryptionType = 'starttls'; - smtpEnabledEvents = ''; + smtpEnabledEvents = 'login.attempt,cpu.high'; smtpCpu = 80; smtpMemory = 80; outboundDownThreshold = 3; diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index f7e4c2213..b4262b1b7 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -1169,6 +1169,11 @@ export const sections: readonly Section[] = [ path: '/panel/api/setting/defaultSettings', summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.', }, + { + method: 'POST', + path: '/panel/api/setting/factoryDefaults', + summary: 'Return the shipped (factory) default value per browser-safe setting key, so clients can tell a stored value apart from the default it would fall back to. Per-install material (secret, panelGuid, mTLS keys) and credential fields are never included.', + }, { method: 'POST', path: '/panel/api/setting/update', diff --git a/frontend/src/pages/settings/EmailTab.tsx b/frontend/src/pages/settings/EmailTab.tsx index e5e6a2780..fcb75fcf9 100644 --- a/frontend/src/pages/settings/EmailTab.tsx +++ b/frontend/src/pages/settings/EmailTab.tsx @@ -5,7 +5,7 @@ import { MailOutlined, SendOutlined, SettingOutlined } from '@ant-design/icons'; import { HttpUtil } from '@/utils'; import { onNumber } from '@/utils/onNumber'; import type { AllSetting } from '@/models/setting'; -import { SettingListItem } from '@/components/ui'; +import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { EmailNotifications } from '@/components/ui/notifications/EmailNotifications'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; @@ -63,7 +63,7 @@ export default function EmailTab({ allSetting, updateSetting }: EmailTabProps) { onChange={(e) => updateSetting({ smtpHost: e.target.value })} /> - + } description={t('pages.settings.smtpPortDesc')}> updateSetting({ smtpPort: v }))} /> diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index f98129a55..fa52755a4 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -18,7 +18,7 @@ import { import type { AllSetting } from '@/models/setting'; import { HttpUtil, LanguageManager } from '@/utils'; import { onNumber } from '@/utils/onNumber'; -import { SettingListItem } from '@/components/ui'; +import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; import { sanitizePath } from './uriPath'; @@ -169,7 +169,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ webDomain: e.target.value })} /> - + } description={t('pages.settings.panelPortDesc')}> updateSetting({ webPort: v }))} /> @@ -178,7 +178,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ webBasePath: sanitizePath(e.target.value) })} /> - + } description={t('pages.settings.sessionMaxAgeDesc')}> updateSetting({ sessionMaxAge: v }))} /> @@ -207,7 +207,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp /> - + } description={t('pages.settings.pageSizeDesc')}> updateSetting({ pageSize: v }))} /> @@ -233,11 +233,11 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp label: catTabLabel(, t('pages.settings.notifications'), isMobile), children: ( <> - + } description={t('pages.settings.expireTimeDiffDesc')}> updateSetting({ expireDiff: v }))} /> - + } description={t('pages.settings.trafficDiffDesc')}> updateSetting({ trafficDiff: v }))} /> @@ -307,7 +307,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ ldapHost: e.target.value })} /> - + }> updateSetting({ ldapPort: v }))} /> @@ -386,15 +386,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp updateSetting({ ldapAutoDelete: v })} /> - + }> updateSetting({ ldapDefaultTotalGB: v }))} /> - + }> updateSetting({ ldapDefaultExpiryDays: v }))} /> - + }> updateSetting({ ldapDefaultLimitIP: v }))} /> diff --git a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx index 4cb23023f..4799aa3b6 100644 --- a/frontend/src/pages/settings/SubscriptionGeneralTab.tsx +++ b/frontend/src/pages/settings/SubscriptionGeneralTab.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import type { AllSetting } from '@/models/setting'; import { onNumber } from '@/utils/onNumber'; -import { SettingListItem } from '@/components/ui'; +import { DefaultSettingTag, SettingListItem } from '@/components/ui'; import { RemarkTemplateField } from '@/components/form'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { catTabLabel } from './catTabLabel'; @@ -56,7 +56,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su updateSetting({ subDomain: e.target.value })} /> - + } description={t('pages.settings.subPortDesc')}> updateSetting({ subPort: v }))} /> @@ -105,7 +105,7 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su /> - + } description={t('pages.settings.subUpdatesDesc')}> updateSetting({ subUpdates: v }))} /> diff --git a/frontend/src/schemas/setting.ts b/frontend/src/schemas/setting.ts index 4d3432ef4..56131721e 100644 --- a/frontend/src/schemas/setting.ts +++ b/frontend/src/schemas/setting.ts @@ -103,3 +103,7 @@ export const AllSettingSchema = z.object({ }).loose(); export type AllSettingInput = z.infer; + +export const FactoryDefaultsSchema = z.record(z.string(), z.string()); + +export type FactoryDefaults = z.infer; diff --git a/frontend/src/test/default-setting-tag.test.tsx b/frontend/src/test/default-setting-tag.test.tsx new file mode 100644 index 000000000..abe9229bc --- /dev/null +++ b/frontend/src/test/default-setting-tag.test.tsx @@ -0,0 +1,64 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { keys } from '@/api/queryKeys'; +import DefaultSettingTag, { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag'; +import { makeTestQueryClient, renderWithProviders } from './test-utils'; + +function clientWithDefaults(defaults: Record) { + const queryClient = makeTestQueryClient(); + queryClient.setQueryData(keys.settings.factoryDefaults(), defaults); + return queryClient; +} + +describe('matchesFactoryDefault', () => { + it('compares by value with type-aware coercion', () => { + expect(matchesFactoryDefault(2096, '2096')).toBe(true); + expect(matchesFactoryDefault(8443, '2096')).toBe(false); + expect(matchesFactoryDefault(true, 'true')).toBe(true); + expect(matchesFactoryDefault(false, 'true')).toBe(false); + expect(matchesFactoryDefault('/sub/', '/sub/')).toBe(true); + expect(matchesFactoryDefault('/other/', '/sub/')).toBe(false); + }); + + it('never matches when the key has no shipped default', () => { + expect(matchesFactoryDefault(2096, undefined)).toBe(false); + }); + + it('rejects blank or unparsable defaults instead of coercing them', () => { + expect(matchesFactoryDefault(0, '')).toBe(false); + expect(matchesFactoryDefault(0, ' ')).toBe(false); + expect(matchesFactoryDefault(0, 'none')).toBe(false); + expect(matchesFactoryDefault(false, '')).toBe(false); + expect(matchesFactoryDefault(false, 'no')).toBe(false); + }); +}); + +describe('DefaultSettingTag', () => { + it('shows the tag when the current value equals the shipped default, however it got there', () => { + renderWithProviders( + , + { queryClient: clientWithDefaults({ subPort: '2096' }) }, + ); + + expect(screen.getByText('Default')).toBeDefined(); + }); + + it('renders nothing when the value differs from the default', () => { + renderWithProviders( + , + { queryClient: clientWithDefaults({ subPort: '2096' }) }, + ); + + expect(screen.queryByText('Default')).toBeNull(); + }); + + it('renders nothing while defaults are unknown', () => { + renderWithProviders( + , + { queryClient: makeTestQueryClient() }, + ); + + expect(screen.queryByText('Default')).toBeNull(); + }); +}); diff --git a/frontend/src/test/factory-defaults-contract.test.ts b/frontend/src/test/factory-defaults-contract.test.ts new file mode 100644 index 000000000..a001217d9 --- /dev/null +++ b/frontend/src/test/factory-defaults-contract.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { matchesFactoryDefault } from '@/components/ui/DefaultSettingTag'; +import { AllSetting } from '@/models/setting'; + +/* + * Contract test between the three homes of a setting's default value: the Go + * defaultValueMap (authoritative, served by /setting/factoryDefaults), the + * frontend AllSetting class defaults (what the form shows when the API omits + * a value), and the tag's own comparison. If someone bumps a default on one + * side only, the Default tag would start calling a different value "Default" + * than the one the form displays — this test fails instead. + */ + +function goDefaultLiterals(): Record { + const source = readFileSync( + resolve(process.cwd(), '..', 'internal', 'web', 'service', 'setting.go'), + 'utf8', + ); + const start = source.indexOf('var defaultValueMap = map[string]string{'); + const end = source.indexOf('\n}', start); + const block = source.slice(start, end); + const literals: Record = {}; + for (const match of block.matchAll(/"([A-Za-z0-9]+)":\s+"((?:[^"\\]|\\.)*)"\s*,/g)) { + literals[match[1]] = JSON.parse(`"${match[2]}"`); + } + return literals; +} + +describe('factory defaults contract', () => { + const goDefaults = goDefaultLiterals(); + const frontend = new AllSetting() as unknown as Record; + const sharedKeys = Object.keys(goDefaults).filter((key) => { + const value = frontend[key]; + return typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string'; + }); + + it('parses a plausible slice of the Go map', () => { + expect(goDefaults.webPort).toBe('2053'); + expect(goDefaults.subPort).toBe('2096'); + expect(sharedKeys.length).toBeGreaterThan(20); + }); + + it.each(sharedKeys)('frontend default for %s matches the shipped default', (key) => { + expect( + matchesFactoryDefault(frontend[key], goDefaults[key]), + `AllSetting.${key} = ${JSON.stringify(frontend[key])} vs defaultValueMap ${JSON.stringify(goDefaults[key])}`, + ).toBe(true); + }); +}); diff --git a/frontend/src/test/test-utils.tsx b/frontend/src/test/test-utils.tsx index d0640eaa2..be81b5305 100644 --- a/frontend/src/test/test-utils.tsx +++ b/frontend/src/test/test-utils.tsx @@ -1,10 +1,20 @@ import type { ReactElement } from 'react'; import { render, fireEvent } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider } from '@/hooks/useTheme'; -export function renderWithProviders(ui: ReactElement) { - return render({ui}); +export function makeTestQueryClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }); +} + +export function renderWithProviders(ui: ReactElement, options?: { queryClient?: QueryClient }) { + const queryClient = options?.queryClient ?? makeTestQueryClient(); + return render( + + {ui} + , + ); } export function fieldLabels(): string[] { diff --git a/internal/web/controller/setting.go b/internal/web/controller/setting.go index 82545a863..0516bceca 100644 --- a/internal/web/controller/setting.go +++ b/internal/web/controller/setting.go @@ -65,6 +65,7 @@ func (a *SettingController) initRouter(g *gin.RouterGroup) { g.POST("/all", a.getAllSetting) g.POST("/defaultSettings", a.getDefaultSettings) + g.POST("/factoryDefaults", a.getFactoryDefaults) g.POST("/update", a.updateSetting) g.POST("/validateRegex", a.validateRegex) g.POST("/updateUser", a.updateUser) @@ -112,6 +113,10 @@ func (a *SettingController) getDefaultSettings(c *gin.Context) { jsonObj(c, result, nil) } +func (a *SettingController) getFactoryDefaults(c *gin.Context) { + jsonObj(c, a.settingService.GetFactoryDefaults(), nil) +} + // updateSetting updates all settings with the provided data. func (a *SettingController) updateSetting(c *gin.Context) { form, ok := middleware.BindAndValidate[updateSettingForm](c) diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 39a731099..1ba6bdffb 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -1445,3 +1445,33 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) { return result, nil } + +var factoryDefaultSecretKeys = map[string]bool{ + "tgBotToken": true, + "twoFactorToken": true, + "ldapPassword": true, + "smtpPassword": true, +} + +/* +GetFactoryDefaults returns the shipped default value per setting, keyed by +the AllSetting json field name. Unlike GetDefaultSettings (which reports +current effective values), this is defaultValueMap projected through the +AllSetting field set: only keys that exist as an AllSetting json tag are +returned, minus the credential fields in factoryDefaultSecretKeys. Keys +with no AllSetting field (secret, panelGuid, the node mTLS material, +xrayTemplateConfig) are excluded structurally rather than by deny-list. +*/ +func (s *SettingService) GetFactoryDefaults() map[string]string { + result := make(map[string]string) + for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) { + key := field.Tag.Get("json") + if key == "" || factoryDefaultSecretKeys[key] { + continue + } + if value, ok := defaultValueMap[key]; ok { + result[key] = value + } + } + return result +} diff --git a/internal/web/service/setting_factory_defaults_test.go b/internal/web/service/setting_factory_defaults_test.go new file mode 100644 index 000000000..73c9f4bad --- /dev/null +++ b/internal/web/service/setting_factory_defaults_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "reflect" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util" + "github.com/mhsanaei/3x-ui/v3/internal/web/entity" +) + +func allSettingJSONTags(t *testing.T) map[string]bool { + t.Helper() + tags := make(map[string]bool) + for _, field := range reflect_util.GetFields(reflect.TypeFor[entity.AllSetting]()) { + if tag := field.Tag.Get("json"); tag != "" { + tags[tag] = true + } + } + return tags +} + +func TestGetFactoryDefaultsExposesBrowserSafeKeys(t *testing.T) { + defaults := (&SettingService{}).GetFactoryDefaults() + + tests := []struct { + key string + want string + }{ + {key: "webPort", want: "2053"}, + {key: "subPort", want: "2096"}, + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + got, ok := defaults[tc.key] + if !ok { + t.Fatalf("expected key %q in factory defaults", tc.key) + } + if got != tc.want { + t.Errorf("factory default for %q = %q, want %q", tc.key, got, tc.want) + } + }) + } +} + +func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) { + defaults := (&SettingService{}).GetFactoryDefaults() + + for _, key := range []string{ + "secret", + "panelGuid", + "nodeMtlsCaCertPem", + "nodeMtlsCaKeyPem", + "nodeMtlsClientCertPem", + "nodeMtlsClientKeyPem", + "xrayTemplateConfig", + "tgBotToken", + "twoFactorToken", + "ldapPassword", + "smtpPassword", + } { + t.Run(key, func(t *testing.T) { + if _, ok := defaults[key]; ok { + t.Errorf("factory defaults must not expose %q", key) + } + }) + } +} + +func TestGetFactoryDefaultsInvariant(t *testing.T) { + defaults := (&SettingService{}).GetFactoryDefaults() + tags := allSettingJSONTags(t) + + for key := range defaults { + t.Run(key, func(t *testing.T) { + if !tags[key] { + t.Errorf("key %q is not an entity.AllSetting json tag", key) + } + if factoryDefaultSecretKeys[key] { + t.Errorf("key %q is in the credential deny-list and must not be returned", key) + } + }) + } +} diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 3f4b97e69..1babe9b3c 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -998,6 +998,7 @@ "pinFetchFailed": "تعذّر جلب الشهادة" }, "settings": { + "defaultTag": "افتراضي", "title": "إعدادات البانل", "save": "حفظ", "infoDesc": "كل تغيير هتعمله هنا لازم يتخزن. ياريت تعيد تشغيل البانل عشان التعديلات تتفعل.", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index 539a863d7..1e733cced 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -1115,6 +1115,7 @@ "pinFetchFailed": "Could not fetch the certificate" }, "settings": { + "defaultTag": "Default", "title": "Panel Settings", "save": "Save", "infoDesc": "Every change made here needs to be saved. Please restart the panel to apply changes.", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 3451a2ac3..a37afdef9 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -998,6 +998,7 @@ "pinFetchFailed": "No se pudo obtener el certificado" }, "settings": { + "defaultTag": "Predeterminado", "title": "Configuraciones", "save": "Guardar", "infoDesc": "Cada cambio realizado aquí debe ser guardado. Por favor, reinicie el panel para aplicar los cambios.", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 8fea8746b..a72f506cd 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -998,6 +998,7 @@ "pinFetchFailed": "دریافت گواهی ممکن نشد" }, "settings": { + "defaultTag": "پیش‌فرض", "title": "تنظیمات پنل", "save": "ذخیره", "infoDesc": "برای اعمال تغییرات در این بخش باید پس از ذخیره کردن، پنل را ریستارت کنید", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index d6cfa5ba4..11b3ada60 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Tidak dapat mengambil sertifikat" }, "settings": { + "defaultTag": "Bawaan", "title": "Pengaturan Panel", "save": "Simpan", "infoDesc": "Setiap perubahan yang dibuat di sini perlu disimpan. Harap restart panel untuk menerapkan perubahan.", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index b941df361..1d0548e71 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -998,6 +998,7 @@ "pinFetchFailed": "証明書を取得できませんでした" }, "settings": { + "defaultTag": "デフォルト", "title": "パネル設定", "save": "保存", "infoDesc": "ここでのすべての変更は、保存してパネルを再起動する必要があります", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index e5bdfe499..e51a7b126 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Não foi possível obter o certificado" }, "settings": { + "defaultTag": "Padrão", "title": "Configurações do Painel", "save": "Salvar", "infoDesc": "Toda alteração feita aqui precisa ser salva. Reinicie o painel para aplicar as alterações.", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index bead99939..b9bf73195 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Не удалось получить сертификат" }, "settings": { + "defaultTag": "По умолчанию", "title": "Настройки", "save": "Сохранить", "infoDesc": "Сохраните изменения и перезапустите панель для их применения.", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index bc3b16fef..d54ba80dd 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Sertifika alınamadı" }, "settings": { + "defaultTag": "Varsayılan", "title": "Panel Ayarları", "save": "Kaydet", "infoDesc": "Burada yapılan her değişikliğin kaydedilmesi gerekir. Değişikliklerin uygulanması için paneli yeniden başlatın.", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index e8eeb9aec..2ff8e3f05 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Не вдалося отримати сертифікат" }, "settings": { + "defaultTag": "Типово", "title": "Параметри панелі", "save": "Зберегти", "infoDesc": "Кожна внесена тут зміна повинна бути збережена. Перезапустіть панель, щоб застосувати зміни.", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 3f8352d68..fd87a1ada 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -998,6 +998,7 @@ "pinFetchFailed": "Không thể lấy chứng chỉ" }, "settings": { + "defaultTag": "Mặc định", "title": "Cài đặt", "save": "Lưu", "infoDesc": "Mọi thay đổi được thực hiện ở đây cần phải được lưu. Vui lòng khởi động lại bảng điều khiển để áp dụng các thay đổi.", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 6116b3579..904c330da 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -998,6 +998,7 @@ "pinFetchFailed": "无法获取证书" }, "settings": { + "defaultTag": "默认", "title": "面板设置", "save": "保存", "infoDesc": "此处的所有更改都需要保存并重启面板才能生效", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 15452f638..52f225143 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -998,6 +998,7 @@ "pinFetchFailed": "無法取得憑證" }, "settings": { + "defaultTag": "預設", "title": "面板設定", "save": "儲存", "infoDesc": "此處的所有更改都需要儲存並重啟面板才能生效",