diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 0f44cfc..90f0306 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -41,6 +41,7 @@ export default function Support() { const openSupportContact = useCallback( (config: SupportConfig) => { const target = resolveSupportContact(config); + if (!target) return; if (target.kind === 'external') { openLink(target.url, { tryInstantView: false }); } else { @@ -212,45 +213,29 @@ export default function Support() { if (supportConfig && !supportConfig.tickets_enabled) { log.debug('Tickets disabled, config:', supportConfig); + // Куда и чем открывать контакт — один резолв на весь блок. null → открывать + // нечего (пустой/битый конфиг), и кнопку тогда не рендерим вовсе. + const contact = resolveSupportContact(supportConfig); + const getSupportMessage = () => { log.debug('Getting support message for type:', supportConfig.support_type); - if (supportConfig.support_type === 'profile') { - const supportUsername = supportConfig.support_username || '@support'; - log.debug('Opening profile:', supportUsername); - return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), - message: t('support.contactSupport', { username: supportUsername }), - buttonText: t('support.contactUs'), - buttonAction: () => { - log.debug('Button clicked, opening:', supportUsername); - openSupportContact(supportConfig); - }, - }; - } + const title = isAdmin ? t('support.ticketsDisabled') : t('support.title'); if (supportConfig.support_type === 'url' && supportConfig.support_url) { return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), + title, message: t('support.useExternalLink'), buttonText: t('support.openSupport'), - buttonAction: () => { - openLink(supportConfig.support_url!, { tryInstantView: false }); - }, }; } - // Fallback: contact support (should not normally happen if config is correct) + // profile и любой fallback — контакт в телеграме const supportUsername = supportConfig.support_username || '@support'; - log.debug('Fallback: Opening profile:', supportUsername); return { - title: isAdmin ? t('support.ticketsDisabled') : t('support.title'), + title, message: t('support.contactSupport', { username: supportUsername }), buttonText: t('support.contactUs'), - buttonAction: () => { - log.debug('Fallback button clicked, opening:', supportUsername); - openSupportContact(supportConfig); - }, }; }; @@ -264,9 +249,11 @@ export default function Support() {

{supportMessage.title}

{supportMessage.message}

- + {contact && ( + + )} ); @@ -345,28 +332,30 @@ export default function Support() { {/* Contact support card for "both" mode — self-animated: mounts after the config query resolves, when the parent stagger orchestration has already finished and would leave it stuck at opacity 0 */} - {supportConfig?.support_type === 'both' && supportConfig.support_username && ( - - -
-
- + {supportConfig?.support_type === 'both' && + supportConfig.support_username && + resolveSupportContact(supportConfig) && ( + + +
+
+ +
+
+
{t('support.contactUs')}
+
{supportConfig.support_username}
+
-
-
{t('support.contactUs')}
-
{supportConfig.support_username}
-
-
- - - - )} + + + + )} {/* Tickets List */} diff --git a/src/utils/supportContact.test.ts b/src/utils/supportContact.test.ts index e8e0a5e..48cafd4 100644 --- a/src/utils/supportContact.test.ts +++ b/src/utils/supportContact.test.ts @@ -7,6 +7,10 @@ import { resolveSupportContact } from './supportContact'; * SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из * `https://help.example.com` получалось `https://t.me/https://help.example.com`. * Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего. + * + * resolveSupportContact дополнительно защищается от битого конфига: чужие схемы + * (`javascript:` и т.п.) и URL-образный legacy-username не уводят в опенер, а + * возвращают null — кнопку в таком случае не рендерим. */ const config = (overrides: Partial): SupportConfig => ({ @@ -26,7 +30,7 @@ describe('resolveSupportContact', () => { ); expect(target).toEqual({ kind: 'external', url: 'https://help.example.com' }); - expect(target.url).not.toContain('t.me'); + expect(target?.url).not.toContain('t.me'); }); it('телеграм-контакт открывается через telegram-ссылку', () => { @@ -41,6 +45,24 @@ describe('resolveSupportContact', () => { expect(target).toEqual({ kind: 'telegram', url: 'https://t.me/help' }); }); + it('tg:// deep link — валидная схема', () => { + const target = resolveSupportContact( + config({ support_url: 'tg://resolve?domain=help', contact_is_telegram: true }), + ); + + expect(target).toEqual({ kind: 'telegram', url: 'tg://resolve?domain=help' }); + }); + + describe('чужие схемы в support_url отсекаются', () => { + it.each([ + 'javascript:alert(1)', + 'data:text/html,x', + 'not a url', + ])('%s -> null', (support_url) => { + expect(resolveSupportContact(config({ support_url }))).toBeNull(); + }); + }); + describe('старый бэк без support_url — прежнее поведение', () => { it.each([ ['@help', 'https://t.me/help'], @@ -51,11 +73,17 @@ describe('resolveSupportContact', () => { expect(target).toEqual({ kind: 'telegram', url: expected }); }); - it('контакт вообще не задан — дефолтный @support', () => { - expect(resolveSupportContact(config({}))).toEqual({ - kind: 'telegram', - url: 'https://t.me/support', - }); + it.each([ + 'https://help.example.com', + 'help.example.com', + 't.me/help', + ])('URL-образный legacy username (%s) не клеится в t.me — null', (support_username) => { + expect(resolveSupportContact(config({ support_username }))).toBeNull(); + }); + + it('контакт вообще не задан — кнопки нет (null, без дефолта @support)', () => { + expect(resolveSupportContact(config({}))).toBeNull(); + expect(resolveSupportContact(config({ support_username: ' ' }))).toBeNull(); }); }); @@ -64,6 +92,6 @@ describe('resolveSupportContact', () => { // поведение, а не в переход по внешней ссылке. const target = resolveSupportContact(config({ support_url: 'https://t.me/help' })); - expect(target.kind).toBe('telegram'); + expect(target?.kind).toBe('telegram'); }); }); diff --git a/src/utils/supportContact.ts b/src/utils/supportContact.ts index 07ac663..39d70f2 100644 --- a/src/utils/supportContact.ts +++ b/src/utils/supportContact.ts @@ -10,6 +10,19 @@ export type SupportContactTarget = { url: string; }; +// Открываем только то, что реально ведёт в браузер/телеграм. support_url приходит +// из админского SUPPORT_USERNAME — чужую схему (`javascript:`, `data:` и т.п.) +// нельзя пускать в опенер как есть. +const ALLOWED_SCHEMES = ['http:', 'https:', 'tg:']; + +function isAllowedScheme(url: string): boolean { + try { + return ALLOWED_SCHEMES.includes(new URL(url).protocol); + } catch { + return false; + } +} + /** * Резолвит контакт поддержки из конфига. * @@ -20,17 +33,31 @@ export type SupportContactTarget = { * * Старый бэк не отдаёт `support_url`; для него сохранено прежнее поведение, * иначе новый фронт сломался бы на неподнятом бэкенде. + * + * Возвращает `null`, когда открывать нечего (пустой/битый конфиг, чужая схема, + * URL-образный legacy username) — вызывающий в этом случае не рендерит кнопку, + * а не уводит пользователя по мусорной ссылке. */ -export function resolveSupportContact(config: SupportConfig): SupportContactTarget { - if (config.support_url) { +export function resolveSupportContact(config: SupportConfig): SupportContactTarget | null { + const serverUrl = config.support_url?.trim(); + + if (serverUrl) { + if (!isAllowedScheme(serverUrl)) return null; + // contact_is_telegram отсутствует только у старого бэка, а он не шлёт и // support_url — так что явная проверка на false, а не на truthy. const kind = config.contact_is_telegram === false ? 'external' : 'telegram'; - return { kind, url: config.support_url }; + return { kind, url: serverUrl }; } - const raw = config.support_username || '@support'; + const raw = config.support_username?.trim(); + if (!raw) return null; + const username = raw.startsWith('@') ? raw.slice(1) : raw; + // Legacy-путь: голый URL здесь дал бы `t.me/https://…`, поэтому клеим t.me + // только из настоящего юзернейма — всё URL-образное отдаём резолвить бэку. + if (!/^[A-Za-z0-9_]{3,}$/.test(username)) return null; + return { kind: 'telegram', url: `https://t.me/${username}` }; }