- Updated package dependencies to include `@sentry/node` for error tracking. - Refactored command routing to incorporate user and guild blacklisting checks, improving command execution safety. - Enhanced health server metrics to include queue waiting times, providing better insights into system performance. - Introduced confirmation handling for guild backup commands, streamlining user interactions. - Improved moderation commands with better error handling and case management, ensuring robust moderation capabilities. - Added new duration parsing functions to handle timeout durations effectively, enhancing moderation features. - Updated localization files to reflect new command structures and user guidance improvements.
176 lines
5.5 KiB
TypeScript
176 lines
5.5 KiB
TypeScript
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
|
import { env } from './env.js';
|
|
import { redis } from './redis.js';
|
|
import {
|
|
deleteCaptchaChallenge,
|
|
getCaptchaChallenge,
|
|
hashCaptchaAnswer
|
|
} from './modules/verification/service.js';
|
|
import {
|
|
automodQueue,
|
|
backupQueue,
|
|
birthdayQueue,
|
|
feedsQueue,
|
|
presenceQueue,
|
|
retentionQueue,
|
|
ticketQueue,
|
|
verificationQueue
|
|
} from './queues.js';
|
|
import { collectMetricsSnapshot, formatPrometheus } from './metrics.js';
|
|
|
|
function readBody(req: IncomingMessage): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on('data', (chunk) => chunks.push(chunk));
|
|
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
function parseFormBody(body: string): Record<string, string> {
|
|
return Object.fromEntries(new URLSearchParams(body));
|
|
}
|
|
|
|
function renderCaptchaPage(token: string, question: string, error?: string): string {
|
|
const errorBlock = error ? `<p style="color:#ef4444">${error}</p>` : '';
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>Nexumi Verification</title>
|
|
<style>
|
|
body { font-family: Inter, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
|
|
form { background:#1f2937; padding:2rem; border-radius:12px; width:min(420px, 90vw); }
|
|
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#111827; color:#f9fafb; }
|
|
button { background:#6366f1; border:none; cursor:pointer; font-weight:600; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<form method="POST" action="/verify/captcha">
|
|
<h1>Nexumi Verification</h1>
|
|
<p>Solve: <strong>${question} = ?</strong></p>
|
|
${errorBlock}
|
|
<input type="hidden" name="token" value="${token}" />
|
|
<input type="number" name="answer" required autofocus />
|
|
<button type="submit">Verify</button>
|
|
</form>
|
|
</body>
|
|
</html>`;
|
|
}
|
|
|
|
async function handleCaptchaGet(url: URL, res: ServerResponse): Promise<void> {
|
|
const token = url.searchParams.get('token');
|
|
if (!token) {
|
|
res.statusCode = 400;
|
|
res.end('Missing token');
|
|
return;
|
|
}
|
|
const challenge = await getCaptchaChallenge(redis, token);
|
|
if (!challenge) {
|
|
res.statusCode = 404;
|
|
res.end('Captcha expired');
|
|
return;
|
|
}
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end(renderCaptchaPage(token, challenge.question));
|
|
}
|
|
|
|
async function handleCaptchaPost(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
const body = await readBody(req);
|
|
const form = parseFormBody(body);
|
|
const token = form.token;
|
|
const answer = form.answer;
|
|
if (!token || !answer) {
|
|
res.statusCode = 400;
|
|
res.end('Missing fields');
|
|
return;
|
|
}
|
|
const challenge = await getCaptchaChallenge(redis, token);
|
|
if (!challenge) {
|
|
res.statusCode = 404;
|
|
res.end('Captcha expired');
|
|
return;
|
|
}
|
|
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
|
|
return;
|
|
}
|
|
await deleteCaptchaChallenge(redis, token);
|
|
await verificationQueue.add(
|
|
'verificationComplete',
|
|
{ guildId: challenge.guildId, userId: challenge.userId },
|
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
|
);
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end('<html><body style="font-family:sans-serif;background:#111827;color:#f9fafb;display:flex;justify-content:center;align-items:center;min-height:100vh"><div><h1>Verified</h1><p>You can return to Discord.</p></div></body></html>');
|
|
}
|
|
|
|
async function approximateQueueWaiting(): Promise<number> {
|
|
const counts = await Promise.all([
|
|
automodQueue.getWaitingCount(),
|
|
backupQueue.getWaitingCount(),
|
|
birthdayQueue.getWaitingCount(),
|
|
feedsQueue.getWaitingCount(),
|
|
presenceQueue.getWaitingCount(),
|
|
retentionQueue.getWaitingCount(),
|
|
ticketQueue.getWaitingCount()
|
|
]);
|
|
return counts.reduce((sum, n) => sum + n, 0);
|
|
}
|
|
|
|
export function startHealthServer(): void {
|
|
const server = createServer(async (req, res) => {
|
|
if (!req.url) {
|
|
res.statusCode = 400;
|
|
res.end('Bad Request');
|
|
return;
|
|
}
|
|
const url = new URL(req.url, `http://127.0.0.1:${env.HEALTH_PORT}`);
|
|
|
|
if (url.pathname === '/health') {
|
|
res.statusCode = 200;
|
|
res.end('ok');
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/metrics') {
|
|
const auth = req.headers.authorization;
|
|
if (!env.METRICS_TOKEN || auth !== `Bearer ${env.METRICS_TOKEN}`) {
|
|
res.statusCode = 401;
|
|
res.end('Unauthorized');
|
|
return;
|
|
}
|
|
try {
|
|
const snapshot = await collectMetricsSnapshot(redis);
|
|
snapshot.queueWaiting = await approximateQueueWaiting();
|
|
res.statusCode = 200;
|
|
res.setHeader('Content-Type', 'text/plain; version=0.0.4; charset=utf-8');
|
|
res.end(formatPrometheus(snapshot));
|
|
} catch {
|
|
res.statusCode = 500;
|
|
res.end('metrics error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/verify/captcha' && req.method === 'GET') {
|
|
await handleCaptchaGet(url, res);
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === '/verify/captcha' && req.method === 'POST') {
|
|
await handleCaptchaPost(req, res);
|
|
return;
|
|
}
|
|
|
|
res.statusCode = 404;
|
|
res.end('Not Found');
|
|
});
|
|
|
|
server.listen(env.HEALTH_PORT);
|
|
}
|