up
This commit is contained in:
@@ -5,6 +5,8 @@ import { join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { Controller } from './controller.js'
|
||||
import { parseRange } from './range.js'
|
||||
import { PackageStore } from './package-store.js'
|
||||
import { packageMediaResponse } from './package-media.js'
|
||||
import type { HostInfo } from '../src/types/screen.js'
|
||||
|
||||
// Native Wayland запрещает размещение окна на выбранном дисплее по координатам.
|
||||
@@ -87,9 +89,19 @@ function show(displayId: number) {
|
||||
})
|
||||
void output.loadURL('screen-app://app/index.html?output=1')
|
||||
}
|
||||
// Две копии приложения не должны одновременно менять указатели пакетов.
|
||||
if (!app.requestSingleInstanceLock()) app.exit(0)
|
||||
app.on('second-instance', () => {
|
||||
control?.show()
|
||||
control?.focus()
|
||||
})
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(async () => {
|
||||
const packages = new PackageStore(join(app.getPath('userData'), 'packages'))
|
||||
await packages.initialize()
|
||||
app.on('before-quit', () => packages.cancel())
|
||||
session.defaultSession.setPermissionRequestHandler((_contents, _permission, callback) =>
|
||||
callback(false),
|
||||
)
|
||||
@@ -102,6 +114,7 @@ app
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
}
|
||||
if (url.host !== 'app' || request.method !== 'GET') return new Response(null, { status: 404 })
|
||||
if (url.pathname.startsWith('/packages/')) return packageMediaResponse(packages, request)
|
||||
if (url.pathname.startsWith('/media/')) {
|
||||
const media =
|
||||
url.pathname === '/media/audio'
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { Readable } from 'node:stream'
|
||||
import type { PackageStore } from './package-store.js'
|
||||
import { parseRange } from './range.js'
|
||||
|
||||
export async function packageMediaResponse(
|
||||
store: PackageStore,
|
||||
request: Request,
|
||||
): Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
const match = /^\/packages\/([a-f0-9]{64})\/([a-f0-9]{64})$/.exec(url.pathname)
|
||||
if (url.host !== 'app' || request.method !== 'GET' || !match)
|
||||
return new Response(null, { status: 404 })
|
||||
try {
|
||||
const file = await store.localFile(match[1]!, match[2]!)
|
||||
const rangeHeader = request.headers.get('range')
|
||||
const range = parseRange(rangeHeader, file.size)
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': file.mime,
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Cache-Control': 'no-store',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Content-Security-Policy': "default-src 'none'; frame-ancestors 'none'; sandbox",
|
||||
}
|
||||
if (!range)
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...headers, 'Content-Range': `bytes */${file.size}` },
|
||||
})
|
||||
headers['Content-Length'] = String(range.end - range.start + 1)
|
||||
if (rangeHeader) headers['Content-Range'] = `bytes ${range.start}-${range.end}/${file.size}`
|
||||
const stream = createReadStream(file.path, range)
|
||||
const abort = () => stream.destroy()
|
||||
request.signal.addEventListener('abort', abort, { once: true })
|
||||
stream.on('close', () => request.signal.removeEventListener('abort', abort))
|
||||
if (request.signal.aborted) stream.destroy()
|
||||
return new Response(Readable.toWeb(stream) as ReadableStream<Uint8Array>, {
|
||||
status: rangeHeader ? 206 : 200,
|
||||
headers,
|
||||
})
|
||||
} catch {
|
||||
return new Response(null, { status: 404 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createServer } from 'node:http'
|
||||
import { mkdtemp, readFile, readdir, rm, mkdir, symlink, writeFile } from 'node:fs/promises'
|
||||
import { readdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, type TestContext } from 'node:test'
|
||||
import { buildScreenPackage, parseScreenPackage } from './screen-package.js'
|
||||
import { PackageStore, packageKey, type DownloadProgress } from './package-store.js'
|
||||
import { packageMediaResponse } from './package-media.js'
|
||||
|
||||
const highSpace = 1n << 40n
|
||||
const body = Buffer.from('RIFFtest-data-WAVE')
|
||||
function description(server = 'https://media.test') {
|
||||
return buildScreenPackage(server, 7, {
|
||||
game: {
|
||||
game: {
|
||||
ID: 7,
|
||||
name: 'Test',
|
||||
scenario: {
|
||||
image: `${server}/cover.png`,
|
||||
story: { introduction: { audio: `${server}/intro.wav` } },
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {},
|
||||
music: {},
|
||||
})
|
||||
}
|
||||
function mediaResponse(bytes: Uint8Array = body, mime = 'audio/wav') {
|
||||
return new Response(Buffer.from(bytes), {
|
||||
headers: { 'Content-Type': mime, 'Content-Length': String(bytes.length) },
|
||||
})
|
||||
}
|
||||
async function fixture(t: TestContext) {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'screen-store-'))
|
||||
t.after(() => rm(dir, { recursive: true, force: true }))
|
||||
const root = join(dir, 'packages')
|
||||
const state: {
|
||||
bytes: Buffer
|
||||
free: bigint
|
||||
handler?: (url: string, init?: RequestInit) => Promise<Response>
|
||||
} = { bytes: body, free: highSpace }
|
||||
const calls: string[] = []
|
||||
const delays: number[] = []
|
||||
const dependencies = {
|
||||
fetch: (async (input, init) => {
|
||||
calls.push(String(input))
|
||||
assert.equal(new Headers(init?.headers).get('Authorization'), null)
|
||||
assert.equal(init?.credentials, 'omit')
|
||||
assert.equal(init?.redirect, 'manual')
|
||||
return state.handler ? state.handler(String(input), init) : mediaResponse(state.bytes)
|
||||
}) as typeof fetch,
|
||||
freeBytes: async () => state.free,
|
||||
wait: async (milliseconds: number, signal: AbortSignal) => {
|
||||
signal.throwIfAborted()
|
||||
delays.push(milliseconds)
|
||||
},
|
||||
}
|
||||
const store = new PackageStore(root, dependencies)
|
||||
await store.initialize()
|
||||
const pkg = description()
|
||||
const key = packageKey(pkg.server, pkg.game.id)
|
||||
return { root, dir, state, calls, delays, dependencies, store, pkg, key }
|
||||
}
|
||||
|
||||
test('загружает, проверяет с диска, восстанавливает после перезапуска и выдаёт офлайн Range', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const progress: DownloadProgress[] = []
|
||||
const downloaded = await f.store.download(f.pkg, { onProgress: (value) => progress.push(value) })
|
||||
assert.equal(downloaded.files.length, 2)
|
||||
assert.equal(downloaded.files[0]!.sha256, createHash('sha256').update(body).digest('hex'))
|
||||
assert.deepEqual(progress.at(-1), {
|
||||
stage: 'ready',
|
||||
assetId: null,
|
||||
downloadedBytes: body.length * 2,
|
||||
completedFiles: 2,
|
||||
totalFiles: 2,
|
||||
totalBytes: body.length * 2,
|
||||
})
|
||||
assert.ok(progress.slice(0, -1).every((p) => p.totalBytes === null))
|
||||
f.state.handler = async () => {
|
||||
throw new Error('Network disabled')
|
||||
}
|
||||
const restored = new PackageStore(f.root, f.dependencies)
|
||||
await restored.initialize()
|
||||
assert.deepEqual(restored.list(), [downloaded])
|
||||
for (const asset of f.pkg.assets) {
|
||||
const url = `screen-app://app/packages/${f.key}/${asset.id}`
|
||||
const response = await packageMediaResponse(restored, new Request(url))
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body)
|
||||
const range = await packageMediaResponse(
|
||||
restored,
|
||||
new Request(url, { headers: { Range: 'bytes=2-5' } }),
|
||||
)
|
||||
assert.equal(range.status, 206)
|
||||
assert.equal(range.headers.get('content-range'), `bytes 2-5/${body.length}`)
|
||||
assert.deepEqual(Buffer.from(await range.arrayBuffer()), body.subarray(2, 6))
|
||||
assert.equal(
|
||||
(await packageMediaResponse(restored, new Request(url, { headers: { Range: 'bytes=999-' } })))
|
||||
.status,
|
||||
416,
|
||||
)
|
||||
}
|
||||
assert.equal(f.calls.length, 2)
|
||||
const copy = restored.list()
|
||||
copy[0]!.files.length = 0
|
||||
assert.equal(restored.list()[0]!.files.length, 2)
|
||||
})
|
||||
|
||||
test('новые байты по тому же URL меняют версию; старая версия удаляется после активации', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const first = await f.store.download(f.pkg)
|
||||
f.state.bytes = Buffer.from('different media bytes')
|
||||
const second = await f.store.download(f.pkg)
|
||||
assert.equal(first.package.metadataVersion, second.package.metadataVersion)
|
||||
assert.notEqual(first.version, second.version)
|
||||
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${second.version}`])
|
||||
assert.equal(
|
||||
await readFile(join(f.root, f.key, 'current.json'), 'utf8'),
|
||||
JSON.stringify(second.version),
|
||||
)
|
||||
assert.equal((await f.store.download(f.pkg)).version, second.version)
|
||||
})
|
||||
|
||||
test('после обрыва повторяет только текущий файл целиком', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const broken = f.pkg.assets[1]!.url
|
||||
let attempts = 0
|
||||
f.state.handler = async (url) => {
|
||||
if (url !== broken || attempts++ > 0) return mediaResponse()
|
||||
let reads = 0
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
if (reads++ === 0) controller.enqueue(body.subarray(0, 3))
|
||||
else controller.error(new Error('Connection reset'))
|
||||
},
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'audio/wav', 'Content-Length': String(body.length) } },
|
||||
)
|
||||
}
|
||||
await f.store.download(f.pkg)
|
||||
assert.equal(f.calls.filter((url) => url === f.pkg.assets[0]!.url).length, 1)
|
||||
assert.equal(f.calls.filter((url) => url === broken).length, 2)
|
||||
assert.deepEqual(f.delays, [1000])
|
||||
assert.deepEqual(await readFile((await f.store.localFile(f.key, f.pkg.assets[1]!.id)).path), body)
|
||||
})
|
||||
|
||||
test('ошибка обновления, отмена и нехватка места сохраняют прежний пакет', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const first = await f.store.download(f.pkg)
|
||||
f.state.handler = async () => {
|
||||
throw new Error('Offline')
|
||||
}
|
||||
await assert.rejects(f.store.download(f.pkg), /Обрыв/)
|
||||
assert.deepEqual(f.delays, [1000, 3000])
|
||||
assert.deepEqual(f.store.list(), [first])
|
||||
f.state.handler = undefined
|
||||
await assert.rejects(
|
||||
f.store.download(f.pkg, {
|
||||
onProgress: (p) => {
|
||||
if (p.stage === 'downloading') f.store.cancel()
|
||||
},
|
||||
}),
|
||||
/отменена/,
|
||||
)
|
||||
const signal = new AbortController()
|
||||
await assert.rejects(
|
||||
f.store.download(f.pkg, {
|
||||
signal: signal.signal,
|
||||
onProgress: (p) => {
|
||||
if (p.stage === 'activating') signal.abort()
|
||||
},
|
||||
}),
|
||||
/отменена/,
|
||||
)
|
||||
f.state.free = 0n
|
||||
await assert.rejects(f.store.download(f.pkg), /Недостаточно места/)
|
||||
f.state.free = highSpace
|
||||
await assert.rejects(
|
||||
f.store.download(f.pkg, {
|
||||
onProgress: (p) => {
|
||||
if (p.stage === 'downloading') f.state.free = 0n
|
||||
},
|
||||
}),
|
||||
/Недостаточно места/,
|
||||
)
|
||||
assert.deepEqual(f.store.list(), [first])
|
||||
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${first.version}`])
|
||||
})
|
||||
|
||||
test('не активирует файл, повреждённый между записью и повторной проверкой', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await assert.rejects(
|
||||
f.store.download(f.pkg, {
|
||||
onProgress: (p) => {
|
||||
if (p.stage !== 'verifying') return
|
||||
const stage = readdirSync(join(f.root, f.key)).find((name) => name.startsWith('staging-'))!
|
||||
writeFileSync(join(f.root, f.key, stage, 'assets', p.assetId!), Buffer.alloc(body.length))
|
||||
},
|
||||
}),
|
||||
/Ошибка проверки/,
|
||||
)
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
assert.deepEqual(await readdir(join(f.root, f.key)), [])
|
||||
})
|
||||
|
||||
test('повреждение после перезапуска и некорректный манифест исключают готовность', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const first = await f.store.download(f.pkg)
|
||||
const path = (await f.store.localFile(f.key, first.files[0]!.id)).path
|
||||
await writeFile(path, Buffer.alloc(body.length))
|
||||
await assert.rejects(f.store.verify(f.key), /Повреждён файл/)
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
const restored = new PackageStore(f.root, f.dependencies)
|
||||
await restored.initialize()
|
||||
assert.deepEqual(restored.list(), [])
|
||||
await writeFile(join(f.root, f.key, 'current.json'), JSON.stringify('../../outside'))
|
||||
await restored.initialize()
|
||||
assert.deepEqual(restored.list(), [])
|
||||
const bad = structuredClone(f.pkg)
|
||||
bad.assets[0]!.id = '../escape'
|
||||
assert.throws(() => parseScreenPackage(bad), /ресурс/)
|
||||
await assert.rejects(f.store.download(bad), /ресурс/)
|
||||
})
|
||||
|
||||
test('очищает следы сбоя до/после переключения указателя', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const ready = await f.store.download(f.pkg)
|
||||
const stage = join(f.root, f.key, `staging-${randomUUID()}`)
|
||||
const orphan = join(f.root, f.key, `v-${'a'.repeat(64)}`)
|
||||
await mkdir(stage)
|
||||
await mkdir(orphan)
|
||||
await writeFile(join(f.root, f.key, 'current.tmp'), JSON.stringify('b'.repeat(64)))
|
||||
const restored = new PackageStore(f.root, f.dependencies)
|
||||
await restored.initialize()
|
||||
assert.deepEqual(restored.list(), [ready])
|
||||
assert.deepEqual(await readdir(join(f.root, f.key)), ['current.json', `v-${ready.version}`])
|
||||
})
|
||||
|
||||
test('симлинки и произвольные пути не позволяют читать или удалять чужие файлы', async (t) => {
|
||||
const f = await fixture(t)
|
||||
await writeFile(join(f.dir, 'outside.wav'), body)
|
||||
const ready = await f.store.download(f.pkg)
|
||||
const path = (await f.store.localFile(f.key, ready.files[0]!.id)).path
|
||||
await rm(path)
|
||||
await symlink(join(f.dir, 'outside.wav'), path)
|
||||
await assert.rejects(f.store.verify(f.key), /Симлинк/)
|
||||
await f.store.remove(f.key)
|
||||
assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body)
|
||||
await symlink(f.dir, join(f.root, f.key), 'dir')
|
||||
await assert.rejects(f.store.remove(f.key), /Симлинк/)
|
||||
await assert.rejects(f.store.remove('../'), /идентификатор/)
|
||||
assert.deepEqual(await readFile(join(f.dir, 'outside.wav')), body)
|
||||
assert.equal(
|
||||
(await packageMediaResponse(f.store, new Request(`screen-app://app/packages/${f.key}/bad`)))
|
||||
.status,
|
||||
404,
|
||||
)
|
||||
})
|
||||
|
||||
test('редиректы не отправляют JWT; опасные адреса и ошибочные ответы отклоняются', async (t) => {
|
||||
const f = await fixture(t)
|
||||
f.state.handler = async (url) =>
|
||||
url.startsWith('https://cdn.test')
|
||||
? mediaResponse()
|
||||
: new Response(null, { status: 302, headers: { Location: 'https://cdn.test/video.wav' } })
|
||||
await f.store.download(f.pkg)
|
||||
assert.ok(f.calls.some((url) => url.startsWith('https://cdn.test')))
|
||||
for (const location of [
|
||||
'http://cdn.test/file.wav',
|
||||
'file:///etc/passwd',
|
||||
'https://user:password@cdn.test/file.wav',
|
||||
'https://cdn.test/file.wav?token=SECRET',
|
||||
]) {
|
||||
f.state.handler = async () =>
|
||||
new Response(null, { status: 302, headers: { Location: location } })
|
||||
await assert.rejects(f.store.download(f.pkg), /редирект|адрес/)
|
||||
}
|
||||
for (const status of [401, 403, 404, 206]) {
|
||||
f.state.handler = async () => new Response(null, { status })
|
||||
const before = f.calls.length
|
||||
await assert.rejects(f.store.download(f.pkg), /HTTP/)
|
||||
assert.equal(f.calls.length - before, 1)
|
||||
}
|
||||
f.state.handler = async () => new Response('HTML', { headers: { 'Content-Type': 'text/html' } })
|
||||
await assert.rejects(f.store.download(f.pkg), /тип медиа/)
|
||||
f.state.handler = async () =>
|
||||
new Response('short', { headers: { 'Content-Type': 'audio/wav', 'Content-Length': '100' } })
|
||||
await assert.rejects(f.store.download(f.pkg), /не полностью/)
|
||||
f.state.handler = async () => new Response(null, { status: 302, headers: { Location: '/loop' } })
|
||||
await assert.rejects(f.store.download(f.pkg), /редирект/)
|
||||
})
|
||||
|
||||
test('изменения хранилища сериализованы, отмена доступна во время загрузки', async (t) => {
|
||||
const f = await fixture(t)
|
||||
let started!: () => void
|
||||
const loading = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
f.state.handler = async (_url, init) => {
|
||||
started()
|
||||
return new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true })
|
||||
})
|
||||
}
|
||||
const job = f.store.download(f.pkg)
|
||||
await loading
|
||||
await assert.rejects(f.store.remove(f.key), /занято/)
|
||||
await assert.rejects(f.store.download(f.pkg), /занято/)
|
||||
f.store.cancel()
|
||||
await assert.rejects(job, /отменена/)
|
||||
})
|
||||
|
||||
test('реальный HTTP: отключение сервера не мешает восстановить и читать пакет', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const server = createServer((_req, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'audio/wav', 'Content-Length': body.length })
|
||||
response.end(body)
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
t.after(() => server.close())
|
||||
const address = server.address()
|
||||
assert.ok(address && typeof address !== 'string')
|
||||
const pkg = description(`http://127.0.0.1:${address.port}`)
|
||||
const store = new PackageStore(f.root)
|
||||
await store.initialize()
|
||||
const downloaded = await store.download(pkg)
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
)
|
||||
const restored = new PackageStore(f.root)
|
||||
await restored.initialize()
|
||||
for (const file of downloaded.files) {
|
||||
const response = await packageMediaResponse(
|
||||
restored,
|
||||
new Request(`screen-app://app/packages/${downloaded.key}/${file.id}`),
|
||||
)
|
||||
assert.equal(response.status, 200)
|
||||
assert.deepEqual(Buffer.from(await response.arrayBuffer()), body)
|
||||
}
|
||||
})
|
||||
|
||||
test('принимает MIME WAV/OGG существующего бэкенда и неизвестный общий размер', async (t) => {
|
||||
const f = await fixture(t)
|
||||
for (const mime of ['audio/wave', 'audio/x-wav', 'application/ogg']) {
|
||||
f.state.handler = async () => new Response(body, { headers: { 'Content-Type': mime } })
|
||||
const pkg = await f.store.download(f.pkg)
|
||||
assert.ok(
|
||||
pkg.files.every(
|
||||
(file) => file.mime === (mime === 'application/ogg' ? 'audio/ogg' : 'audio/wav'),
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('повторная загрузка восстанавливает повреждённую копию той же версии', async (t) => {
|
||||
const f = await fixture(t)
|
||||
const original = await f.store.download(f.pkg)
|
||||
await writeFile(
|
||||
(await f.store.localFile(f.key, original.files[0]!.id)).path,
|
||||
Buffer.alloc(body.length),
|
||||
)
|
||||
const repaired = await f.store.download(f.pkg)
|
||||
assert.equal(repaired.version, original.version)
|
||||
assert.deepEqual(await f.store.verify(f.key), original)
|
||||
})
|
||||
|
||||
test('превышение времени бездействия повторяет запрос трижды и оставляет пакет неготовым', async (t) => {
|
||||
const f = await fixture(t)
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] })
|
||||
let started!: () => void
|
||||
let start = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
f.state.handler = async (_url, init) => {
|
||||
started()
|
||||
return new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('timeout')), { once: true })
|
||||
})
|
||||
}
|
||||
const job = f.store.download(f.pkg)
|
||||
const rejected = assert.rejects(job, /таймаут/)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await start
|
||||
start = new Promise<void>((resolve) => {
|
||||
started = resolve
|
||||
})
|
||||
t.mock.timers.tick(30_001)
|
||||
}
|
||||
await rejected
|
||||
assert.equal(f.calls.length, 3)
|
||||
assert.deepEqual(f.store.list(), [])
|
||||
})
|
||||
@@ -0,0 +1,513 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { createReadStream } from 'node:fs'
|
||||
import { lstat, mkdir, open, readFile, readdir, rename, rm, statfs } from 'node:fs/promises'
|
||||
import { extname, join, resolve } from 'node:path'
|
||||
import { setTimeout as wait } from 'node:timers/promises'
|
||||
import { parseScreenPackage, type ScreenPackage } from './screen-package.js'
|
||||
|
||||
const digest = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||
const hex = /^[a-f0-9]{64}$/
|
||||
const reserve = 64n * 1024n * 1024n
|
||||
const mimeAliases: Record<string, string> = {
|
||||
'audio/wave': 'audio/wav',
|
||||
'audio/x-wav': 'audio/wav',
|
||||
'application/ogg': 'audio/ogg',
|
||||
'audio/mp3': 'audio/mpeg',
|
||||
'image/jpg': 'image/jpeg',
|
||||
}
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.ogg': 'audio/ogg',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
}
|
||||
export interface StoredFile {
|
||||
id: string
|
||||
size: number
|
||||
sha256: string
|
||||
mime: string
|
||||
}
|
||||
export interface LocalPackage {
|
||||
formatVersion: 1
|
||||
key: string
|
||||
version: string
|
||||
package: ScreenPackage
|
||||
files: StoredFile[]
|
||||
}
|
||||
export interface DownloadProgress {
|
||||
stage: 'downloading' | 'verifying' | 'activating' | 'ready'
|
||||
assetId: string | null
|
||||
downloadedBytes: number
|
||||
completedFiles: number
|
||||
totalFiles: number
|
||||
totalBytes: number | null
|
||||
}
|
||||
interface Dependencies {
|
||||
fetch?: typeof fetch
|
||||
freeBytes?: () => Promise<bigint>
|
||||
wait?: (milliseconds: number, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
class RetryableError extends Error {}
|
||||
export function packageKey(server: string, gameId: number): string {
|
||||
return digest(JSON.stringify([server, gameId]))
|
||||
}
|
||||
function versionOf(pkg: ScreenPackage, files: StoredFile[]): string {
|
||||
return digest(
|
||||
JSON.stringify([
|
||||
pkg.metadataVersion,
|
||||
files.map(({ id, size, sha256, mime }) => ({ id, size, sha256, mime })),
|
||||
]),
|
||||
)
|
||||
}
|
||||
function checkHex(value: unknown): asserts value is string {
|
||||
if (typeof value !== 'string' || !hex.test(value))
|
||||
throw new Error('Некорректный идентификатор пакета или файла')
|
||||
}
|
||||
async function checksum(path: string) {
|
||||
const hash = createHash('sha256')
|
||||
let size = 0
|
||||
for await (const chunk of createReadStream(path)) {
|
||||
hash.update(chunk)
|
||||
size += chunk.length
|
||||
}
|
||||
return { size, sha256: hash.digest('hex') }
|
||||
}
|
||||
|
||||
export class PackageStore {
|
||||
private readonly root: string
|
||||
private busy = false
|
||||
private cancellation: AbortController | null = null
|
||||
private readonly ready = new Map<string, LocalPackage>()
|
||||
constructor(
|
||||
root: string,
|
||||
private readonly dependencies: Dependencies = {},
|
||||
) {
|
||||
this.root = resolve(root)
|
||||
}
|
||||
|
||||
// Все сегменты создаёт сервис; существующие симлинки никогда не обходятся.
|
||||
private async path(...parts: string[]) {
|
||||
let path = this.root
|
||||
for (const part of ['', ...parts]) {
|
||||
if (part) path = join(path, part)
|
||||
try {
|
||||
const info = await lstat(path)
|
||||
if (info.isSymbolicLink()) throw new Error('Симлинк в хранилище пакетов')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
private async exclusive<T>(action: () => Promise<T>): Promise<T> {
|
||||
if (this.busy) throw new Error('Хранилище пакетов занято')
|
||||
this.busy = true
|
||||
try {
|
||||
return await action()
|
||||
} finally {
|
||||
this.busy = false
|
||||
}
|
||||
}
|
||||
private async syncDirectory(path: string) {
|
||||
// Windows не поддерживает fsync каталога; файлы синхронизируются на всех ОС.
|
||||
if (process.platform === 'win32') return
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
private async writeJSON(path: string, value: unknown) {
|
||||
const handle = await open(path, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(JSON.stringify(value))
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
private async cleanup(key: string, keep?: string) {
|
||||
const dir = await this.path(key)
|
||||
for (const name of await readdir(dir)) {
|
||||
if (
|
||||
(/^staging-[a-f0-9-]{36}$/.test(name) ||
|
||||
/^v-[a-f0-9]{64}$/.test(name) ||
|
||||
name === 'current.tmp') &&
|
||||
name !== keep
|
||||
) {
|
||||
// rm удаляет сам симлинк, не его цель; содержимое по ссылке не читается.
|
||||
await rm(join(dir, name), { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
async initialize(): Promise<void> {
|
||||
await this.exclusive(async () => {
|
||||
await this.path()
|
||||
await mkdir(this.root, { recursive: true })
|
||||
await this.path()
|
||||
this.ready.clear()
|
||||
for (const key of await readdir(this.root)) {
|
||||
if (!hex.test(key)) continue
|
||||
try {
|
||||
await this.path(key)
|
||||
const manifest = await this.load(key)
|
||||
this.ready.set(key, manifest)
|
||||
await this.cleanup(key, `v-${manifest.version}`)
|
||||
} catch {
|
||||
// Повреждённый пакет остаётся для явного удаления, но не становится готовым.
|
||||
try {
|
||||
let keep: string | undefined
|
||||
try {
|
||||
const pointer: unknown = JSON.parse(
|
||||
await readFile(await this.path(key, 'current.json'), 'utf8'),
|
||||
)
|
||||
checkHex(pointer)
|
||||
keep = `v-${pointer}`
|
||||
} catch {
|
||||
/* Без корректного указателя готовой версии нет. */
|
||||
}
|
||||
await this.cleanup(key, keep)
|
||||
} catch {
|
||||
/* Чужие пути не обходим. */
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
private async load(key: string): Promise<LocalPackage> {
|
||||
checkHex(key)
|
||||
const pointer: unknown = JSON.parse(
|
||||
await readFile(await this.path(key, 'current.json'), 'utf8'),
|
||||
)
|
||||
checkHex(pointer)
|
||||
return this.verifyDirectory(key, pointer)
|
||||
}
|
||||
private async verifyDirectory(key: string, version: string): Promise<LocalPackage> {
|
||||
const raw = JSON.parse(
|
||||
await readFile(await this.path(key, `v-${version}`, 'manifest.json'), 'utf8'),
|
||||
)
|
||||
if (
|
||||
raw?.formatVersion !== 1 ||
|
||||
raw.key !== key ||
|
||||
raw.version !== version ||
|
||||
!Array.isArray(raw.files)
|
||||
)
|
||||
throw new Error('Повреждён манифест пакета')
|
||||
const pkg = parseScreenPackage(raw.package)
|
||||
if (packageKey(pkg.server, pkg.game.id) !== key || raw.files.length !== pkg.assets.length)
|
||||
throw new Error('Неполный пакет')
|
||||
const files: StoredFile[] = []
|
||||
for (let i = 0; i < pkg.assets.length; i++) {
|
||||
const file = raw.files[i] as StoredFile
|
||||
if (
|
||||
!file ||
|
||||
file.id !== pkg.assets[i]!.id ||
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size <= 0 ||
|
||||
!hex.test(file.sha256) ||
|
||||
!Object.values(mimeTypes).includes(file.mime)
|
||||
)
|
||||
throw new Error('Повреждён список файлов')
|
||||
const path = await this.path(key, `v-${version}`, 'assets', file.id)
|
||||
if (!(await lstat(path)).isFile()) throw new Error('Ресурс не является файлом')
|
||||
const actual = await checksum(path)
|
||||
if (actual.size !== file.size || actual.sha256 !== file.sha256)
|
||||
throw new Error('Повреждён файл пакета')
|
||||
files.push({ id: file.id, ...actual, mime: file.mime })
|
||||
}
|
||||
if (versionOf(pkg, files) !== version) throw new Error('Повреждена версия пакета')
|
||||
return { formatVersion: 1, key, version, package: pkg, files }
|
||||
}
|
||||
list(): LocalPackage[] {
|
||||
return structuredClone([...this.ready.values()])
|
||||
}
|
||||
cancel(): void {
|
||||
this.cancellation?.abort()
|
||||
}
|
||||
async verify(key: string): Promise<LocalPackage> {
|
||||
return this.exclusive(async () => {
|
||||
try {
|
||||
const pkg = await this.load(key)
|
||||
this.ready.set(key, pkg)
|
||||
return structuredClone(pkg)
|
||||
} catch (error) {
|
||||
this.ready.delete(key)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
}
|
||||
async remove(key: string): Promise<void> {
|
||||
checkHex(key)
|
||||
await this.exclusive(async () => {
|
||||
const dir = await this.path(key)
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
this.ready.delete(key)
|
||||
})
|
||||
}
|
||||
async localFile(key: string, assetId: string) {
|
||||
checkHex(key)
|
||||
checkHex(assetId)
|
||||
const pkg = this.ready.get(key)
|
||||
const file = pkg?.files.find((file) => file.id === assetId)
|
||||
if (!pkg || !file) throw new Error('Готовый ресурс не найден')
|
||||
try {
|
||||
const path = await this.path(key, `v-${pkg.version}`, 'assets', assetId)
|
||||
const info = await lstat(path)
|
||||
if (!info.isFile() || info.size !== file.size) throw new Error('Повреждён файл пакета')
|
||||
return { path, size: file.size, mime: file.mime }
|
||||
} catch (error) {
|
||||
this.ready.delete(key)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
private async space(needed = 0) {
|
||||
const available = this.dependencies.freeBytes
|
||||
? await this.dependencies.freeBytes()
|
||||
: await statfs(this.root, { bigint: true }).then((s) => s.bavail * s.bsize)
|
||||
if (available < reserve + BigInt(needed))
|
||||
throw new Error('Недостаточно места для пакета (нужен резерв 64 МиБ)')
|
||||
}
|
||||
|
||||
async download(
|
||||
value: ScreenPackage,
|
||||
options: { signal?: AbortSignal; onProgress?: (progress: DownloadProgress) => void } = {},
|
||||
): Promise<LocalPackage> {
|
||||
return this.exclusive(async () => {
|
||||
const pkg = parseScreenPackage(value)
|
||||
const key = packageKey(pkg.server, pkg.game.id)
|
||||
this.cancellation = new AbortController()
|
||||
const signal = AbortSignal.any([
|
||||
this.cancellation.signal,
|
||||
...(options.signal ? [options.signal] : []),
|
||||
])
|
||||
const stage = `staging-${randomUUID()}`
|
||||
let staged = ''
|
||||
let published = ''
|
||||
let activated = false
|
||||
const files: StoredFile[] = []
|
||||
let completedBytes = 0
|
||||
const report = (stage: DownloadProgress['stage'], assetId: string | null, bytes = 0) => {
|
||||
try {
|
||||
options.onProgress?.({
|
||||
stage,
|
||||
assetId,
|
||||
downloadedBytes: completedBytes + bytes,
|
||||
completedFiles: files.length,
|
||||
totalFiles: pkg.assets.length,
|
||||
totalBytes: stage === 'ready' ? completedBytes : null,
|
||||
})
|
||||
} catch {
|
||||
/* Наблюдатель не должен прерывать изменение хранилища. */
|
||||
}
|
||||
}
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
await this.path()
|
||||
await mkdir(this.root, { recursive: true })
|
||||
const dir = await this.path(key)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await this.space()
|
||||
staged = await this.path(key, stage)
|
||||
await mkdir(join(staged, 'assets'), { recursive: true })
|
||||
for (const asset of pkg.assets) {
|
||||
let file: StoredFile | undefined
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
signal.throwIfAborted()
|
||||
const path = join(staged, 'assets', asset.id)
|
||||
try {
|
||||
file = await this.fetchFile(asset, path, signal, (bytes) =>
|
||||
report('downloading', asset.id, bytes),
|
||||
)
|
||||
break
|
||||
} catch (error) {
|
||||
await rm(path, { force: true })
|
||||
signal.throwIfAborted()
|
||||
if (!(error instanceof RetryableError) || attempt === 2) throw error
|
||||
const delay = attempt === 0 ? 1000 : 3000
|
||||
if (this.dependencies.wait) await this.dependencies.wait(delay, signal)
|
||||
else await wait(delay, undefined, { signal })
|
||||
}
|
||||
}
|
||||
if (!file) throw new Error('Не удалось скачать файл')
|
||||
report('verifying', asset.id, file.size)
|
||||
const actual = await checksum(join(staged, 'assets', asset.id))
|
||||
signal.throwIfAborted()
|
||||
if (actual.size !== file.size || actual.sha256 !== file.sha256)
|
||||
throw new Error('Ошибка проверки скачанного файла')
|
||||
files.push(file)
|
||||
completedBytes += file.size
|
||||
}
|
||||
const version = versionOf(pkg, files)
|
||||
const manifest: LocalPackage = { formatVersion: 1, key, version, package: pkg, files }
|
||||
await this.writeJSON(join(staged, 'manifest.json'), manifest)
|
||||
await this.syncDirectory(join(staged, 'assets'))
|
||||
await this.syncDirectory(staged)
|
||||
report('activating', null)
|
||||
signal.throwIfAborted()
|
||||
const target = await this.path(key, `v-${version}`)
|
||||
let exists = false
|
||||
try {
|
||||
await lstat(target)
|
||||
exists = true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (exists) {
|
||||
try {
|
||||
await this.verifyDirectory(key, version)
|
||||
} catch {
|
||||
// Повторная загрузка восстанавливает уже повреждённую локальную копию.
|
||||
if (this.ready.get(key)?.version === version) this.ready.delete(key)
|
||||
await rm(target, { recursive: true, force: true })
|
||||
exists = false
|
||||
}
|
||||
}
|
||||
if (!exists) {
|
||||
await rename(staged, target)
|
||||
staged = ''
|
||||
published = target
|
||||
}
|
||||
await this.syncDirectory(dir)
|
||||
const pointer = await this.path(key, 'current.tmp')
|
||||
await rm(pointer, { force: true })
|
||||
await this.writeJSON(pointer, version)
|
||||
signal.throwIfAborted()
|
||||
await rename(pointer, await this.path(key, 'current.json'))
|
||||
activated = true
|
||||
this.ready.set(key, manifest)
|
||||
await this.syncDirectory(dir).catch(() => {})
|
||||
// После переключения указателя сбой очистки не отменяет успешную активацию.
|
||||
await this.cleanup(key, `v-${version}`).catch(() => {})
|
||||
report('ready', null)
|
||||
return structuredClone(manifest)
|
||||
} catch (error) {
|
||||
if (signal.aborted && !activated) throw new Error('Загрузка отменена')
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOSPC')
|
||||
throw new Error('Недостаточно места для пакета')
|
||||
throw error
|
||||
} finally {
|
||||
this.cancellation = null
|
||||
if (staged) await rm(staged, { recursive: true, force: true }).catch(() => {})
|
||||
if (published && !activated)
|
||||
await rm(published, { recursive: true, force: true }).catch(() => {})
|
||||
if (!activated)
|
||||
await rm(await this.path(key, 'current.tmp'), { force: true }).catch(() => {})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async fetchFile(
|
||||
asset: ScreenPackage['assets'][number],
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
progress: (bytes: number) => void,
|
||||
): Promise<StoredFile> {
|
||||
const idle = new AbortController()
|
||||
const combined = AbortSignal.any([signal, idle.signal])
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
const reset = () => {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => idle.abort(), 30_000)
|
||||
}
|
||||
reset()
|
||||
const network = async <T>(operation: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await operation()
|
||||
} catch {
|
||||
signal.throwIfAborted()
|
||||
throw new RetryableError('Обрыв соединения или таймаут загрузки')
|
||||
}
|
||||
}
|
||||
let response: Response | undefined
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined
|
||||
try {
|
||||
let url = new URL(asset.url)
|
||||
for (let redirects = 0; ; redirects++) {
|
||||
if (
|
||||
!['http:', 'https:'].includes(url.protocol) ||
|
||||
url.username ||
|
||||
url.password ||
|
||||
url.hash ||
|
||||
[...url.searchParams.keys()].some((key) =>
|
||||
/token|signature|credential|password|authorization/i.test(key),
|
||||
)
|
||||
)
|
||||
throw new Error('Недопустимый адрес медиа')
|
||||
response = await network(() =>
|
||||
(this.dependencies.fetch ?? fetch)(url.href, {
|
||||
redirect: 'manual',
|
||||
credentials: 'omit',
|
||||
headers: { 'Accept-Encoding': 'identity' },
|
||||
signal: combined,
|
||||
}),
|
||||
)
|
||||
reset()
|
||||
if (![301, 302, 303, 307, 308].includes(response.status)) break
|
||||
await response.body?.cancel()
|
||||
const location = response.headers.get('location')
|
||||
if (!location || redirects === 5) throw new Error('Недопустимый редирект медиа')
|
||||
const next = new URL(location, url)
|
||||
if (url.protocol === 'https:' && next.protocol !== 'https:')
|
||||
throw new Error('Небезопасный редирект медиа')
|
||||
url = next
|
||||
}
|
||||
if ([408, 429].includes(response.status) || response.status >= 500)
|
||||
throw new RetryableError(`Ошибка загрузки: HTTP ${response.status}`)
|
||||
if (response.status !== 200 || !response.body)
|
||||
throw new Error(`Медиа недоступно: HTTP ${response.status}`)
|
||||
const encoding = response.headers.get('content-encoding')
|
||||
if (encoding && encoding !== 'identity') throw new Error('Неожиданное сжатие медиа')
|
||||
const header = response.headers.get('content-length')
|
||||
const length = header === null ? null : Number(header)
|
||||
if (length !== null && (!Number.isSafeInteger(length) || length <= 0))
|
||||
throw new Error('Некорректный размер медиа')
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase()
|
||||
const mime =
|
||||
contentType && contentType !== 'application/octet-stream'
|
||||
? (mimeAliases[contentType] ?? contentType)
|
||||
: mimeTypes[extname(url.pathname).toLowerCase()]
|
||||
if (!mime || !Object.values(mimeTypes).includes(mime))
|
||||
throw new Error('Неподдерживаемый тип медиа')
|
||||
await this.space(length ?? 0)
|
||||
const file = await open(path, 'wx', 0o600)
|
||||
let size = 0
|
||||
const hash = createHash('sha256')
|
||||
try {
|
||||
reader = response.body.getReader()
|
||||
for (;;) {
|
||||
const chunk = await network(() => reader!.read())
|
||||
signal.throwIfAborted()
|
||||
if (chunk.done) break
|
||||
reset()
|
||||
if (
|
||||
!Number.isSafeInteger(size + chunk.value.length) ||
|
||||
(length !== null && size + chunk.value.length > length)
|
||||
)
|
||||
throw new RetryableError('Размер медиа не совпадает с ответом сервера')
|
||||
await this.space(chunk.value.length)
|
||||
await file.writeFile(chunk.value)
|
||||
hash.update(chunk.value)
|
||||
size += chunk.value.length
|
||||
progress(size)
|
||||
}
|
||||
if (!size || (length !== null && size !== length))
|
||||
throw new RetryableError('Файл загружен не полностью')
|
||||
await file.sync()
|
||||
} finally {
|
||||
await file.close()
|
||||
}
|
||||
return { id: asset.id, size, sha256: hash.digest('hex'), mime }
|
||||
} finally {
|
||||
clearTimeout(timer!)
|
||||
if (reader) await reader.cancel().catch(() => {})
|
||||
else await response?.body?.cancel().catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { buildScreenPackage, prepareScreenPackage } from './screen-package.js'
|
||||
|
||||
const server = 'https://example.test'
|
||||
function fixture() {
|
||||
const playlist = (ID: number, filename: string) => ({
|
||||
ID,
|
||||
name: `Playlist ${ID}`,
|
||||
tracks: [{ ID: ID * 10, name: 'Track', filename, position: 0 }],
|
||||
})
|
||||
return {
|
||||
game: {
|
||||
game: {
|
||||
ID: 7,
|
||||
name: 'Игра',
|
||||
teams: [{ password: 'PRIVATE' }],
|
||||
scenario: {
|
||||
image: 'cover.png',
|
||||
music_playlist_id: 1,
|
||||
story: {
|
||||
background: '',
|
||||
introduction: { audio: 'intro.mp3' },
|
||||
conclusion: { video: 'https://media.test/final.mp4' },
|
||||
places: [{ image: 'PRIVATE' }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plan: {
|
||||
plan: {
|
||||
currentStep: 1,
|
||||
steps: [
|
||||
{ mode: 'SCREEN_MODE_COVER' },
|
||||
{ mode: 'SCREEN_MODE_TEXT', text: 'Привет' },
|
||||
{ mode: 'SCREEN_MODE_TIMER', timerLabel: 'Перерыв', timerDurationSeconds: '60' },
|
||||
],
|
||||
},
|
||||
},
|
||||
music: {
|
||||
scenarioPlaylistId: 1,
|
||||
gamePlaylistId: 2,
|
||||
playlists: [
|
||||
playlist(99, 'PRIVATE'),
|
||||
playlist(2, 'intro.mp3'),
|
||||
playlist(1, `${server}/api/files/intro.mp3`),
|
||||
],
|
||||
state: {
|
||||
playlist: playlist(3, 'selected.mp3'),
|
||||
positionMs: '1000',
|
||||
playback: 'MUSIC_PLAYBACK_PLAYING',
|
||||
stateVersion: '4',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const build = (data = fixture()) => buildScreenPackage(server, 7, data)
|
||||
|
||||
test('пакет содержит только материалы показа и связанные плейлисты', () => {
|
||||
const result = build()
|
||||
assert.equal(result.formatVersion, 1)
|
||||
assert.equal(result.media.background, result.media.cover)
|
||||
assert.deepEqual(
|
||||
result.playlists.map((p) => p.id),
|
||||
[1, 2, 3],
|
||||
)
|
||||
assert.equal(result.assets.length, 4)
|
||||
assert.equal(result.playlists[0]!.tracks[0]!.assetId, result.playlists[1]!.tracks[0]!.assetId)
|
||||
assert.equal(result.steps[2]!.timerDurationSeconds, 60)
|
||||
assert.deepEqual(
|
||||
result.steps.map((s) => s.mode),
|
||||
[1, 8, 7],
|
||||
)
|
||||
for (const excluded of [
|
||||
'PRIVATE',
|
||||
'currentStep',
|
||||
'positionMs',
|
||||
'playback',
|
||||
'stateVersion',
|
||||
'token',
|
||||
'password',
|
||||
'teams',
|
||||
])
|
||||
assert.ok(!JSON.stringify(result).includes(excluded))
|
||||
assert.match(result.metadataVersion, /^[a-f0-9]{64}$/)
|
||||
})
|
||||
|
||||
test('отпечаток стабилен, зависит от метаданных, исключает живые команды', () => {
|
||||
const original = build().metadataVersion
|
||||
const data = fixture()
|
||||
data.music.playlists.reverse()
|
||||
data.music.state.positionMs = '5000'
|
||||
data.plan.plan.currentStep = 2
|
||||
assert.equal(build(data).metadataVersion, original)
|
||||
data.plan.plan.steps[1]!.text = 'Новый текст'
|
||||
assert.notEqual(build(data).metadataVersion, original)
|
||||
const other = fixture()
|
||||
other.game.game.scenario.story.conclusion.video = 'https://media.test/new.mp4'
|
||||
assert.notEqual(build(other).metadataVersion, original)
|
||||
assert.equal(buildScreenPackage(`${server}/`, 7, fixture()).metadataVersion, original)
|
||||
})
|
||||
|
||||
test('треки упорядочены по позиции и ID, текущий плейлист не обязан входить в библиотеку', () => {
|
||||
const data = fixture()
|
||||
data.music.state.playlist.tracks = [
|
||||
{ ID: 32, name: 'B', filename: 'b.mp3', position: 1 },
|
||||
{ ID: 31, name: 'A', filename: 'a.mp3', position: 0 },
|
||||
]
|
||||
assert.deepEqual(
|
||||
build(data).playlists[2]!.tracks.map((t) => t.id),
|
||||
[31, 32],
|
||||
)
|
||||
})
|
||||
|
||||
test('пустой план и необязательные материалы допустимы', () => {
|
||||
const result = buildScreenPackage(server, 7, {
|
||||
game: { game: { ID: 7, scenario: { story: {} } } },
|
||||
plan: {},
|
||||
music: {},
|
||||
})
|
||||
assert.deepEqual(result.assets, [])
|
||||
assert.deepEqual(result.steps, [])
|
||||
assert.deepEqual(result.playlists, [])
|
||||
assert.equal(result.media.intro, null)
|
||||
})
|
||||
|
||||
test('неполные или некорректные ответы не становятся пакетом', () => {
|
||||
for (const mutate of [
|
||||
(d: ReturnType<typeof fixture>) => {
|
||||
d.game.game.ID = 8
|
||||
},
|
||||
(d: ReturnType<typeof fixture>) => {
|
||||
d.music.gamePlaylistId = 100
|
||||
},
|
||||
(d: ReturnType<typeof fixture>) => {
|
||||
d.music.playlists[1]!.tracks[0]!.filename = ''
|
||||
},
|
||||
(d: ReturnType<typeof fixture>) => {
|
||||
d.plan.plan.steps[2]!.timerDurationSeconds = 'bad'
|
||||
},
|
||||
(d: ReturnType<typeof fixture>) => {
|
||||
d.plan.plan.steps[0]!.mode = 'SCREEN_MODE_UNKNOWN'
|
||||
},
|
||||
(d: ReturnType<typeof fixture>) => {
|
||||
d.plan.plan.steps[1]!.text = ''
|
||||
},
|
||||
]) {
|
||||
const data = fixture()
|
||||
mutate(data)
|
||||
assert.throws(() => build(data))
|
||||
}
|
||||
assert.throws(() => buildScreenPackage(server, 7, { game: {}, plan: {}, music: {} }))
|
||||
assert.throws(() =>
|
||||
buildScreenPackage(server, 7, { game: { game: { ID: 7, scenario: {} } }, plan: {}, music: {} }),
|
||||
)
|
||||
})
|
||||
|
||||
test('получает данные через три существующих API без обращения к внешним медиа', async () => {
|
||||
const data = fixture()
|
||||
const calls: string[] = []
|
||||
const mock: typeof fetch = async (input, init) => {
|
||||
const url = String(input)
|
||||
calls.push(url)
|
||||
assert.equal(new Headers(init?.headers).get('Authorization'), 'Bearer SECRET')
|
||||
assert.equal(init?.redirect, 'error')
|
||||
assert.equal(init?.credentials, 'omit')
|
||||
assert.ok(init?.signal)
|
||||
return Response.json(
|
||||
url.endsWith('/music') ? data.music : url.endsWith('/screen-plan') ? data.plan : data.game,
|
||||
)
|
||||
}
|
||||
const result = await prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, mock)
|
||||
assert.deepEqual(calls.sort(), [
|
||||
`${server}/api/games/7`,
|
||||
`${server}/api/games/7/music`,
|
||||
`${server}/api/games/7/screen-plan`,
|
||||
])
|
||||
assert.deepEqual(result, build(data))
|
||||
assert.ok(!JSON.stringify(result).includes('SECRET'))
|
||||
})
|
||||
|
||||
test('отказы API и некорректный JSON не выдают частичный пакет или ответ с секретом', async () => {
|
||||
for (const status of [401, 403, 404, 500]) {
|
||||
await assert.rejects(
|
||||
prepareScreenPackage(
|
||||
{ server, gameId: 7, token: 'SECRET' },
|
||||
async () => new Response('SECRET', { status }),
|
||||
),
|
||||
/авторизация|доступа|HTTP/,
|
||||
)
|
||||
}
|
||||
await assert.rejects(
|
||||
prepareScreenPackage(
|
||||
{ server, gameId: 7, token: 'SECRET' },
|
||||
async () => new Response('SECRET'),
|
||||
),
|
||||
/Некорректный JSON/,
|
||||
)
|
||||
await assert.rejects(
|
||||
prepareScreenPackage({ server, gameId: 7, token: 'SECRET' }, async () => {
|
||||
throw new Error('SECRET')
|
||||
}),
|
||||
/^Error: Сервер недоступен$/,
|
||||
)
|
||||
})
|
||||
|
||||
test('отмена и недопустимые адреса обрабатываются до выдачи пакета', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await assert.rejects(
|
||||
prepareScreenPackage(
|
||||
{ server, gameId: 7, token: 'SECRET', signal: controller.signal },
|
||||
async (_input, init) => {
|
||||
init?.signal?.throwIfAborted()
|
||||
return Response.json({})
|
||||
},
|
||||
),
|
||||
/отменена/,
|
||||
)
|
||||
for (const address of [
|
||||
'file:///etc/passwd',
|
||||
'https://user:password@example.test',
|
||||
`${server}?token=SECRET`,
|
||||
]) {
|
||||
await assert.rejects(
|
||||
prepareScreenPackage({ server: address, gameId: 7, token: 'SECRET' }),
|
||||
/Недопустимый|секрет/,
|
||||
)
|
||||
}
|
||||
const data = fixture()
|
||||
data.game.game.scenario.story.conclusion.video = 'https://media.test/video?access_token=SECRET'
|
||||
assert.throws(() => build(data), /секрет/)
|
||||
await assert.rejects(
|
||||
prepareScreenPackage({ server, gameId: 0, token: 'SECRET' }),
|
||||
/идентификатор/,
|
||||
)
|
||||
await assert.rejects(prepareScreenPackage({ server, gameId: 7, token: '' }), /токен/)
|
||||
})
|
||||
@@ -0,0 +1,336 @@
|
||||
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<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
|
||||
}
|
||||
|
||||
// Только проекция данных показа: сырые ответы 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
|
||||
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
|
||||
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] : []),
|
||||
])
|
||||
async function get(suffix: string): Promise<unknown> {
|
||||
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<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
|
||||
}
|
||||
Reference in New Issue
Block a user