Перейти к основному содержимому

Deep links и мобильные SDK

Используйте ссылку вида:

https://watch.shopstory.live/stream/<translationId>?t=<seconds>&utm_source=<source>
  • translationId — ID трансляции из /v3/streams.
  • t — опциональный таймкод в секундах, чтобы открыть запись с нужного момента.
  • Добавляйте utm_* параметры для аналитики.
shopstory://stream/<translationId>?t=<seconds>&product=<feedProductId>
  • Поддерживается ShopStory SDK для iOS и Android.
  • Передавайте product, чтобы открывать запись с нужным товаром внутри приложения.

WebView bridge и SDK

  • SDK пробрасывает события воспроизведения и кликов в нативный код — используйте их для аналитики и CRM.
  • При открытии deep link из push-уведомления передавайте utm_campaign, чтобы можно было корректно атрибутировать переходы.
  • Если вы используете собственный WebView, убедитесь, что он разрешает postMessage между страницей и контейнером.

Встраивание в кастомные UI

  • Используйте параметр embed=true в мини-плеере, чтобы получить iFrame-friendly версию.
  • Для SSR-проектов не забывайте обрабатывать CORS и задавать referrerpolicy="strict-origin".

Валидация translationId

Всегда валидируйте ID перед использованием:

Безопасная валидация translationId
/**
* Валидатор для translationId
* Разрешает только буквы, цифры, дефисы и подчеркивания
*/
function isValidTranslationId(id) {
if (!id || typeof id !== 'string') {
return false;
}

// Только безопасные символы
const safePattern = /^[a-zA-Z0-9_-]+$/;

// Разумная длина (1-100 символов)
if (id.length === 0 || id.length > 100) {
return false;
}

return safePattern.test(id);
}

/**
* Создать безопасный deep link
*/
function createSafeDeepLink(translationId, options = {}) {
// Валидация ID
if (!isValidTranslationId(translationId)) {
throw new Error('Invalid translation ID format');
}

// Валидация таймкода
const startTime = parseInt(options.startTime) || 0;
if (startTime < 0 || startTime > 86400) { // Макс 24 часа
throw new Error('Invalid start time');
}

// Валидация UTM параметров
const allowedUtmSources = ['app', 'website', 'email', 'push', 'banner'];
const utmSource = allowedUtmSources.includes(options.utmSource)
? options.utmSource
: 'app';

// Безопасное построение URL
const url = new URL('https://watch.shopstory.live/stream/' + encodeURIComponent(translationId));

if (startTime > 0) {
url.searchParams.set('t', startTime.toString());
}

url.searchParams.set('utm_source', utmSource);

return url.toString();
}

// Использование
try {
const link = createSafeDeepLink('2017', {
startTime: 120,
utmSource: 'app'
});

console.log('✅ Deep link:', link);
// https://watch.shopstory.live/stream/2017?t=120&utm_source=app

} catch (error) {
console.error('❌ Ошибка создания deep link:', error.message);
}

Защита от Open Redirect

Безопасная обработка редиректов
/**
* ❌ УЯЗВИМЫЙ КОД - не используйте!
*/
function createUnsafeLink(translationId, redirect) {
// Опасно! Пользовательский ввод может содержать http://evil.com
return `https://watch.shopstory.live/stream/${translationId}?redirect=${redirect}`;
}

/**
* ✅ БЕЗОПАСНЫЙ КОД
*/
function createSafeLinkWithReturn(translationId, returnPath) {
if (!isValidTranslationId(translationId)) {
throw new Error('Invalid translation ID');
}

// Whitelist разрешенных путей для возврата
const allowedPaths = [
'/catalog',
'/product',
'/profile',
'/cart'
];

// Проверяем, что returnPath начинается с разрешенного префикса
const isAllowed = allowedPaths.some(path => returnPath?.startsWith(path));

if (!isAllowed) {
console.warn('Invalid return path, using default');
returnPath = '/catalog';
}

// Убеждаемся, что путь не содержит протокол
if (returnPath.includes('://')) {
throw new Error('Return path cannot contain protocol');
}

const url = new URL('https://watch.shopstory.live/stream/' + encodeURIComponent(translationId));
url.searchParams.set('return', returnPath);

return url.toString();
}

// Безопасное использование
const link = createSafeLinkWithReturn('2017', '/catalog');
// ✅ https://watch.shopstory.live/stream/2017?return=/catalog

// Попытка атаки отклонена
try {
createSafeLinkWithReturn('2017', 'http://evil.com');
} catch (error) {
console.log('✅ Атака заблокирована:', error.message);
}

Безопасная обработка UTM параметров

Валидация UTM параметров
/**
* Безопасное добавление UTM меток
*/
function addSafeUTMParams(baseUrl, utmParams = {}) {
const url = new URL(baseUrl);

// Whitelist разрешенных источников
const allowedSources = ['app', 'website', 'email', 'push', 'banner', 'social'];
const allowedMediums = ['organic', 'cpc', 'email', 'push', 'banner', 'social'];
const allowedCampaigns = /^[a-zA-Z0-9_-]+$/;

// utm_source
if (utmParams.source && allowedSources.includes(utmParams.source)) {
url.searchParams.set('utm_source', utmParams.source);
}

// utm_medium
if (utmParams.medium && allowedMediums.includes(utmParams.medium)) {
url.searchParams.set('utm_medium', utmParams.medium);
}

// utm_campaign (только безопасные символы)
if (utmParams.campaign && allowedCampaigns.test(utmParams.campaign)) {
url.searchParams.set('utm_campaign', utmParams.campaign.slice(0, 50)); // Ограничение длины
}

return url.toString();
}

