Files
alice-backend/server/admin.mjs
T

106 lines
3.9 KiB
JavaScript

import { createHmac, timingSafeEqual } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { validateSettings } from './store.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/admin' && route !== '/api/admin/settings' && route !== '/api/admin/history') 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/admin' && req.method === 'GET') {
json(res, 200, {
settings: store.getSettings(),
history: store.getHistory().reverse(),
});
} 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;
}