156 lines
5.9 KiB
TypeScript
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)
|
|
}
|
|
}
|