- Added support for multiple captcha providers including Google reCAPTCHA (v2 and v3), hCaptcha, and Cloudflare Turnstile. - Introduced new fields in the verification configuration for selecting captcha providers and enabling alt-account detection. - Implemented logic to handle verification attempts and flag potential alt accounts based on IP and invite code analysis. - Updated environment configuration to include necessary keys for captcha providers. - Enhanced the user interface to allow selection of captcha providers in the verification setup. - Improved backend handling of verification records to store additional data related to captcha provider usage.
200 lines
6.3 KiB
TypeScript
200 lines
6.3 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;
|
|
}
|
|
// External providers (reCAPTCHA / hCaptcha / Turnstile) are served by the WebUI.
|
|
if (challenge.provider !== 'MATH' && env.WEBUI_URL) {
|
|
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
|
|
res.statusCode = 302;
|
|
res.setHeader('Location', target);
|
|
res.end();
|
|
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 (challenge.provider !== 'MATH') {
|
|
if (env.WEBUI_URL) {
|
|
const target = `${env.WEBUI_URL.replace(/\/$/, '')}/verify/captcha?token=${encodeURIComponent(token)}`;
|
|
res.statusCode = 302;
|
|
res.setHeader('Location', target);
|
|
res.end();
|
|
return;
|
|
}
|
|
res.statusCode = 400;
|
|
res.end('Use the WebUI captcha URL for this provider');
|
|
return;
|
|
}
|
|
if (!challenge.answerHash || 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,
|
|
captchaProvider: challenge.provider
|
|
},
|
|
{ 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);
|
|
}
|