diff --git a/src/components/connection/TvQuickConnect.tsx b/src/components/connection/TvQuickConnect.tsx index 56eb4e4..cdb7e09 100644 --- a/src/components/connection/TvQuickConnect.tsx +++ b/src/components/connection/TvQuickConnect.tsx @@ -6,6 +6,7 @@ import { retrieveLaunchParams, } from '@telegram-apps/sdk-react'; import { blockButtonClass } from './blocks/buttonStyles'; +import { loadHtml5Qrcode, type Html5QrcodeInstance } from '@/utils/qrScanner'; const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); @@ -19,24 +20,12 @@ function isTelegramMobile(): boolean { } const HAPP_TV_API = 'https://check.happ.su/sendtv'; -const HTML5_QRCODE_CDN = 'https://cdn.jsdelivr.net/npm/html5-qrcode@2.3.8/html5-qrcode.min.js'; interface Props { subscriptionUrl: string; isLight: boolean; } -interface Html5QrcodeInstance { - start: ( - cameraIdOrConfig: { facingMode: string } | string, - config: { fps: number; qrbox: { width: number; height: number } }, - onSuccess: (decoded: string) => void, - onError: () => void, - ) => Promise; - stop: () => Promise; - clear: () => void; -} - export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { const { t } = useTranslation(); const [code, setCode] = useState(''); @@ -158,24 +147,16 @@ export default function TvQuickConnect({ subscriptionUrl, isLight }: Props) { return; } - // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode - type WindowWithHtml5 = Window & { Html5Qrcode?: new (id: string) => Html5QrcodeInstance }; - const w = window as WindowWithHtml5; - if (!w.Html5Qrcode) { - const script = document.createElement('script'); - script.src = HTML5_QRCODE_CDN; - document.head.appendChild(script); - await new Promise((resolve, reject) => { - script.onload = () => resolve(); - script.onerror = () => reject(); - }).catch(() => undefined); - } - if (!w.Html5Qrcode) { + // Browser fallback (Mac/iOS Safari, desktop Chrome): html5-qrcode. + // Загрузка вынесена в общий хелпер — он подключает скрипт с проверкой + // целостности (SRI), чтобы подмена на стороне CDN не исполнилась в кабинете. + const Html5Qrcode = await loadHtml5Qrcode(); + if (!Html5Qrcode) { showToast(t('subscription.tvQuickConnect.noCamera'), 'error'); return; } setScanning(true); - const scanner = new w.Html5Qrcode('tv-qr-reader'); + const scanner = new Html5Qrcode('tv-qr-reader'); scannerRef.current = scanner; const config = { fps: 10, qrbox: { width: 220, height: 220 } }; try { diff --git a/src/utils/qrScanner.ts b/src/utils/qrScanner.ts index a1dccb8..8152982 100644 --- a/src/utils/qrScanner.ts +++ b/src/utils/qrScanner.ts @@ -12,6 +12,11 @@ import { const TG_MOBILE_PLATFORMS = new Set(['ios', 'android', 'android_x', 'ios_x']); export const HTML5_QRCODE_CDN = 'https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js'; +// SRI-хеш ИМЕННО этого файла (sha384 от html5-qrcode@2.3.8/html5-qrcode.min.js). +// Без него подмена на стороне CDN исполнилась бы в кабинете с полными правами +// страницы. При несовпадении браузер откажется исполнять скрипт — сканер +// деградирует в «камера недоступна», а не запускает чужой код. +const HTML5_QRCODE_SRI = 'sha384-c9d8RFSL+u3exBOJ4Yp3HUJXS4znl9f+z66d1y54ig+ea249SpqR+w1wyvXz/lk+'; export interface Html5QrcodeInstance { start: ( @@ -62,9 +67,14 @@ export async function loadHtml5Qrcode(): Promise((resolve) => { script.onload = () => resolve(); + // onerror срабатывает и при провале проверки целостности — вызывающий + // получит undefined и покажет «камера недоступна». script.onerror = () => resolve(); }); return w.Html5Qrcode;