import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import type { OnlineIntent, OnlineState } from '../src/types/online.js' import type { Report, ResultsSnapshot } from '../src/types/screen.js' import type { Controller } from './controller.js' import { PackageStore, packageKey } from './package-store.js' import { prepareScreenPackage } from './screen-package.js' import { parseResults } from './results.js' import { applyRemote, integer, object, parseRemote, send, text, type RemoteState, } from './remote-state.js' export interface SecretStorage { available(): boolean encrypt(value: string): Buffer decrypt(value: Buffer): string } interface ScreenSession { id: string token: string gameId: number } interface Credentials { server: string access: string refresh: string screen: ScreenSession | null } class ApiError extends Error { constructor(public status: number) { super( status === 401 ? 'Сессия истекла. Войдите снова' : status === 403 ? 'Нет доступа к действию' : status === 400 ? 'Действие пока недоступно для этой игры' : `Ошибка сервера: HTTP ${status}`, ) } } function serverURL(value: unknown): string { const url = new URL(text(value)) if ( !['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== '') ) throw new Error('Укажите адрес HTTP(S) сервера без пути и параметров') return url.origin } function token(value: unknown): string { const s = text(value) if (!s || s.length > 16384 || /[\r\n]/.test(s)) throw new Error('Сервер не вернул действующие токены') return s } export class Online { readonly state: OnlineState = { server: '', authenticated: false, remembered: false, busy: false, canCancel: false, games: [], screenId: null, connection: 'disabled', error: '', notice: '', progress: null, packagesRevision: 0, } private credentials: Credentials | null = null private screen: ScreenSession | null = null private generation = 0 private refreshPromise: Promise | null = null private writes: Promise = Promise.resolve() private cancellation: AbortController | null = null private downloadKey: string | null = null private previous: RemoteState | null = null private pollBusy = false private pollSession = '' private initialSound = true private lastResults = -Infinity private resultsBusy = false private advanceBusy = false constructor( private directory: string, private packages: PackageStore, private controller: Controller, private secrets: SecretStorage, private fetchAPI: typeof fetch = fetch, ) { controller.onMusicEnded = (report) => { void this.musicEnded(report) } } get blocksOutput() { return this.downloadKey !== null && this.downloadKey === this.controller.state.catalog.key } get busy() { return this.state.busy } snapshot(): OnlineState { return structuredClone(this.state) } private async atomic(name: string, data: string | Buffer) { await mkdir(this.directory, { recursive: true, mode: 0o700 }) const path = join(this.directory, name) await writeFile(`${path}.tmp`, data, { mode: 0o600 }) await rename(`${path}.tmp`, path) } private persistSecrets() { const data = this.credentials ? { ...this.credentials, screen: this.screen } : null this.state.remembered = !!data && this.secrets.available() let encrypted: Buffer | null = null try { if (this.state.remembered) encrypted = this.secrets.encrypt(JSON.stringify(data)) } catch { this.state.remembered = false this.state.notice = 'Не удалось сохранить вход средствами ОС. Сессия действует до закрытия приложения' } const write = this.writes.then(async () => { if (encrypted) await this.atomic('credentials.bin', encrypted) else await rm(join(this.directory, 'credentials.bin'), { force: true }) }) this.writes = write.catch(() => {}) return write } async initialize() { try { const config = object( JSON.parse(await readFile(join(this.directory, 'selection.json'), 'utf8')), ) if (config.server) this.state.server = serverURL(config.server) if (typeof config.key === 'string' && /^[a-f0-9]{64}$/.test(config.key)) await this.select(config.key, false) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.state.error = 'Не удалось восстановить выбранные материалы; выберите готовый пакет' } if (!this.secrets.available()) return try { const raw = object( JSON.parse(this.secrets.decrypt(await readFile(join(this.directory, 'credentials.bin')))), ) const server = serverURL(raw.server) const c: Credentials = { server, access: token(raw.access), refresh: token(raw.refresh), screen: null, } if (raw.screen) { const s = object(raw.screen) c.screen = { id: String(integer(s.id, 1)), token: token(s.token), gameId: integer(s.gameId, 1), } } this.credentials = c this.state.server = server this.state.authenticated = true this.state.remembered = true const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key) if (c.screen && pkg?.package.server === server && pkg.package.game.id === c.screen.gameId) { this.screen = c.screen this.state.screenId = c.screen.id this.state.connection = 'waiting' this.initialSound = true } } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.state.error = 'Сохранённый вход недоступен. Войдите снова; локальные материалы доступны' } } private async saveSelection() { await this.atomic( 'selection.json', JSON.stringify({ server: this.state.server, key: this.controller.state.catalog.key }), ) } async select(key: string | null, persist = true) { if (this.busy) throw new Error('Подождите завершения подготовки материалов') let pkg try { pkg = key === null ? null : await this.packages.verify(key) } catch (error) { if (key === this.controller.state.catalog.key) { this.detach() this.controller.selectPackage(null) await this.saveSelection() await this.persistSecrets() this.state.packagesRevision++ } throw error } this.detach() this.controller.selectPackage(pkg) this.lastResults = -Infinity if (key) await this.restoreResults(key) if (persist) { await this.saveSelection() await this.persistSecrets() } } private detach() { this.generation++ this.screen = null this.previous = null this.state.screenId = null this.state.connection = 'disabled' } private async raw(server: string, path: string, init: RequestInit = {}): Promise { try { return await this.fetchAPI(`${server}${path}`, { ...init, redirect: 'error', credentials: 'omit', signal: AbortSignal.any([ AbortSignal.timeout(30_000), ...(init.signal ? [init.signal] : []), ]), }) } catch { throw new Error('Сервер недоступен или превышено время ожидания') } } private async json(response: Response): Promise> { if (!response.ok) throw new ApiError(response.status) try { return object(await response.json()) } catch { throw new Error('Сервер вернул некорректный JSON') } } private async refresh(failed: string, generation: number) { if (this.credentials?.access !== failed) return if (this.refreshPromise) return this.refreshPromise const c = this.credentials! this.refreshPromise = (async () => { const response = await this.raw(c.server, '/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: c.refresh }), }) if (generation !== this.generation) throw new Error('Сессия изменилась') if (response.status === 401) { this.credentials = null this.state.authenticated = false await this.persistSecrets() throw new ApiError(401) } const result = await this.json(response) if (generation !== this.generation) throw new Error('Сессия изменилась') this.credentials = { ...c, access: token(result.accessToken), refresh: token(result.refreshToken), } await this.persistSecrets() })().finally(() => { this.refreshPromise = null }) return this.refreshPromise } private async authorized(path: string, init: RequestInit = {}) { const c = this.credentials const generation = this.generation if (!c) throw new ApiError(401) const request = (access: string) => { const headers = new Headers(init.headers) headers.set('Authorization', `Bearer ${access}`) return this.raw(c.server, path, { ...init, headers }) } let response = await request(c.access) if (generation !== this.generation) throw new Error('Сессия изменилась') if (response.status === 401) { await response.body?.cancel() await this.refresh(c.access, generation) if (generation !== this.generation || !this.credentials) throw new ApiError(401) response = await request(this.credentials.access) if (response.status === 401) { this.credentials = null this.state.authenticated = false await this.persistSecrets() } } return response } private async api(path: string, body?: object, authorized = true, signal?: AbortSignal) { const init: RequestInit = body === undefined ? { signal } : { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal, } return this.json( authorized ? await this.authorized(path, init) : await this.raw(this.state.server, path, init), ) } async act(input: unknown): Promise { const data = object(input) if (data.action === 'cancel') { this.cancellation?.abort() this.packages.cancel() return this.snapshot() } if (this.busy) throw new Error('Подождите завершения текущего действия') this.state.busy = true this.state.error = '' this.state.notice = '' try { await this.perform(data) } catch (error) { this.state.error = error instanceof Error ? error.message : 'Действие не выполнено' throw new Error(this.state.error) } finally { this.state.busy = false this.state.progress = null } return this.snapshot() } private async perform(data: Record) { const action = data.action as OnlineIntent['action'] switch (action) { case 'login': { const server = serverURL(data.server) const email = text(data.email).trim() const password = text(data.password) if (!email.includes('@') || email.length > 320 || !password || password.length > 4096) throw new Error('Укажите email и пароль') this.detach() this.credentials = null this.state.authenticated = false this.state.games = [] await this.persistSecrets() this.state.server = server const response = await this.raw(server, '/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, password }), }) const result = await this.json(response) this.credentials = { server, access: token(result.accessToken), refresh: token(result.refreshToken), screen: null, } try { const me = object((await this.api('/api/users/me')).user) if ( !Array.isArray(me.roles) || !me.roles.some((r) => ['admin', 'organizer', 'author'].includes(String(r))) ) throw new Error( 'Для управления экраном нужны права организатора, автора или администратора', ) } catch (error) { this.credentials = null await this.persistSecrets() throw error } this.state.authenticated = true await this.persistSecrets() await this.saveSelection() await this.loadGames() return } case 'logout': this.detach() this.credentials = null this.state.authenticated = false this.state.games = [] await this.persistSecrets() return case 'games': return this.loadGames() case 'download': case 'check': { const gameId = integer(data.gameId, 1) const key = packageKey(this.state.server, gameId) if ( action === 'download' && key === this.controller.state.catalog.key && this.controller.state.outputOpen ) throw new Error('Закройте показ перед обновлением выбранной игры') if (!this.credentials) throw new ApiError(401) this.downloadKey = action === 'download' ? key : null this.cancellation = new AbortController() this.state.canCancel = true try { const description = await prepareScreenPackage( { server: this.state.server, gameId, token: this.credentials.access, signal: this.cancellation.signal, }, (url, init) => { const parsed = new URL(String(url)) if (parsed.origin !== this.state.server) throw new Error('Недопустимый сервер API') return this.authorized(parsed.pathname, init) }, ) if (action === 'check') { const previous = this.packages.list().find((p) => p.key === key) this.state.notice = !previous ? 'Игра ещё не загружена' : previous.package.metadataVersion === description.metadataVersion ? 'Описание не изменилось. Для проверки замены файлов по прежним адресам загрузите материалы повторно' : 'На сервере есть изменения. Загрузите материалы при закрытом показе' return } const pkg = await this.packages.download(description, { signal: this.cancellation.signal, onProgress: (p) => { this.state.progress = p }, }) this.state.packagesRevision++ if (key === this.controller.state.catalog.key) { this.controller.selectPackage(pkg) this.previous = null await this.restoreResults(key) } this.state.notice = 'Материалы проверены и готовы к показу' } finally { this.cancellation = null this.state.canCancel = false this.downloadKey = null } return } case 'remove': { const key = text(data.key) if (!/^[a-f0-9]{64}$/.test(key)) throw new Error('Некорректный пакет') if (key === this.controller.state.catalog.key) throw new Error('Сначала выберите другой пакет или тестовый режим') await this.packages.remove(key) await rm(join(this.directory, `results-${key}.json`), { force: true }) this.state.packagesRevision++ return } case 'connect': { const pkg = this.packages.list().find((p) => p.key === this.controller.state.catalog.key) if (!pkg || pkg.package.server !== this.state.server) throw new Error('Выберите готовую игру с этого сервера') if (!this.credentials) throw new ApiError(401) this.detach() const result = await this.api('/api/screens', {}, false) const s: ScreenSession = { id: String(integer(result.screenID, 1)), token: token(result.accessToken), gameId: pkg.package.game.id, } await this.api(`/api/screens/${s.id}/bind`, { screenID: s.id, accessToken: s.token, gameID: s.gameId, }) this.screen = s this.state.screenId = s.id this.state.connection = 'waiting' this.initialSound = true await this.persistSecrets() return } case 'disconnect': this.detach() await this.persistSecrets() return case 'sound': if (!this.screen) throw new Error('Сначала подключите управление') await this.api(`/api/screens/${this.screen.id}/sound`, { screenID: this.screen.id, soundEnabled: true, }) return case 'results': return this.refreshResults() default: throw new Error('Неизвестное действие') } } private async loadGames() { const result = await this.api('/api/games') if (!Array.isArray(result.games ?? [])) throw new Error('Некорректный список игр') this.state.games = ((result.games ?? []) as unknown[]).map((value) => { const g = object(value) return { id: integer(g.ID, 1), name: text(g.name), status: text(g.status ?? '') } }) } private async restoreResults(key: string) { try { this.controller.setResults( parseResults( JSON.parse(await readFile(join(this.directory, `results-${key}.json`), 'utf8')), key, ), ) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.state.error = 'Сохранённые результаты недоступны; обновите снимок' } } async refreshResults() { if (this.resultsBusy) return const s = this.screen const key = this.controller.state.catalog.key if (!s || !key) throw new Error('Подключите экран к выбранной игре для получения результатов') const generation = this.generation this.resultsBusy = true this.lastResults = Date.now() try { const result = await this.api( '/api/screens/results', { screenID: s.id, accessToken: s.token }, false, ) if (generation !== this.generation || key !== this.controller.state.catalog.key) return if (!Array.isArray(result.questions ?? []) || !Array.isArray(result.teams ?? [])) throw new Error('Некорректные результаты') const snapshot: ResultsSnapshot = parseResults( { packageKey: key, capturedAt: new Date().toISOString(), questions: result.questions ?? [], teams: ((result.teams ?? []) as unknown[]).map((value) => { const row = object(value) const team = object(row.team) if (!Array.isArray(row.answers ?? [])) throw new Error('Некорректные ответы') return { id: text(team.ID), name: text(team.name), place: integer(row.place, 1), totalScore: integer(row.totalScore ?? 0, -2147483648), actionsCount: integer(row.actionsCount ?? 0), answers: ((row.answers ?? []) as unknown[]).map((value) => { const a = object(value) return { questionCode: text(a.questionCode), score: integer(a.score ?? 0, -2147483648), } }), } }), }, key, ) await this.atomic(`results-${key}.json`, JSON.stringify(snapshot)) if (generation === this.generation && key === this.controller.state.catalog.key) this.controller.setResults(snapshot) } finally { this.resultsBusy = false } } async poll() { const s = this.screen const session = this.controller.state.session if (!s || !this.controller.state.outputOpen) { this.previous = null if (s) this.state.connection = 'waiting' return } if (this.pollBusy) return if (this.pollSession !== session) { this.previous = null this.pollSession = session } this.pollBusy = true const generation = this.generation try { const result = await this.api( '/api/screens/state', { screenID: s.id, accessToken: s.token }, false, AbortSignal.timeout(5000), ) if ( generation !== this.generation || session !== this.controller.state.session || !this.controller.state.outputOpen ) return let next = parseRemote(result.state) if (next.gameId !== s.gameId) { this.detach() await this.persistSecrets() this.state.error = 'Привязка экрана изменилась на сервере. Локальный показ сохранён; подключите управление заново' return } const previous = this.previous if (previous && next.version < previous.version) next = { ...previous, sound: next.sound, music: next.music } if (previous?.music && next.music && next.music.version < previous.music.version) next = { ...next, music: previous.music } this.previous = next if (this.state.connection === 'offline') this.state.error = '' this.state.connection = 'connected' if (!previous && this.initialSound) { this.initialSound = false send(this.controller, { action: 'mute', enabled: !next.sound }) } if (previous) { try { applyRemote(this.controller, previous, next) } catch (error) { this.state.error = error instanceof Error ? error.message : 'Команда сервера не выполнена' } } if (this.controller.state.scene.mode === 6 && Date.now() - this.lastResults >= 5000) void this.refreshResults().catch((error) => { this.state.error = error instanceof Error ? error.message : 'Не удалось обновить результаты' }) } catch (error) { if (generation !== this.generation || session !== this.controller.state.session) return this.previous = null if (error instanceof ApiError && error.status === 401) { this.detach() this.state.connection = 'expired' await this.persistSecrets().catch(() => {}) } else this.state.connection = 'offline' this.state.error = error instanceof Error ? error.message : 'Не удалось получить состояние экрана' } finally { this.pollBusy = false } } async musicEnded(report: Report) { const m = this.previous?.music const c = this.controller.state.channels.music if ( !this.screen || !m || this.advanceBusy || this.state.connection !== 'connected' || report.channel !== 'music' || report.status !== 'ended' || report.session !== this.controller.state.session || report.operation !== c.operation || c.desired !== 'playing' || c.playlistId !== m.playlistId || c.source?.id !== `track-${m.playlistId}-${m.trackId}` || m.playback !== 2 ) return this.advanceBusy = true const generation = this.generation try { const result = await this.api( '/api/screens/music/advance', { screenID: this.screen.id, accessToken: this.screen.token }, false, AbortSignal.timeout(5000), ) if (generation === this.generation && this.previous?.music?.version === m.version) { const music = parseRemote({ music: result.state }).music if (music) this.previous = { ...this.previous, music } } } catch { if (generation === this.generation) { this.previous = null this.state.connection = 'offline' this.state.error = 'Переход музыки выполнен локально; сервер не подтвердил обновление' } } finally { this.advanceBusy = false } } close() { this.cancellation?.abort() this.packages.cancel() this.detach() } }