Enhance verification module with improved command handling and localization
- Updated the verification command to utilize a new `ephemeral` utility for consistent ephemeral message replies. - Refactored the `buildVerificationPanel` function to include localization support for panel titles and descriptions. - Added permission checks to ensure the bot can post in the designated verification channel. - Introduced a new verification queue in the queue management system for better handling of verification tasks. - Updated localization files to include new keys for verification-related messages in both English and German.
This commit is contained in:
110
apps/webui/src/app/verify/captcha/route.ts
Normal file
110
apps/webui/src/app/verify/captcha/route.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
deleteCaptchaChallenge,
|
||||
getCaptchaChallenge,
|
||||
hashCaptchaAnswer
|
||||
} from '@/lib/captcha';
|
||||
import { getVerificationQueue } from '@/lib/queues';
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
function renderCaptchaPage(token: string, question: string, error?: string): string {
|
||||
const errorBlock = error ? `<p style="color:#ef4444">${escapeHtml(error)}</p>` : '';
|
||||
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>
|
||||
body { font-family: Inter, system-ui, 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; box-sizing:border-box; }
|
||||
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>${escapeHtml(question)} = ?</strong></p>
|
||||
${errorBlock}
|
||||
<input type="hidden" name="token" value="${escapeHtml(token)}" />
|
||||
<input type="number" name="answer" required autofocus />
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderDonePage(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Nexumi Verified</title>
|
||||
<style>
|
||||
body { font-family: Inter, system-ui, sans-serif; background:#111827; color:#f9fafb; display:flex; justify-content:center; align-items:center; min-height:100vh; margin:0; }
|
||||
div { text-align:center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
<h1>Verified</h1>
|
||||
<p>You can return to Discord.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function html(body: string, status = 200): NextResponse {
|
||||
return new NextResponse(body, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/html; charset=utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
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 html(renderCaptchaPage(token, challenge.question));
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const form = await request.formData();
|
||||
const token = String(form.get('token') ?? '');
|
||||
const answer = String(form.get('answer') ?? '');
|
||||
if (!token || !answer) {
|
||||
return new NextResponse('Missing fields', { status: 400 });
|
||||
}
|
||||
|
||||
const challenge = await getCaptchaChallenge(token);
|
||||
if (!challenge) {
|
||||
return new NextResponse('Captcha expired', { status: 404 });
|
||||
}
|
||||
|
||||
if (hashCaptchaAnswer(answer) !== challenge.answerHash) {
|
||||
return html(renderCaptchaPage(token, challenge.question, 'Wrong answer. Try again.'));
|
||||
}
|
||||
|
||||
await deleteCaptchaChallenge(token);
|
||||
await getVerificationQueue().add(
|
||||
'verificationComplete',
|
||||
{ guildId: challenge.guildId, userId: challenge.userId },
|
||||
{ removeOnComplete: 1000, removeOnFail: 500 }
|
||||
);
|
||||
|
||||
return html(renderDonePage());
|
||||
}
|
||||
32
apps/webui/src/lib/captcha.ts
Normal file
32
apps/webui/src/lib/captcha.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { redis } from './redis';
|
||||
|
||||
const CAPTCHA_PREFIX = 'verify:captcha:';
|
||||
|
||||
export type CaptchaChallenge = {
|
||||
token: string;
|
||||
guildId: string;
|
||||
userId: string;
|
||||
question: string;
|
||||
answerHash: string;
|
||||
};
|
||||
|
||||
export async function getCaptchaChallenge(token: string): Promise<CaptchaChallenge | null> {
|
||||
const raw = await redis.get(`${CAPTCHA_PREFIX}${token}`);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw) as CaptchaChallenge;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteCaptchaChallenge(token: string): Promise<void> {
|
||||
await redis.del(`${CAPTCHA_PREFIX}${token}`);
|
||||
}
|
||||
|
||||
export function hashCaptchaAnswer(answer: string): string {
|
||||
return createHash('sha256').update(answer.trim()).digest('hex');
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export const scheduleQueueName = 'schedules';
|
||||
export const guildBackupQueueName = 'guild-backups';
|
||||
export const suggestionsQueueName = 'suggestions';
|
||||
export const messagesQueueName = 'messages';
|
||||
export const verificationQueueName = 'verification';
|
||||
|
||||
const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGiveawayQueue?: Queue;
|
||||
@@ -26,6 +27,7 @@ const globalForQueues = globalThis as unknown as {
|
||||
__nexumiGuildBackupQueue?: Queue;
|
||||
__nexumiSuggestionsQueue?: Queue;
|
||||
__nexumiMessagesQueue?: Queue;
|
||||
__nexumiVerificationQueue?: Queue;
|
||||
__nexumiGiveawayQueueEvents?: QueueEvents;
|
||||
__nexumiGuildBackupQueueEvents?: QueueEvents;
|
||||
__nexumiSuggestionsQueueEvents?: QueueEvents;
|
||||
@@ -76,6 +78,13 @@ export function getMessagesQueue(): Queue {
|
||||
return globalForQueues.__nexumiMessagesQueue;
|
||||
}
|
||||
|
||||
export function getVerificationQueue(): Queue {
|
||||
globalForQueues.__nexumiVerificationQueue ??= new Queue(verificationQueueName, {
|
||||
connection: queueConnection()
|
||||
});
|
||||
return globalForQueues.__nexumiVerificationQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* QueueEvents instances are only needed for jobs where the dashboard needs
|
||||
* to wait for the bot to finish (e.g. ending a giveaway so the winners list
|
||||
|
||||
Reference in New Issue
Block a user