392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import { createEveningDetectiveServerClient } from './api/generated/crabs/evening_detective_server/index.js'
|
|
import {
|
|
ApiError,
|
|
NetworkError,
|
|
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
|
|
signal?: AbortSignal
|
|
onRequestFailure?: (failure: {
|
|
path: string
|
|
reason: NetworkErrorCode | 'SESSION_CHANGED' | 'AUTH' | 'HTTP' | 'INVALID_JSON'
|
|
}) => void
|
|
}
|
|
|
|
function object(value: unknown): Record<string, unknown> {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
throw new Error('Некорректный ответ API')
|
|
return value as Record<string, unknown>
|
|
}
|
|
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<string, { id: string; url: string }>()
|
|
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<number, Record<string, unknown>>()
|
|
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<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 error
|
|
}
|
|
const failure = networkFailure(error, signal)
|
|
request.onRequestFailure?.({ path, reason: failure.code })
|
|
const name =
|
|
suffix === '/music'
|
|
? 'Музыка игры'
|
|
: suffix === '/screen-plan'
|
|
? 'План экранов'
|
|
: 'Описание игры'
|
|
throw new NetworkError(`${name}: ${failure.message}`, failure.code)
|
|
}
|
|
if (!response.ok) {
|
|
request.onRequestFailure?.({
|
|
path: `/api/games/{id}${suffix}`,
|
|
reason: [401, 403].includes(response.status) ? 'AUTH' : 'HTTP',
|
|
})
|
|
throw new Error(
|
|
response.status === 401
|
|
? 'Требуется авторизация'
|
|
: response.status === 403
|
|
? 'Нет доступа к игре'
|
|
: `Ошибка API: HTTP ${response.status}`,
|
|
)
|
|
}
|
|
try {
|
|
return await response.json()
|
|
} catch {
|
|
request.onRequestFailure?.({ path: `/api/games/{id}${suffix}`, reason: 'INVALID_JSON' })
|
|
throw new Error('Некорректный JSON в ответе API')
|
|
}
|
|
})
|
|
const [game, plan, music] = await Promise.all([
|
|
client.GetGame({ ID: request.gameId }),
|
|
client.GetScreenPlan({ gameID: request.gameId }),
|
|
client.GetGameMusic({ gameID: request.gameId }),
|
|
])
|
|
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<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,
|
|
},
|
|
})
|
|
if (result.metadataVersion !== data.metadataVersion || result.assets.length !== assets.size)
|
|
throw new Error('Повреждены метаданные пакета')
|
|
return result
|
|
}
|