import { createHash } from 'node:crypto' 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 }[] music: { scenarioPlaylistId: number | null gamePlaylistId: number | null selectedPlaylistId: number | null } playlists: { id: number name: string tracks: { id: number; name: string; position: number; assetId: string }[] }[] assets: { id: string; url: string }[] } export interface PackageRequest { server: string gameId: number token: string signal?: AbortSignal } function object(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Некорректный ответ API') return value as Record } function optionalObject(value: unknown) { return value == null ? {} : object(value) } function list(value: unknown): unknown[] { if (value == null) return [] if (!Array.isArray(value)) throw new Error('Некорректный список в ответе API') return value } function text(value: unknown): string { if (value == null) return '' if (typeof value !== 'string') throw new Error('Некорректный текст в ответе API') return value } function integer(value: unknown, fallback = 0): 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 new Error('Некорректное число в ответе API') return value } function id(value: unknown): number { const result = integer(value) if (result === 0) throw new Error('Отсутствует идентификатор в ответе 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', ] function mode(value: unknown): number { const result = typeof value === 'string' && value.startsWith('SCREEN_MODE_') ? modes.indexOf(value.slice('SCREEN_MODE_'.length)) : integer(value) if (result < 1 || result > 8) throw new Error('Неизвестный режим показа') 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 }, ): ScreenPackage { server = serverURL(server) id(gameId) const game = object(object(responses.game).game) if (id(game.ID) !== gameId) throw new Error('API вернул другую игру') const scenario = object(game.scenario) const story = object(scenario.story) const intro = optionalObject(story.introduction) const conclusion = optionalObject(story.conclusion) const assets = new Map() function asset(value: unknown): string | null { const ref = text(value).trim() if (!ref) return null const url = resolveMediaURL(server, ref) const assetId = hash(url) assets.set(assetId, { id: assetId, url }) return assetId } const cover = asset(scenario.image) const media = { cover, background: asset(story.background) ?? cover, intro: asset(intro.audio), conclusion: asset(conclusion.video), } const plan = optionalObject(object(responses.plan).plan) const steps = list(plan.steps).map((value) => { const step = object(value) const result = { mode: mode(step.mode), text: text(step.text), timerLabel: text(step.timerLabel), timerDurationSeconds: integer(step.timerDurationSeconds), } if (result.mode === 8 && !result.text.trim()) throw new Error('Пустой текст шага показа') if ( result.mode === 7 && (result.timerDurationSeconds < 1 || result.timerDurationSeconds > 86400) ) throw new Error('Некорректная длительность таймера') return result }) const musicResponse = object(responses.music) const state = optionalObject(musicResponse.state) const selected = state.playlist == null ? null : object(state.playlist) const music = { scenarioPlaylistId: integer(musicResponse.scenarioPlaylistId ?? scenario.music_playlist_id) || null, gamePlaylistId: integer(musicResponse.gamePlaylistId) || null, selectedPlaylistId: selected ? id(selected.ID) : null, } const available = new Map>() for (const value of list(musicResponse.playlists)) { const playlist = object(value) available.set(id(playlist.ID), playlist) } // Текущий плейлист берём из того же ответа, даже если его нет в библиотеке. if (selected) available.set(id(selected.ID), selected) 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 playlist = available.get(playlistId) if (!playlist) throw new Error('Не найден связанный плейлист') const tracks = list(playlist.tracks) .map((value) => { const track = object(value) const assetId = asset(track.filename) if (!assetId) throw new Error('Не указан файл трека') return { id: id(track.ID), name: text(track.name), position: integer(track.position), assetId, } }) .sort((a, b) => a.position - b.position || a.id - b.id) return { id: playlistId, name: text(playlist.name), tracks } }) const data = { formatVersion: 1 as const, server, game: { id: gameId, name: text(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] : []), ]) async function get(suffix: string): Promise { let response: Response try { response = await fetchAPI(`${server}/api/games/${request.gameId}${suffix}`, { headers: { Authorization: `Bearer ${request.token}`, Accept: 'application/json' }, credentials: 'omit', redirect: 'error', signal, }) } catch { throw new Error( signal.aborted ? 'Подготовка отменена или превышено время ожидания' : 'Сервер недоступен', ) } if (!response.ok) throw new Error( response.status === 401 ? 'Требуется авторизация' : response.status === 403 ? 'Нет доступа к игре' : `Ошибка API: HTTP ${response.status}`, ) try { return await response.json() } catch { throw new Error('Некорректный JSON в ответе API') } } const [game, plan, music] = await Promise.all([get(''), get('/screen-plan'), get('/music')]) return buildScreenPackage(server, request.gameId, { game, plan, music }) } // Восстанавливаем только разрешённые поля и проверяем отпечаток перед работой с диском. 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), tracks: list(playlist.tracks).map((value) => { const track = object(value) return { ID: id(track.id), name: text(track.name), position: integer(track.position), 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, timerDurationSeconds: step.timerDurationSeconds, } }), }, }, music: { scenarioPlaylistId: integer(music.scenarioPlaylistId), gamePlaylistId: integer(music.gamePlaylistId), state: { playlist: selected }, playlists, }, }) if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size) throw new Error('Повреждены метаданные пакета') return result }