Merge PR #502: ужесточить resolveSupportContact

Схема support_url валидируется (http/https/tg, чужое → null), legacy
SUPPORT_USERNAME проверяется регуляркой (URL-образное не клеится в
t.me/https://…), null → кнопка контакта не рендерится вместо дефолтного
@support. Ветки ticketsDisabled/both переведены на общий резолв.
Спасибо @AirP0WeR.
This commit is contained in:
Fringg
2026-07-24 14:21:59 +03:00
3 changed files with 103 additions and 59 deletions

View File

@@ -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 */}

View File

@@ -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');
}); });
}); });

View File

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