118 lines
5.0 KiB
JavaScript
118 lines
5.0 KiB
JavaScript
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']],
|
|
['/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 ? '<!doctype html><html lang="ru"><meta charset="utf-8"><title>Spotify подключён</title><p>Spotify подключён. Вернитесь в пульт.</p></html>' : '<!doctype html><html lang="ru"><meta charset="utf-8"><title>Ошибка подключения</title><p>Не удалось подключить Spotify. Попробуйте снова из пульта.</p></html>');
|
|
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(),
|
|
});
|
|
} 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;
|
|
}
|