// Использование
const link = addSafeUTMParams(
'https://watch.shopstory.live/stream/2017',
{
source: 'email',
medium: 'email',
campaign: 'spring-sale-2025'
}
);

console.log(link);
// https://watch.shopstory.live/stream/2017?utm_source=email&utm_medium=email&utm_campaign=spring-sale-2025

React компонент с валидацией

SafeStreamLink.jsx
import React from 'react';
import PropTypes from 'prop-types';

/**
* Безопасный компонент ссылки на стрим
*/
export function SafeStreamLink({ translationId, startTime, utmSource, children, className }) {
const isValidId = (id) => {
return typeof id === 'string' &&
id.length > 0 &&
id.length <= 100 &&
/^[a-zA-Z0-9_-]+$/.test(id);
};

const createLink = () => {
if (!isValidId(translationId)) {
console.error('Invalid translationId:', translationId);
return '#invalid-id';
}

const url = new URL('https://watch.shopstory.live/stream/' + encodeURIComponent(translationId));

// Таймкод
const time = parseInt(startTime) || 0;
if (time > 0 && time <= 86400) {
url.searchParams.set('t', time.toString());
}

// UTM source
const allowedSources = ['app', 'website', 'email', 'push'];
if (allowedSources.includes(utmSource)) {
url.searchParams.set('utm_source', utmSource);
}

return url.toString();
};

const handleClick = (e) => {
const link = createLink();

if (link === '#invalid-id') {
e.preventDefault();
console.error('Cannot navigate to invalid stream');
return;
}

// Логирование для аналитики
console.log('Stream link clicked:', {
translationId,
startTime,
utmSource
});
};

return (
<a
href={createLink()}
className={className}
onClick={handleClick}
rel="noopener noreferrer"
target="_blank"
>
{children}
</a>
);
}

SafeStreamLink.propTypes = {
translationId: PropTypes.string.isRequired,
startTime: PropTypes.number,
utmSource: PropTypes.oneOf(['app', 'website', 'email', 'push']),
children: PropTypes.node.isRequired,
className: PropTypes.string
};

SafeStreamLink.defaultProps = {
startTime: 0,
utmSource: 'app',
className: ''
};

// Использование
<SafeStreamLink
translationId="2017"
startTime={120}
utmSource="app"
className="stream-link"
>
Смотреть стрим
</SafeStreamLink>
Безопасная обработка мобильных deep links
/**
* Генерация мобильного deep link с валидацией
*/
function createMobileDeepLink(translationId, options = {}) {
if (!isValidTranslationId(translationId)) {
throw new Error('Invalid translationId');
}

const params = new URLSearchParams();

// Таймкод
const startTime = parseInt(options.startTime) || 0;
if (startTime > 0 && startTime <= 86400) {
params.set('t', startTime.toString());
}

// Product ID (валидация)
if (options.productId && /^[a-zA-Z0-9_-]+$/.test(options.productId)) {
params.set('product', options.productId.slice(0, 100));
}

const queryString = params.toString();
const link = `shopstory://stream/${encodeURIComponent(translationId)}${queryString ? '?' + queryString : ''}`;

return link;
}

/**
* Универсальная функция для web и mobile
*/
function createUniversalLink(translationId, options = {}) {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

if (isMobile && options.preferApp) {
return createMobileDeepLink(translationId, options);
}

return createSafeDeepLink(translationId, options);
}

// Использование
const link = createUniversalLink('2017', {
startTime: 120,
productId: 'p-12345',
preferApp: true
});

console.log('Universal link:', link);

Sanitization пользовательского ввода

Очистка пользовательского ввода
/**
* Очистить translationId от потенциально опасных символов
*/
function sanitizeTranslationId(input) {
if (!input || typeof input !== 'string') {
return null;
}

// Удаляем все кроме безопасных символов
const sanitized = input.replace(/[^a-zA-Z0-9_-]/g, '');

// Проверяем длину
if (sanitized.length === 0 || sanitized.length > 100) {
return null;
}

return sanitized;
}

/**
* Безопасная обработка ввода пользователя
*/
function handleUserInput(userInput) {
const translationId = sanitizeTranslationId(userInput);

if (!translationId) {
console.error('Invalid input from user');
// Показываем дружелюбное сообщение пользователю
showError('Некорректный формат идентификатора стрима');
return;
}

try {
const link = createSafeDeepLink(translationId, {
utmSource: 'search'
});

window.location.href = link;
} catch (error) {
console.error('Failed to create deep link:', error);
showError('Не удалось создать ссылку на стрим');
}
}

// Пример использования с формой поиска
document.getElementById('searchForm').addEventListener('submit', (e) => {
e.preventDefault();
const userInput = document.getElementById('streamId').value;
handleUserInput(userInput);
});

📊 Контрольный список безопасности

Перед использованием deep links в production:

  • ✅ Валидируете translationId перед использованием
  • ✅ Используете encodeURIComponent() для всех параметров
  • ✅ Проверяете длину строк (защита от DoS)
  • ✅ Используете whitelist для UTM параметров
  • ✅ Не принимаете URL редиректов от пользователей
  • ✅ Логируете подозрительные попытки создания ссылок
  • ✅ Используете Content Security Policy
  • ✅ Тестируете на XSS и open redirect уязвимости

Готово! Вернитесь к операционному разделу, чтобы учесть лимиты и кэширование.