mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-09 22:55:00 +03:00
fix(frontend): keep node-reported text out of i18next's nesting parser
The SPA initialised i18next with nesting on, though no locale uses
$t(...), so its nesting regex, exponential on an unclosed "$t(", ran
over every missing key and interpolated value, including the status and
name a node reports for its sub-nodes. One crafted status froze the
Nodes page.
Nesting is switched off for every i18next instance the project creates,
an unknown status renders as Unknown instead of being used as a key,
and a sub-node is never named as the parent it is reached through.
This commit is contained in:
@@ -5,6 +5,7 @@ import i18next from 'i18next';
|
|||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
import { buildAntdThemeConfig } from '@/hooks/useTheme';
|
import { buildAntdThemeConfig } from '@/hooks/useTheme';
|
||||||
|
import { INTERPOLATION } from '@/i18n/react';
|
||||||
import enUS from '../../internal/web/translation/en-US.json';
|
import enUS from '../../internal/web/translation/en-US.json';
|
||||||
|
|
||||||
if (!i18next.isInitialized) {
|
if (!i18next.isInitialized) {
|
||||||
@@ -12,7 +13,7 @@ if (!i18next.isInitialized) {
|
|||||||
lng: 'en-US',
|
lng: 'en-US',
|
||||||
fallbackLng: 'en-US',
|
fallbackLng: 'en-US',
|
||||||
resources: { 'en-US': { translation: enUS } },
|
resources: { 'en-US': { translation: enUS } },
|
||||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
interpolation: INTERPOLATION,
|
||||||
returnNull: false,
|
returnNull: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
|
import type { InterpolationOptions } from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
import { LanguageManager } from '@/utils';
|
import { LanguageManager } from '@/utils';
|
||||||
@@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json';
|
|||||||
|
|
||||||
const FALLBACK = 'en-US';
|
const FALLBACK = 'en-US';
|
||||||
|
|
||||||
|
// No locale uses $t(...) nesting, and its regex backtracks exponentially on an unclosed
|
||||||
|
// "$t(" in untrusted text (node-reported names, remarks); no off switch, so never match.
|
||||||
|
export const INTERPOLATION: InterpolationOptions = {
|
||||||
|
escapeValue: false,
|
||||||
|
prefix: '{',
|
||||||
|
suffix: '}',
|
||||||
|
nestingPrefix: '',
|
||||||
|
nestingPrefixEscaped: '(?!)',
|
||||||
|
};
|
||||||
|
|
||||||
const lazyModules = import.meta.glob([
|
const lazyModules = import.meta.glob([
|
||||||
'../../../internal/web/translation/*.json',
|
'../../../internal/web/translation/*.json',
|
||||||
'!../../../internal/web/translation/en-US.json',
|
'!../../../internal/web/translation/en-US.json',
|
||||||
@@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') {
|
|||||||
lng: active,
|
lng: active,
|
||||||
fallbackLng: FALLBACK,
|
fallbackLng: FALLBACK,
|
||||||
resources: { [FALLBACK]: { translation: enUS } },
|
resources: { [FALLBACK]: { translation: enUS } },
|
||||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
interpolation: INTERPOLATION,
|
||||||
returnNull: false,
|
returnNull: false,
|
||||||
});
|
});
|
||||||
if (active !== FALLBACK) {
|
if (active !== FALLBACK) {
|
||||||
|
|||||||
@@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
|
// A sub-node's status is free text from its parent node: show it as unknown, not as a key.
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
{t(`pages.nodes.statusValues.${status || 'unknown'}`, {
|
||||||
|
defaultValue: t('pages.nodes.statusValues.unknown'),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPct(p?: number, available = true): string {
|
function formatPct(p?: number, available = true): string {
|
||||||
@@ -188,11 +195,11 @@ export default function NodeList({
|
|||||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
// Map a node GUID to its display name so a transitive sub-node can show which
|
// Map a direct node's GUID to its display name so a transitive sub-node can show
|
||||||
// parent it is reached through (#4983).
|
// which parent it is reached through (#4983); the master walks one level only.
|
||||||
const nameByGuid = useMemo(() => {
|
const nameByGuid = useMemo(() => {
|
||||||
const m = new Map<string, string>();
|
const m = new Map<string, string>();
|
||||||
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
for (const n of nodes) if (n.guid && !n.transitive) m.set(n.guid, n.name || n.guid);
|
||||||
return m;
|
return m;
|
||||||
}, [nodes]);
|
}, [nodes]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { screen } from '@testing-library/react';
|
||||||
|
import i18next from 'i18next';
|
||||||
|
|
||||||
|
import NodeList from '@/pages/nodes/NodeList';
|
||||||
|
import type { NodeRecord } from '@/schemas/node';
|
||||||
|
|
||||||
|
import { renderWithProviders } from './test-utils';
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
// A closed reference: an unclosed "$t(" would hang a regressed run instead of failing it.
|
||||||
|
const reported = '$t(pages.nodes.subNode)';
|
||||||
|
|
||||||
|
function renderSubNode(fields: Partial<NodeRecord>) {
|
||||||
|
const nodes: NodeRecord[] = [
|
||||||
|
{ id: 1, name: 'parent', guid: 'p1', enable: true, status: 'online' },
|
||||||
|
// parentGuid is its own GUID: the sub-node tries to put its name into the parent tooltip.
|
||||||
|
{ id: 0, name: 'child', guid: 'c1', parentGuid: 'c1', transitive: true, ...fields },
|
||||||
|
];
|
||||||
|
renderWithProviders(
|
||||||
|
<NodeList
|
||||||
|
nodes={nodes}
|
||||||
|
isMobile={false}
|
||||||
|
selectedIds={[]}
|
||||||
|
onSelectionChange={noop}
|
||||||
|
onAdd={noop}
|
||||||
|
onMtls={noop}
|
||||||
|
onEdit={noop}
|
||||||
|
onDelete={noop}
|
||||||
|
onProbe={noop}
|
||||||
|
onToggleEnable={noop}
|
||||||
|
onUpdateNode={noop}
|
||||||
|
onUpdateSelected={noop}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NodeList sub-node rows', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it('shows a node-reported status it does not know as Unknown, not as an i18next key', () => {
|
||||||
|
renderSubNode({ status: reported });
|
||||||
|
|
||||||
|
const row = screen.getByText('child').closest('tr') as HTMLElement;
|
||||||
|
expect(row.textContent).toContain('Unknown');
|
||||||
|
expect(row.textContent).not.toContain('statusValues');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never names a sub-node as the parent it is reached through', () => {
|
||||||
|
const translate = vi.spyOn(i18next, 't');
|
||||||
|
renderSubNode({ name: reported, status: 'online' });
|
||||||
|
|
||||||
|
expect(translate).toHaveBeenCalledWith(
|
||||||
|
'pages.nodes.subNodeTip',
|
||||||
|
expect.objectContaining({ parent: '-' }),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(translate.mock.calls)).not.toContain(reported);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('i18next nesting', () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
// Closed references only: an unclosed "$t(" would hang a regressed run instead of failing it.
|
||||||
|
it('is off, so "$t(...)" in a missing key or an interpolated value stays plain text', async () => {
|
||||||
|
const utils = await import('@/utils');
|
||||||
|
vi.spyOn(utils.LanguageManager, 'getLanguage').mockReturnValue('en-US');
|
||||||
|
const { readyI18n } = await import('@/i18n/react');
|
||||||
|
const i18n = await readyI18n();
|
||||||
|
|
||||||
|
expect(i18n.t('$t(pages.nodes.subNode)')).toBe('$t(pages.nodes.subNode)');
|
||||||
|
expect(i18n.t('pages.nodes.subNodeTip', { parent: '$t(pages.nodes.subNode)' })).toBe(
|
||||||
|
"Read-only: a downstream node reached through $t(pages.nodes.subNode). Manage it from $t(pages.nodes.subNode)'s own panel.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { cleanup } from '@testing-library/react';
|
|||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
|
import { INTERPOLATION } from '@/i18n/react';
|
||||||
import enUS from '../../../internal/web/translation/en-US.json';
|
import enUS from '../../../internal/web/translation/en-US.json';
|
||||||
|
|
||||||
// RTL sets this from a global beforeAll, which never runs with `globals: false`.
|
// RTL sets this from a global beforeAll, which never runs with `globals: false`.
|
||||||
@@ -75,7 +76,7 @@ if (!i18next.isInitialized) {
|
|||||||
lng: 'en-US',
|
lng: 'en-US',
|
||||||
fallbackLng: 'en-US',
|
fallbackLng: 'en-US',
|
||||||
resources: { 'en-US': { translation: enUS } },
|
resources: { 'en-US': { translation: enUS } },
|
||||||
interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
|
interpolation: INTERPOLATION,
|
||||||
returnNull: false,
|
returnNull: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user