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}` };
}