Files
evening_detective_screen/electron/lan.ts
T
2026-10-17 18:14:31 +07:00

156 lines
5.9 KiB
TypeScript

import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
import { extname, resolve, sep } from 'node:path'
import { WebSocket, WebSocketServer } from 'ws'
import type { State } from '../src/types/screen.js'
const mime: Record<string, string> = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript',
'.css': 'text/css',
}
export class LanServer {
url = ''
code = String(randomInt(100000, 1000000))
pending = new Map<string, { approved: boolean; expires: number }>()
private tokens = new Set<string>()
private attempts = 0
private attemptWindow = 0
private sockets = new WebSocketServer({ noServer: true, maxPayload: 2048 })
private server = createServer((req, res) => {
void this.handle(req, res).catch(() => {
if (!res.headersSent) res.writeHead(400)
res.end()
})
})
constructor(
private root: string,
private getState: () => State,
private command: (input: unknown) => void,
) {
this.server.on('upgrade', (req, socket, head) => {
if (req.url !== '/ws' || req.headers.origin !== this.url || !this.authorized(req)) {
socket.destroy()
return
}
this.sockets.handleUpgrade(req, socket, head, (ws) => {
ws.on('error', (error) => {
console.error('Ошибка WebSocket пульта:', error.message)
ws.terminate()
})
ws.send(JSON.stringify(this.getState()))
ws.on('message', (data) => {
try {
this.command(JSON.parse(data.toString()))
} catch {
ws.close(1008, 'Некорректная команда')
}
})
})
})
}
private authorized(req: IncomingMessage) {
const token = /(?:^|;\s*)screen=([a-f0-9]{64})(?:;|$)/.exec(req.headers.cookie ?? '')?.[1]
return !!token && this.tokens.has(token)
}
async start(address: string) {
await new Promise<void>((accept, reject) => {
this.server.once('error', reject)
this.server.listen(0, address, () => {
this.server.off('error', reject)
accept()
})
})
const info = this.server.address()
if (!info || typeof info === 'string') throw new Error('Не удалось открыть пульт')
this.url = `http://${address}:${info.port}`
}
approve(id: string) {
const pending = this.pending.get(id)
if (!pending || pending.expires < Date.now()) throw new Error('Запрос подключения истёк')
pending.approved = true
}
broadcast(state: State) {
for (const ws of this.sockets.clients)
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(state))
}
async close() {
for (const ws of this.sockets.clients) ws.terminate()
this.sockets.close()
this.tokens.clear()
this.pending.clear()
await new Promise<void>((done) => {
this.server.close(() => done())
this.server.closeAllConnections()
})
}
private json(res: ServerResponse, status: number, value: unknown) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' })
res.end(JSON.stringify(value))
}
private async handle(req: IncomingMessage, res: ServerResponse) {
res.setHeader('X-Content-Type-Options', 'nosniff')
res.setHeader('Referrer-Policy', 'no-referrer')
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'",
)
if (req.headers.host !== new URL(this.url).host) return this.json(res, 403, {})
const url = new URL(req.url ?? '/', this.url)
for (const [id, pending] of this.pending)
if (pending.expires < Date.now()) this.pending.delete(id)
if (req.method === 'POST' && url.pathname === '/pair') {
if (req.headers.origin !== this.url) return this.json(res, 403, {})
if (Date.now() - this.attemptWindow > 60000) {
this.attemptWindow = Date.now()
this.attempts = 0
}
if (++this.attempts > 10 || this.pending.size >= 10) return this.json(res, 429, {})
let body = ''
for await (const chunk of req) {
body += chunk.toString()
if (body.length > 256) return this.json(res, 413, {})
}
const code: unknown = JSON.parse(body).code
if (
typeof code !== 'string' ||
code.length !== this.code.length ||
!timingSafeEqual(Buffer.from(code), Buffer.from(this.code))
)
return this.json(res, 403, {})
const id = randomBytes(24).toString('hex')
this.pending.set(id, { approved: false, expires: Date.now() + 120000 })
return this.json(res, 200, { id })
}
if (req.method === 'GET' && url.pathname.startsWith('/pair/')) {
const id = url.pathname.slice(6)
const pending = this.pending.get(id)
if (!pending) return this.json(res, 404, {})
if (!pending.approved) return this.json(res, 202, {})
const token = randomBytes(32).toString('hex')
this.tokens.add(token)
this.pending.delete(id)
res.setHeader('Set-Cookie', `screen=${token}; HttpOnly; SameSite=Strict; Path=/`)
return this.json(res, 200, {})
}
if (req.method !== 'GET' || (url.pathname !== '/' && !url.pathname.startsWith('/assets/')))
return this.json(res, 404, {})
const filename = resolve(
this.root,
'.' + decodeURIComponent(url.pathname === '/' ? '/index.html' : url.pathname),
)
if (!filename.startsWith(resolve(this.root) + sep)) return this.json(res, 403, {})
const info = await stat(filename)
if (!info.isFile()) return this.json(res, 404, {})
res.writeHead(200, {
'Content-Type': mime[extname(filename)] ?? 'application/octet-stream',
'Content-Length': info.size,
})
createReadStream(filename)
.on('error', () => res.destroy())
.pipe(res)
}
}