import { fieldError, ResponseFieldError, requestErrorContext } from './request-error.js' import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js' import { ApiError, SessionChangedError, networkFailure, type NetworkErrorCode, } from './request-error.js' import { parseTimerType } from './screen-plan.js' import { validQR } from '../src/utils/presentation.js' import type { TimerType } from '../src/types/screen.js' import { createHash } from 'node:crypto' import type { MusicGroup } from '../src/types/screen.js' import { parsePlaylistGroups, apiGroupEndMode } from './playlist-groups.js' export const INCOMPLETE_MATERIALS_WARNING = 'Получен неполный список материалов. Подключите экран к игре для уточнения' export interface ScreenPackage { formatVersion: 1 metadataVersion: string server: string game: { id: number; name: string } media: { cover: string | null background: string | null intro: string | null conclusion: string | null } steps: { mode: number text: string timerLabel: string timerDurationSeconds: number qrUrl?: string timerType?: TimerType }[] music: { scenarioPlaylistId: number | null gamePlaylistId: number | null selectedPlaylistId: number | null } playlists: { id: number name: string groups?: MusicGroup[] tracks: { id: number; name: string; position: number; groupId?: number; assetId: string }[] }[] assets: { id: string; url: string }[] } export interface PackageRequest { server: string gameId: number token: string resources?: { cover: string; background: string; intro: string; conclusion: string } signal?: AbortSignal onIncomplete?: () => void onRequestFailure?: (failure: { path: string reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON' }) => void } function object(value: unknown, field?: string): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw fieldError(field, 'объект', value, 'Некорректный ответ API') return value as Record } function optionalObject(value: unknown, field?: string) { return value == null ? {} : object(value, field) } function list(value: unknown, field?: string): unknown[] { if (value == null) return [] if (!Array.isArray(value)) throw fieldError(field, 'массив', value, 'Некорректный список в ответе API') return value } function text(value: unknown, field?: string): string { if (value == null) return '' if (typeof value !== 'string') throw fieldError(field, 'строка', value, 'Некорректный текст в ответе API') return value } function integer(value: unknown, fallback = 0, field?: string): number { if (value == null) return fallback if (typeof value === 'string' && /^\d+$/.test(value)) value = Number(value) if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) throw fieldError(field, 'неотрицательное целое число', value, 'Некорректное число в ответе API') return value } function id(value: unknown, field?: string): number { const result = integer(value, 0, field) if (result === 0) throw fieldError( field, 'положительный идентификатор', value, 'Отсутствует идентификатор в ответе API', ) return result } function hash(value: string) { return createHash('sha256').update(value).digest('hex') } function httpURL(value: string): URL { const url = new URL(value) if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash) throw new Error('Недопустимый адрес сервера или файла') // Подписанные ссылки содержат секреты и не должны сохраняться в пакете. if ( [...url.searchParams.keys()].some((key) => /token|signature|credential|password|authorization/i.test(key), ) ) throw new Error('Ссылка на файл содержит секрет') return url } function serverURL(value: string): string { const url = httpURL(value) if (url.search) throw new Error('Недопустимый адрес сервера') return url.href.replace(/\/+$/, '') } const modes = [ 'UNSPECIFIED', 'COVER', 'INTRO', 'INTRO_DONE', 'CONCLUSION', 'CONCLUSION_DONE', 'RESULTS', 'TIMER', 'TEXT', 'QR', 'TEAMS', ] function mode(value: unknown, field?: string): number { const result = typeof value === 'string' && value.startsWith('SCREEN_MODE_') ? modes.indexOf(value.slice('SCREEN_MODE_'.length)) : integer(value, 0, field) if (result < 1 || result > 10) throw fieldError( field, 'режим экрана 1–10 или enum ScreenMode', value, 'Неизвестный режим показа', ) return result } export function resolveMediaURL(server: string, value: string): string { const ref = value.trim() if (ref.startsWith('//') || (/^[a-z][a-z0-9+.-]*:/i.test(ref) && !/^https?:\/\//i.test(ref))) throw new Error('Недопустимый адрес файла') const url = httpURL( /^https?:\/\//i.test(ref) ? ref : ref.startsWith('/') ? new URL(ref, server).href : `${server}/api/files/${encodeURIComponent(ref)}`, ).href return url } // Только проекция данных показа: сырые ответы API могут содержать участников и секреты. export function buildScreenPackage( server: string, gameId: number, responses: { game: unknown; plan: unknown; music: unknown }, resources?: PackageRequest['resources'], ): ScreenPackage { server = serverURL(server) id(gameId) const game = object(object(responses.game, 'gameResponse').game, 'game') if (id(game.ID, 'game.ID') !== gameId) throw new ResponseFieldError( 'game.ID', 'идентификатор выбранной игры', game.ID, 'API вернул другую игру', ) const scenario = optionalObject(game.scenario, 'game.scenario') const story = optionalObject(scenario.story, 'game.scenario.story') const intro = optionalObject(story.introduction, 'game.scenario.story.introduction') const conclusion = optionalObject(story.conclusion, 'game.scenario.story.conclusion') const assets = new Map() function asset(value: unknown, field: string): string | null { const ref = text(value, field).trim() if (!ref) return null let url: string try { url = resolveMediaURL(server, ref) } catch { throw new ResponseFieldError( field, 'адрес HTTP(S) без секретов', value, 'Недопустимый адрес файла или ссылка содержит секрет', ) } const assetId = hash(url) assets.set(assetId, { id: assetId, url }) return assetId } const cover = resources ? asset(resources.cover, 'state.coverImage') : asset(scenario.image, 'game.scenario.image') const media = { cover, background: resources ? asset(resources.background, 'state.backgroundImage') : (asset(story.background, 'game.scenario.story.background') ?? cover), intro: resources ? asset(resources.intro, 'state.introAudio') : asset(intro.audio, 'game.scenario.story.introduction.audio'), conclusion: resources ? asset(resources.conclusion, 'state.conclusionVideo') : asset(conclusion.video, 'game.scenario.story.conclusion.video'), } const plan = optionalObject(object(responses.plan, 'planResponse').plan, 'plan') const steps = list(plan.steps, 'plan.steps').map((value, index) => { const field = `plan.steps[${index}]` const step = object(value, field) const timerType = parseTimerType(step.timerType) const qrUrl = text(step.qrUrl, `${field}.qrUrl`) const result = { ...(qrUrl ? { qrUrl } : {}), ...(timerType ? { timerType } : {}), mode: mode(step.mode, `${field}.mode`), text: text(step.text, `${field}.text`), timerLabel: text(step.timerLabel, `${field}.timerLabel`), timerDurationSeconds: integer(step.timerDurationSeconds, 0, `${field}.timerDurationSeconds`), } if (result.mode === 9 && !validQR(qrUrl, result.text)) throw new Error('Некорректные параметры QR-кода') if (result.mode === 8 && !result.text.trim()) throw new ResponseFieldError( `${field}.text`, 'непустая строка', step.text, 'Пустой текст шага показа', ) if ( result.mode === 7 && (result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400) ) throw new ResponseFieldError( `${field}.timerDurationSeconds`, 'целое число от 1 до 86400', step.timerDurationSeconds, 'Некорректная длительность таймера', ) return result }) const musicResponse = object(responses.music, 'musicResponse') const state = optionalObject(musicResponse.state, 'music.state') const selected = state.playlist == null ? null : object(state.playlist, 'music.state.playlist') const music = { scenarioPlaylistId: integer( musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id, 0, musicResponse.scenarioPlaylistId == null ? 'game.scenario.music_playlist_id' : 'music.scenarioPlaylistId', ) || null, gamePlaylistId: integer(musicResponse.gamePlaylistId, 0, 'music.gamePlaylistId') || null, selectedPlaylistId: selected ? id(selected.ID, 'music.state.playlist.ID') : null, } const available = new Map; field: string }>() for (const [index, value] of list(musicResponse.playlists, 'music.playlists').entries()) { const field = `music.playlists[${index}]` const playlist = object(value, field) available.set(id(playlist.ID, `${field}.ID`), { value: playlist, field }) } // Текущий плейлист берём из того же ответа, даже если его нет в библиотеке. if (selected) available.set(id(selected.ID, 'music.state.playlist.ID'), { value: selected, field: 'music.state.playlist', }) const selectedIds = [ ...new Set(Object.values(music).filter((value): value is number => value !== null)), ].sort((a, b) => a - b) const playlists = selectedIds.map((playlistId) => { const entry = available.get(playlistId) if (!entry) throw new ResponseFieldError( 'music.playlists', 'связанный плейлист', undefined, 'Не найден связанный плейлист', ) const playlist = entry.value const field = entry.field const groups = parsePlaylistGroups(playlist.groups) const tracks = list(playlist.tracks, `${field}.tracks`) .map((value, index) => { const trackField = `${field}.tracks[${index}]` const track = object(value, trackField) const assetId = asset(track.filename, `${trackField}.filename`) if (!assetId) throw new ResponseFieldError( `${trackField}.filename`, 'непустая строка', track.filename, 'Не указан файл трека', ) return { id: id(track.ID, `${trackField}.ID`), name: text(track.name, `${trackField}.name`), position: integer(track.position, 0, `${trackField}.position`), ...(integer(track.groupId, 0, `${trackField}.groupId`) ? { groupId: integer(track.groupId, 0, `${trackField}.groupId`) } : {}), assetId, } }) .sort((a, b) => a.position - b.position || a.id - b.id) for (const track of tracks) { if (track.groupId && !groups.some((group) => group.id === track.groupId)) throw new Error('Группа трека отсутствует в плейлисте') } return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks, ...(groups.length ? { groups } : {}), } }) const data = { formatVersion: 1 as const, server, game: { id: gameId, name: text(game.name, 'game.name') }, media, steps, music, playlists, assets: [...assets.values()].sort((a, b) => a.id.localeCompare(b.id)), } // Это отпечаток метаданных, а не подтверждение целостности ещё не скачанных файлов. return { ...data, metadataVersion: hash(JSON.stringify(data)) } } // Вызывается основным процессом; токен не передаётся renderer и не входит в результат. export async function prepareScreenPackage( request: PackageRequest, fetchAPI: typeof fetch = fetch, ): Promise { const server = serverURL(request.server) id(request.gameId) if (!request.token.trim() || /[\r\n]/.test(request.token)) throw new Error('Требуется действующий токен') const signal = AbortSignal.any([ AbortSignal.timeout(30_000), ...(request.signal ? [request.signal] : []), ]) const client = createEveningDetectiveServerClient(async (requestData, meta) => { const suffix = meta.method === 'GetGameMusic' ? '/music' : meta.method === 'GetScreenPlan' ? '/screen-plan' : '' const safePath = meta.method === 'GetFullScenario' ? '/api/scenarios/{id}/full' : `/api/games/{id}${suffix}` let response: Response try { response = await fetchAPI(`${server}/${requestData.path}`, { method: requestData.method, body: requestData.body, headers: { Authorization: `Bearer ${request.token}`, Accept: 'application/json' }, credentials: 'omit', redirect: 'error', signal, }) } catch (error) { const path = safePath if (error instanceof ApiError || error instanceof SessionChangedError) { request.onRequestFailure?.({ path, reason: error instanceof ApiError ? [401, 403].includes(error.status) ? 'AUTH' : 'HTTP' : 'SESSION_CHANGED', }) throw requestErrorContext(error, meta.method, requestData.method, `/${requestData.path}`) } const failure = networkFailure(error, signal) request.onRequestFailure?.({ path, reason: failure.code }) throw requestErrorContext(failure, meta.method, requestData.method, `/${requestData.path}`) } if (!response.ok) { request.onRequestFailure?.({ path: safePath, reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP', }) throw requestErrorContext( new ApiError(response.status), meta.method, requestData.method, `/${requestData.path}`, ) } try { return await response.json() } catch { request.onRequestFailure?.({ path: safePath, reason: 'INVALID_JSON' }) throw requestErrorContext( new Error('Некорректный JSON в ответе API'), meta.method, requestData.method, `/${requestData.path}`, ) } }) const [game, plan, music] = await Promise.all([ client.GetGame({ ID: request.gameId }), client.GetScreenPlan({ gameID: request.gameId }), client.GetGameMusic({ gameID: request.gameId }), ]) let fullScenarioId: number | null = null try { const gameData = object(object(game, 'gameResponse').game, 'game') const scenario = optionalObject(gameData.scenario, 'game.scenario') if (!request.resources && gameData.scenario != null) { if (scenario.ID != null) { const scenarioId = id(scenario.ID, 'game.scenario.ID') let response let received = false try { response = await client.GetFullScenario({ ID: scenarioId }) received = true } catch (error) { if (signal.aborted || error instanceof SessionChangedError || (error instanceof ApiError && error.status === 401)) throw error request.onIncomplete?.() } if (received) { try { const full = object(object(response, 'scenarioResponse').scenario, 'scenario') if (id(full.ID, 'scenario.ID') !== scenarioId) throw new ResponseFieldError('scenario.ID', 'идентификатор сценария выбранной игры', full.ID, 'API вернул другой сценарий') optionalObject(full.story, 'scenario.story') fullScenarioId = scenarioId gameData.scenario = { ...scenario, ...full, story: full.story } if (full.story == null) request.onIncomplete?.() } catch (error) { throw requestErrorContext(error, 'GetFullScenario', 'GET', `/api/scenarios/${scenarioId}/full`) } } } else if (scenario.story == null) request.onIncomplete?.() } return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources) } catch (error) { const field = error instanceof ResponseFieldError ? error.field : 'game' if (fullScenarioId !== null && field.startsWith('game.scenario.')) throw requestErrorContext(error, 'GetFullScenario', 'GET', `/api/scenarios/${fullScenarioId}/full`) const state = field.startsWith('state.') const suffix = field.startsWith('plan') ? '/screen-plan' : field.startsWith('music') ? '/music' : '' throw requestErrorContext( error, state ? 'GetScreenState' : suffix === '/music' ? 'GetGameMusic' : suffix ? 'GetScreenPlan' : 'GetGame', state ? 'POST' : 'GET', state ? '/api/screens/state' : `/api/games/${request.gameId}${suffix}`, ) } } // Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском. export function parseScreenPackage(value: unknown): ScreenPackage { const data = object(value) if (data.formatVersion !== 1) throw new Error('Неизвестный формат пакета') const game = object(data.game) const media = object(data.media) const music = object(data.music) const assets = new Map() for (const value of list(data.assets)) { const entry = object(value) const key = text(entry.id) const url = httpURL(text(entry.url)).href if (key !== hash(url) || assets.has(key)) throw new Error('Некорректный ресурс пакета') assets.set(key, url) } function ref(value: unknown): string { if (value === null) return '' const url = assets.get(text(value)) if (!url) throw new Error('Отсутствует ресурс пакета') return url } const playlists = list(data.playlists).map((value) => { const playlist = object(value) return { ID: id(playlist.id), name: text(playlist.name), ...(playlist.groups === undefined ? {} : { groups: list(playlist.groups).map((value) => { const group = object(value) if (!['repeat', 'stop', 'next'].includes(String(group.endMode))) throw new Error('Некорректный режим группы пакета') return { ID: id(group.id), name: text(group.name), position: integer(group.position), endMode: apiGroupEndMode(group.endMode as MusicGroup['endMode']), } }), }), tracks: list(playlist.tracks).map((value) => { const track = object(value) return { ID: id(track.id), name: text(track.name), position: integer(track.position), ...(track.groupId === undefined ? {} : { groupId: integer(track.groupId) }), filename: ref(track.assetId), } }), } }) const selectedId = integer(music.selectedPlaylistId) const selected = selectedId ? playlists.find((playlist) => playlist.ID === selectedId) : undefined if (selectedId && !selected) throw new Error('Отсутствует выбранный плейлист') const result = buildScreenPackage( text(data.server), id(game.id), { game: { game: { ID: game.id, name: text(game.name), scenario: { image: ref(media.cover), story: { background: ref(media.background), introduction: { audio: ref(media.intro) }, conclusion: { video: ref(media.conclusion) }, }, }, }, }, plan: { plan: { steps: list(data.steps).map((value) => { const step = object(value) return { mode: step.mode, text: step.text, timerLabel: step.timerLabel, qrUrl: step.qrUrl, timerType: step.timerType, timerDurationSeconds: step.timerDurationSeconds, } }), }, }, music: { scenarioPlaylistId: integer(music.scenarioPlaylistId), gamePlaylistId: integer(music.gamePlaylistId), state: { playlist: selected }, playlists, }, }, { cover: ref(media.cover), background: ref(media.background), intro: ref(media.intro), conclusion: ref(media.conclusion), }, ) if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size) throw new Error('Повреждены метаданные пакета') return result }