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 ` Nexumi Verification ${body} `; } function renderMathPage(token: string, question: string, error?: string): string { const errorBlock = error ? `

${escapeHtml(error)}

` : ''; return renderShell(`

Nexumi Verification

Solve: ${escapeHtml(question)} = ?

${errorBlock}
`); } function renderProviderPage( token: string, provider: CaptchaProvider, siteKey: string, error?: string ): string { const errorBlock = error ? `

${escapeHtml(error)}

` : ''; const labels: Record = { 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(`

Nexumi Verification

${escapeHtml(labels.RECAPTCHA_V3)}

${errorBlock}
`); } let widget = ''; let scripts = ''; if (provider === 'RECAPTCHA_V2') { widget = `
`; scripts = ``; } else if (provider === 'HCAPTCHA') { widget = `
`; scripts = ``; } else if (provider === 'TURNSTILE') { widget = `
`; scripts = ``; } return renderShell(`

Nexumi Verification

${escapeHtml(labels[provider] ?? 'Complete the captcha below.')}

${errorBlock}
${widget}
${scripts}`); } function renderMisconfigured(provider: string): string { return renderShell(`

Captcha unavailable

Provider ${escapeHtml(provider)} is not configured on this server. Ask an admin to set the keys in .env, or switch the guild to Math captcha.

`); } function renderDonePage(): string { return renderShell(`

Verified

You can return to Discord.

`); } 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 { 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 { 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()); }