- 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.
244 lines
8.6 KiB
TypeScript
244 lines
8.6 KiB
TypeScript
import { NextResponse } from 'next/server';
|
|
import type { CaptchaProvider } from '@nexumi/shared';
|
|
import {
|
|
deleteCaptchaChallenge,
|
|
getCaptchaChallenge,
|
|
getCaptchaPublicConfig,
|
|
getClientIp,
|
|
hashCaptchaAnswer,
|
|
hashClientSignal,
|
|
verifyProviderToken
|
|
} from '@/lib/captcha';
|
|
import { getVerificationQueue } from '@/lib/queues';
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|
|
|
|
const PAGE_STYLES = `
|
|
body { font-family: system-ui, sans-serif; background:#0b1220; color:#f8fafc; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
|
|
.card { background:#111827; padding:2rem; border-radius:12px; width:min(440px, 92vw); border:1px solid #1f2937; }
|
|
h1 { margin:0 0 0.5rem; font-size:1.35rem; }
|
|
p { margin:0 0 1rem; color:#cbd5e1; line-height:1.45; }
|
|
.error { color:#f87171; margin-bottom:0.75rem; }
|
|
input, button { width:100%; padding:0.75rem; margin-top:0.75rem; border-radius:8px; border:1px solid #374151; background:#0b1220; color:#f8fafc; box-sizing:border-box; }
|
|
button { background:#4f46e5; border:none; cursor:pointer; font-weight:600; }
|
|
.widget { display:flex; justify-content:center; margin:1rem 0; min-height:78px; }
|
|
`;
|
|
|
|
function renderShell(body: string): string {
|
|
return `<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Nexumi Verification</title>
|
|
<style>${PAGE_STYLES}</style>
|
|
</head>
|
|
<body>${body}</body>
|
|
</html>`;
|
|
}
|
|
|
|
function renderMathPage(token: string, question: string, error?: string): string {
|
|
const errorBlock = error ? `<p class="error">${escapeHtml(error)}</p>` : '';
|
|
return renderShell(`
|
|
<form class="card" method="POST" action="/verify/captcha">
|
|
<h1>Nexumi Verification</h1>
|
|
<p>Solve: <strong>${escapeHtml(question)} = ?</strong></p>
|
|
${errorBlock}
|
|
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
|
<input type="hidden" name="provider" value="MATH" />
|
|
<input type="number" name="answer" required autofocus />
|
|
<button type="submit">Verify</button>
|
|
</form>`);
|
|
}
|
|
|
|
function renderProviderPage(
|
|
token: string,
|
|
provider: CaptchaProvider,
|
|
siteKey: string,
|
|
error?: string
|
|
): string {
|
|
const errorBlock = error ? `<p class="error">${escapeHtml(error)}</p>` : '';
|
|
const labels: Record<string, string> = {
|
|
RECAPTCHA_V2: 'Complete the Google reCAPTCHA below.',
|
|
RECAPTCHA_V3: 'Checking with Google reCAPTCHA…',
|
|
HCAPTCHA: 'Complete the hCaptcha below.',
|
|
TURNSTILE: 'Complete the Cloudflare Turnstile below.'
|
|
};
|
|
|
|
if (provider === 'RECAPTCHA_V3') {
|
|
return renderShell(`
|
|
<form class="card" id="verify-form" method="POST" action="/verify/captcha">
|
|
<h1>Nexumi Verification</h1>
|
|
<p>${escapeHtml(labels.RECAPTCHA_V3)}</p>
|
|
${errorBlock}
|
|
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
|
<input type="hidden" name="provider" value="RECAPTCHA_V3" />
|
|
<input type="hidden" name="captcha_response" id="captcha_response" />
|
|
<button type="submit" id="submit-btn" disabled>Verifying…</button>
|
|
</form>
|
|
<script src="https://www.google.com/recaptcha/api.js?render=${escapeHtml(siteKey)}"></script>
|
|
<script>
|
|
grecaptcha.ready(function () {
|
|
grecaptcha.execute('${escapeHtml(siteKey)}', { action: 'verify' }).then(function (token) {
|
|
document.getElementById('captcha_response').value = token;
|
|
document.getElementById('verify-form').submit();
|
|
});
|
|
});
|
|
</script>`);
|
|
}
|
|
|
|
let widget = '';
|
|
let scripts = '';
|
|
if (provider === 'RECAPTCHA_V2') {
|
|
widget = `<div class="g-recaptcha" data-sitekey="${escapeHtml(siteKey)}"></div>`;
|
|
scripts = `<script src="https://www.google.com/recaptcha/api.js" async defer></script>`;
|
|
} else if (provider === 'HCAPTCHA') {
|
|
widget = `<div class="h-captcha" data-sitekey="${escapeHtml(siteKey)}"></div>`;
|
|
scripts = `<script src="https://js.hcaptcha.com/1/api.js" async defer></script>`;
|
|
} else if (provider === 'TURNSTILE') {
|
|
widget = `<div class="cf-turnstile" data-sitekey="${escapeHtml(siteKey)}"></div>`;
|
|
scripts = `<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>`;
|
|
}
|
|
|
|
return renderShell(`
|
|
<form class="card" method="POST" action="/verify/captcha">
|
|
<h1>Nexumi Verification</h1>
|
|
<p>${escapeHtml(labels[provider] ?? 'Complete the captcha below.')}</p>
|
|
${errorBlock}
|
|
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
|
<input type="hidden" name="provider" value="${escapeHtml(provider)}" />
|
|
<div class="widget">${widget}</div>
|
|
<button type="submit">Verify</button>
|
|
</form>
|
|
${scripts}`);
|
|
}
|
|
|
|
function renderMisconfigured(provider: string): string {
|
|
return renderShell(`
|
|
<div class="card">
|
|
<h1>Captcha unavailable</h1>
|
|
<p>Provider <strong>${escapeHtml(provider)}</strong> is not configured on this server. Ask an admin to set the keys in <code>.env</code>, or switch the guild to Math captcha.</p>
|
|
</div>`);
|
|
}
|
|
|
|
function renderDonePage(): string {
|
|
return renderShell(`
|
|
<div class="card" style="text-align:center">
|
|
<h1>Verified</h1>
|
|
<p>You can return to Discord.</p>
|
|
</div>`);
|
|
}
|
|
|
|
function html(body: string, status = 200): NextResponse {
|
|
return new NextResponse(body, {
|
|
status,
|
|
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
|
});
|
|
}
|
|
|
|
function extractProviderResponse(form: FormData, provider: CaptchaProvider): string {
|
|
const direct = String(form.get('captcha_response') ?? '').trim();
|
|
if (direct) {
|
|
return direct;
|
|
}
|
|
if (provider === 'RECAPTCHA_V2' || provider === 'RECAPTCHA_V3') {
|
|
return String(form.get('g-recaptcha-response') ?? '').trim();
|
|
}
|
|
if (provider === 'HCAPTCHA') {
|
|
return String(form.get('h-captcha-response') ?? '').trim();
|
|
}
|
|
if (provider === 'TURNSTILE') {
|
|
return String(form.get('cf-turnstile-response') ?? '').trim();
|
|
}
|
|
return '';
|
|
}
|
|
|
|
function renderChallengePage(
|
|
token: string,
|
|
provider: CaptchaProvider,
|
|
question: string | undefined,
|
|
error?: string
|
|
): NextResponse {
|
|
if (provider === 'MATH') {
|
|
return html(renderMathPage(token, question ?? '?', error));
|
|
}
|
|
const publicConfig = getCaptchaPublicConfig(provider);
|
|
if (!publicConfig.siteKey) {
|
|
return html(renderMisconfigured(provider), 503);
|
|
}
|
|
return html(renderProviderPage(token, provider, publicConfig.siteKey, error));
|
|
}
|
|
|
|
export async function GET(request: Request): Promise<NextResponse> {
|
|
const token = new URL(request.url).searchParams.get('token');
|
|
if (!token) {
|
|
return new NextResponse('Missing token', { status: 400 });
|
|
}
|
|
const challenge = await getCaptchaChallenge(token);
|
|
if (!challenge) {
|
|
return new NextResponse('Captcha expired', { status: 404 });
|
|
}
|
|
return renderChallengePage(token, challenge.provider, challenge.question);
|
|
}
|
|
|
|
export async function POST(request: Request): Promise<NextResponse> {
|
|
const form = await request.formData();
|
|
const token = String(form.get('token') ?? '');
|
|
if (!token) {
|
|
return new NextResponse('Missing fields', { status: 400 });
|
|
}
|
|
|
|
const challenge = await getCaptchaChallenge(token);
|
|
if (!challenge) {
|
|
return new NextResponse('Captcha expired', { status: 404 });
|
|
}
|
|
|
|
const provider = challenge.provider;
|
|
const clientIp = getClientIp(request);
|
|
const userAgent = request.headers.get('user-agent') ?? '';
|
|
|
|
if (provider === 'MATH') {
|
|
const answer = String(form.get('answer') ?? '');
|
|
if (!answer || !challenge.answerHash || hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
|
return renderChallengePage(token, provider, challenge.question, 'Wrong answer. Try again.');
|
|
}
|
|
} else {
|
|
const responseToken = extractProviderResponse(form, provider);
|
|
const verified = await verifyProviderToken(provider, responseToken, clientIp);
|
|
if (!verified.ok) {
|
|
const message =
|
|
verified.error === 'score_too_low'
|
|
? 'reCAPTCHA score too low. Try again.'
|
|
: verified.error === 'provider_not_configured'
|
|
? 'Captcha provider is not configured.'
|
|
: 'Captcha failed. Try again.';
|
|
return renderChallengePage(token, provider, challenge.question, message);
|
|
}
|
|
}
|
|
|
|
await deleteCaptchaChallenge(token);
|
|
|
|
const ipHash = clientIp ? hashClientSignal(clientIp) : null;
|
|
const userAgentHash = userAgent ? hashClientSignal(userAgent) : null;
|
|
|
|
await getVerificationQueue().add(
|
|
'verificationComplete',
|
|
{
|
|
guildId: challenge.guildId,
|
|
userId: challenge.userId,
|
|
ipHash,
|
|
userAgentHash,
|
|
captchaProvider: provider
|
|
},
|
|
{ removeOnComplete: 1000, removeOnFail: 500 }
|
|
);
|
|
|
|
return html(renderDonePage());
|
|
}
|