500 lines
18 KiB
TypeScript
500 lines
18 KiB
TypeScript
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 { 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
|
||
resources?: { cover: string; background: string; intro: string; conclusion: string }
|
||
signal?: AbortSignal
|
||
onRequestFailure?: (failure: {
|
||
path: string
|
||
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
|
||
}) => void
|
||
}
|
||
|
||
function object(value: unknown, field?: string): Record<string, unknown> {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||
throw fieldError(field, 'объект', value, 'Некорректный ответ API')
|
||
return value as Record<string, unknown>
|
||
}
|
||
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',
|
||
]
|
||
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 > 8)
|
||
throw fieldError(
|
||
field,
|
||
'режим экрана 1–8 или 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<string, { id: string; url: string }>()
|
||
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 result = {
|
||
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 === 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<number, { value: Record<string, unknown>; 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 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`),
|
||
assetId,
|
||
}
|
||
})
|
||
.sort((a, b) => a.position - b.position || a.id - b.id)
|
||
return { id: playlistId, name: text(playlist.name, `${field}.name`), tracks }
|
||
})
|
||
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<ScreenPackage> {
|
||
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'
|
||
: ''
|
||
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 = `/api/games/{id}${suffix}`
|
||
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: `/api/games/{id}${suffix}`,
|
||
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: `/api/games/{id}${suffix}`, 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 }),
|
||
])
|
||
try {
|
||
return buildScreenPackage(server, request.gameId, { game, plan, music }, request.resources)
|
||
} catch (error) {
|
||
const field = error instanceof ResponseFieldError ? error.field : 'game'
|
||
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<string, string>()
|
||
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,
|
||
},
|
||
},
|
||
{
|
||
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
|
||
}
|