import { createHmac, timingSafeEqual } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { validateSettings } from './store.mjs'; import { spotifyStatus, startSpotify, finishSpotify } from './spotify.mjs'; const assets = new Map([ ['/', ['index.html', 'text/html; charset=utf-8']], ['/room-mic', ['room-mic.html', 'text/html; charset=utf-8']], ['/room-mic.js', ['room-mic.js', 'text/javascript; charset=utf-8']], ['/room-mic-worklet.js', ['room-mic-worklet.js', 'text/javascript; charset=utf-8']], ['/app.js', ['app.js', 'text/javascript; charset=utf-8']], ['/style.css', ['style.css', 'text/css; charset=utf-8']], ].map(([route, [file, type]]) => [route, [readFileSync(new URL('./public/' + file, import.meta.url)), type]])); const securityHeaders = { 'cache-control': 'no-store', 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer', 'content-security-policy': "default-src 'none'; script-src 'self' https://telegram.org; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self' https://web.telegram.org https://*.telegram.org", }; export function verifyTelegram(initData, botToken, owner, now = Date.now()) { if (!initData || initData.length > 8192 || !botToken || !owner) return false; const params = new URLSearchParams(initData); const entries = [...params.entries()]; if (new Set(params.keys()).size !== entries.length) return false; const hash = params.get('hash') ?? ''; if (!/^[a-f0-9]{64}$/.test(hash)) return false; const date = Number(params.get('auth_date')); const age = now / 1000 - date; if (!Number.isSafeInteger(date) || age < -60 || age > 3600) return false; const secret = createHmac('sha256', 'WebAppData').update(botToken).digest(); const check = entries.filter(([key]) => key !== 'hash').sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0) .map(([key, value]) => key + '=' + value).join('\n'); const expected = createHmac('sha256', secret).update(check).digest(); if (!timingSafeEqual(expected, Buffer.from(hash, 'hex'))) return false; try { const { id } = JSON.parse(params.get('user')); return Number.isSafeInteger(id) && String(id) === owner; } catch { return false; } } function json(res, status, data) { res.writeHead(status, { ...securityHeaders, 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); } async function readSettings(req) { const chunks = []; let bytes = 0; for await (const chunk of req.iterator({ destroyOnReturn: false })) { bytes += chunk.length; if (bytes > 20_000) { req.resume(); return null; } chunks.push(chunk); } try { return validateSettings(JSON.parse(Buffer.concat(chunks).toString())); } catch { return null; } } export async function handleAdmin(req, res, store) { const route = req.url?.split('?')[0]; const domain = process.env.ADMIN_HOST; if (domain && req.headers.host !== domain) return false; const asset = assets.get(route); if (asset && req.method === 'GET') { const [content, type] = asset; res.writeHead(200, { ...securityHeaders, 'content-type': type }); res.end(content); return true; } if (route === '/api/spotify/callback' && req.method === 'GET') { const params = new URL(req.url, 'http://localhost').searchParams; let success = false; try { await finishSpotify(params.get('state'), params.get('code')); success = true; } catch {} res.writeHead(success ? 200 : 400, { ...securityHeaders, 'content-type': 'text/html; charset=utf-8' }); res.end(success ? '
Spotify подключён. Вернитесь в пульт.
' : 'Не удалось подключить Spotify. Попробуйте снова из пульта.
'); return true; } if (route !== '/api/admin' && route !== '/api/admin/settings' && route !== '/api/admin/history' && route !== '/api/spotify/start') return false; const auth = req.headers.authorization ?? ''; if (!auth.startsWith('tma ') || !verifyTelegram(auth.slice(4), process.env.TELEGRAM_BOT_TOKEN, process.env.TELEGRAM_OWNER_ID)) { json(res, 401, { error: 'telegram_auth' }); return true; } if (route === '/api/spotify/start' && req.method === 'POST') { try { json(res, 200, { url: startSpotify() }); } catch { json(res, 503, { error: 'spotify_not_configured' }); } } else if (route === '/api/admin' && req.method === 'GET') { json(res, 200, { settings: store.getSettings(), history: store.getHistory().reverse(), spotify: { ...spotifyStatus(), playback: store.getDevice() }, }); } else if (route === '/api/admin/settings' && req.method === 'PUT') { const settings = await readSettings(req); if (settings) { store.updateSettings(settings); json(res, 200, { settings }); } else { json(res, 400, { error: 'invalid_settings' }); } } else if (route === '/api/admin/history' && req.method === 'DELETE') { store.clearHistory(); json(res, 200, { history: [] }); } else { json(res, 405, { error: 'method_not_allowed' }); } return true; }