From ebde1f6174552ab4bef7f1fef8ef7950942781e7 Mon Sep 17 00:00:00 2001 From: airp0wer Date: Tue, 21 Jul 2026 10:28:31 +0300 Subject: [PATCH] =?UTF-8?q?fix(support):=20=D1=83=D0=B6=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D1=82=D1=8C=20resolveSupportContact=20?= =?UTF-8?q?=E2=80=94=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0,=20legacy-username,?= =?UTF-8?q?=20null?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инкремент поверх f04675b (#501 был технически перекрыт им). resolveSupportContact теперь не только не клеит t.me с внешним URL, но и отсекает битый конфиг вместо того, чтобы уводить пользователя по мусорной ссылке: 1. Валидация схемы support_url — открываем только http/https/tg, чужая схема (javascript:, data: и т.п.) больше не уходит в опенер как есть, а даёт null. 2. Валидация legacy username регуляркой ^[A-Za-z0-9_]{3,}$ — старый бэк с URL-образным SUPPORT_USERNAME больше не собирает битый t.me/https://…, URL-образное отдаём резолвить бэку. 3. null → кнопка не рендерится — при ненастроенном/битом контакте (в т.ч. ушёл дефолт @support) кнопку в Support.tsx не показываем, а не открываем молча https://t.me/support. Заодно url-ветка в ticketsDisabled и карточка both-режима ходят через resolveSupportContact — ушёл прямой openLink(support_url!) с non-null assertion, три ветки getSupportMessage схлопнуты в две (profile и fallback идентичны). --- src/pages/Support.tsx | 85 ++++++++++++++------------------ src/utils/supportContact.test.ts | 42 +++++++++++++--- src/utils/supportContact.ts | 35 +++++++++++-- 3 files changed, 103 insertions(+), 59 deletions(-) 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}` }; }