mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
fix(support): ужесточить resolveSupportContact — схема, legacy-username, null
Инкремент поверх 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 идентичны).
This commit is contained in:
@@ -41,6 +41,7 @@ export default function Support() {
|
|||||||
const openSupportContact = useCallback(
|
const openSupportContact = useCallback(
|
||||||
(config: SupportConfig) => {
|
(config: SupportConfig) => {
|
||||||
const target = resolveSupportContact(config);
|
const target = resolveSupportContact(config);
|
||||||
|
if (!target) return;
|
||||||
if (target.kind === 'external') {
|
if (target.kind === 'external') {
|
||||||
openLink(target.url, { tryInstantView: false });
|
openLink(target.url, { tryInstantView: false });
|
||||||
} else {
|
} else {
|
||||||
@@ -212,45 +213,29 @@ export default function Support() {
|
|||||||
if (supportConfig && !supportConfig.tickets_enabled) {
|
if (supportConfig && !supportConfig.tickets_enabled) {
|
||||||
log.debug('Tickets disabled, config:', supportConfig);
|
log.debug('Tickets disabled, config:', supportConfig);
|
||||||
|
|
||||||
|
// Куда и чем открывать контакт — один резолв на весь блок. null → открывать
|
||||||
|
// нечего (пустой/битый конфиг), и кнопку тогда не рендерим вовсе.
|
||||||
|
const contact = resolveSupportContact(supportConfig);
|
||||||
|
|
||||||
const getSupportMessage = () => {
|
const getSupportMessage = () => {
|
||||||
log.debug('Getting support message for type:', supportConfig.support_type);
|
log.debug('Getting support message for type:', supportConfig.support_type);
|
||||||
|
|
||||||
if (supportConfig.support_type === 'profile') {
|
const title = isAdmin ? t('support.ticketsDisabled') : t('support.title');
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
|
if (supportConfig.support_type === 'url' && supportConfig.support_url) {
|
||||||
return {
|
return {
|
||||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
title,
|
||||||
message: t('support.useExternalLink'),
|
message: t('support.useExternalLink'),
|
||||||
buttonText: t('support.openSupport'),
|
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';
|
const supportUsername = supportConfig.support_username || '@support';
|
||||||
log.debug('Fallback: Opening profile:', supportUsername);
|
|
||||||
return {
|
return {
|
||||||
title: isAdmin ? t('support.ticketsDisabled') : t('support.title'),
|
title,
|
||||||
message: t('support.contactSupport', { username: supportUsername }),
|
message: t('support.contactSupport', { username: supportUsername }),
|
||||||
buttonText: t('support.contactUs'),
|
buttonText: t('support.contactUs'),
|
||||||
buttonAction: () => {
|
|
||||||
log.debug('Fallback button clicked, opening:', supportUsername);
|
|
||||||
openSupportContact(supportConfig);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -264,9 +249,11 @@ export default function Support() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2>
|
<h2 className="mb-2 text-xl font-semibold text-dark-100">{supportMessage.title}</h2>
|
||||||
<p className="mb-6 text-dark-400">{supportMessage.message}</p>
|
<p className="mb-6 text-dark-400">{supportMessage.message}</p>
|
||||||
<Button onClick={supportMessage.buttonAction} fullWidth>
|
{contact && (
|
||||||
{supportMessage.buttonText}
|
<Button onClick={() => openSupportContact(supportConfig)} fullWidth>
|
||||||
</Button>
|
{supportMessage.buttonText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -345,28 +332,30 @@ export default function Support() {
|
|||||||
{/* Contact support card for "both" mode — self-animated: mounts after the
|
{/* Contact support card for "both" mode — self-animated: mounts after the
|
||||||
config query resolves, when the parent stagger orchestration has already
|
config query resolves, when the parent stagger orchestration has already
|
||||||
finished and would leave it stuck at opacity 0 */}
|
finished and would leave it stuck at opacity 0 */}
|
||||||
{supportConfig?.support_type === 'both' && supportConfig.support_username && (
|
{supportConfig?.support_type === 'both' &&
|
||||||
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
supportConfig.support_username &&
|
||||||
<Card className="flex items-center justify-between">
|
resolveSupportContact(supportConfig) && (
|
||||||
<div className="flex items-center gap-3">
|
<motion.div variants={staggerItem} initial="initial" animate="animate">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-800">
|
<Card className="flex items-center justify-between">
|
||||||
<ChatIcon className="h-5 w-5 text-dark-400" />
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-800">
|
||||||
|
<ChatIcon className="h-5 w-5 text-dark-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-dark-100">{t('support.contactUs')}</div>
|
||||||
|
<div className="text-xs text-dark-400">{supportConfig.support_username}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<Button
|
||||||
<div className="text-sm font-medium text-dark-100">{t('support.contactUs')}</div>
|
variant="secondary"
|
||||||
<div className="text-xs text-dark-400">{supportConfig.support_username}</div>
|
className="shrink-0 whitespace-nowrap"
|
||||||
</div>
|
onClick={() => openSupportContact(supportConfig)}
|
||||||
</div>
|
>
|
||||||
<Button
|
{t('support.writeButton', 'Написать')}
|
||||||
variant="secondary"
|
</Button>
|
||||||
className="shrink-0 whitespace-nowrap"
|
</Card>
|
||||||
onClick={() => openSupportContact(supportConfig)}
|
</motion.div>
|
||||||
>
|
)}
|
||||||
{t('support.writeButton', 'Написать')}
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<motion.div variants={staggerItem} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
<motion.div variants={staggerItem} className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
{/* Tickets List */}
|
{/* Tickets List */}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { resolveSupportContact } from './supportContact';
|
|||||||
* SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из
|
* SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из
|
||||||
* `https://help.example.com` получалось `https://t.me/https://help.example.com`.
|
* `https://help.example.com` получалось `https://t.me/https://help.example.com`.
|
||||||
* Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего.
|
* Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего.
|
||||||
|
*
|
||||||
|
* resolveSupportContact дополнительно защищается от битого конфига: чужие схемы
|
||||||
|
* (`javascript:` и т.п.) и URL-образный legacy-username не уводят в опенер, а
|
||||||
|
* возвращают null — кнопку в таком случае не рендерим.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const config = (overrides: Partial<SupportConfig>): SupportConfig => ({
|
const config = (overrides: Partial<SupportConfig>): SupportConfig => ({
|
||||||
@@ -26,7 +30,7 @@ describe('resolveSupportContact', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(target).toEqual({ kind: 'external', url: 'https://help.example.com' });
|
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-ссылку', () => {
|
it('телеграм-контакт открывается через telegram-ссылку', () => {
|
||||||
@@ -41,6 +45,24 @@ describe('resolveSupportContact', () => {
|
|||||||
expect(target).toEqual({ kind: 'telegram', url: 'https://t.me/help' });
|
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 — прежнее поведение', () => {
|
describe('старый бэк без support_url — прежнее поведение', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['@help', 'https://t.me/help'],
|
['@help', 'https://t.me/help'],
|
||||||
@@ -51,11 +73,17 @@ describe('resolveSupportContact', () => {
|
|||||||
expect(target).toEqual({ kind: 'telegram', url: expected });
|
expect(target).toEqual({ kind: 'telegram', url: expected });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('контакт вообще не задан — дефолтный @support', () => {
|
it.each([
|
||||||
expect(resolveSupportContact(config({}))).toEqual({
|
'https://help.example.com',
|
||||||
kind: 'telegram',
|
'help.example.com',
|
||||||
url: 'https://t.me/support',
|
'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' }));
|
const target = resolveSupportContact(config({ support_url: 'https://t.me/help' }));
|
||||||
|
|
||||||
expect(target.kind).toBe('telegram');
|
expect(target?.kind).toBe('telegram');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,19 @@ export type SupportContactTarget = {
|
|||||||
url: string;
|
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`; для него сохранено прежнее поведение,
|
* Старый бэк не отдаёт `support_url`; для него сохранено прежнее поведение,
|
||||||
* иначе новый фронт сломался бы на неподнятом бэкенде.
|
* иначе новый фронт сломался бы на неподнятом бэкенде.
|
||||||
|
*
|
||||||
|
* Возвращает `null`, когда открывать нечего (пустой/битый конфиг, чужая схема,
|
||||||
|
* URL-образный legacy username) — вызывающий в этом случае не рендерит кнопку,
|
||||||
|
* а не уводит пользователя по мусорной ссылке.
|
||||||
*/
|
*/
|
||||||
export function resolveSupportContact(config: SupportConfig): SupportContactTarget {
|
export function resolveSupportContact(config: SupportConfig): SupportContactTarget | null {
|
||||||
if (config.support_url) {
|
const serverUrl = config.support_url?.trim();
|
||||||
|
|
||||||
|
if (serverUrl) {
|
||||||
|
if (!isAllowedScheme(serverUrl)) return null;
|
||||||
|
|
||||||
// contact_is_telegram отсутствует только у старого бэка, а он не шлёт и
|
// contact_is_telegram отсутствует только у старого бэка, а он не шлёт и
|
||||||
// support_url — так что явная проверка на false, а не на truthy.
|
// support_url — так что явная проверка на false, а не на truthy.
|
||||||
const kind = config.contact_is_telegram === false ? 'external' : 'telegram';
|
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;
|
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}` };
|
return { kind: 'telegram', url: `https://t.me/${username}` };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user