1import type { RequestHandler } from '@sveltejs/kit';
2import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/payloads';
3import db from '$lib/db/db.server';
4import { users } from '$lib/db/schema';
5import { hashPassword } from '$lib/utils/password';
6import { validateTurnstileToken } from '$lib/utils/turnstile.server';
7import { createLogger } from '$lib/utils/logger.server';
8import { eq } from 'drizzle-orm';
9
10const log = createLogger('register-server');
11
12// In-memory map to track failed captcha attempts per IP
13const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>();
14
15// Cleanup old entries every hour
16setInterval(() => {
17 const now = Date.now();
18 const ONE_HOUR = 60 * 60 * 1000;
19 for (const [ip, data] of captchaAttempts.entries()) {
20 if (now - data.lastAttempt > ONE_HOUR) {
21 captchaAttempts.delete(ip);
22 }
23 }
24}, 60 * 60 * 1000);
25
26export const POST: RequestHandler = async ({ request }) => {
27 log.debug('New registration attempt received');
28
29 if (request.method !== 'POST') {
30 log.warn('Invalid method used', { method: request.method });
31 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 });
32 }
33
34 // Get the IP address for rate limiting and Turnstile validation
35 const ip = request.headers.get('x-forwarded-for') || 'unknown';
36 const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
37 const now = Date.now();
38 const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
39
40 let body: unknown;
41 try {
42 body = await request.json();
43 } catch {
44 log.warn('Invalid JSON payload received');
45 return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 });
46 }
47
48 const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as {
49 suUsername: string,
50 suPassword: string,
51 suConfirmPassword: string,
52 captchaAnswer: string,
53 turnstileToken: string
54 };
55
56 if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !turnstileToken) {
57 log.warn('Missing required fields', {
58 hasUsername: Boolean(suUsername),
59 hasPassword: Boolean(suPassword),
60 hasConfirmPassword: Boolean(suConfirmPassword),
61 hasCaptcha: Boolean(captchaAnswer),
62 hasTurnstileToken: Boolean(turnstileToken)
63 });
64 return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 });
65 }
66
67 // Validate Turnstile token
68 const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
69 if (!isValidTurnstile) {
70 log.warn('Invalid Turnstile token', { maskedIp });
71 return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 });
72 }
73
74 // Validate PDR captcha
75 const normalizedAnswer = captchaAnswer.trim().toLowerCase();
76 if (normalizedAnswer !== 'poudlard' && normalizedAnswer !== 'poudlard rp') {
77 attemptData.count++;
78 attemptData.lastAttempt = now;
79 captchaAttempts.set(ip, attemptData);
80 log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count });
81 return new Response(JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError), { status: 400 });
82 }
83
84 // Reset captcha attempts on success
85 captchaAttempts.delete(ip);
86
87 // Destructure and validate required fields from the payload.
88 if (typeof suUsername !== 'string' || typeof suPassword !== 'string' ||
89 typeof suConfirmPassword !== 'string') {
90 log.warn('Missing or invalid input fields');
91 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 });
92 }
93
94 // Trim input values.
95 const username = suUsername.trim();
96 const password = suPassword.trim();
97 const confirmPassword = suConfirmPassword.trim();
98
99 // Basic validations.
100 if (!username || !password || !confirmPassword) {
101 log.warn('All fields must be filled');
102 return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 });
103 }
104
105 if (username.length > 32) {
106 log.warn('Username must be at most 32 characters');
107 return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 });
108 }
109 // Only allow letters, numbers, underscores, and dashes in the username.
110 const usernameRegex = /^[a-zA-Z0-9_-]+$/;
111 if (!usernameRegex.test(username)) {
112 log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.');
113 return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 });
114 }
115
116 if (password !== confirmPassword) {
117 log.warn('Passwords do not match');
118 return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 });
119 }
120
121 if (password.length < 8 || password.length > 64) {
122 log.warn('Password must be between 8 and 64 characters');
123 return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 });
124 }
125
126 // Check if nickname already exists
127 const existingUser = await db.select().from(users).where(eq(users.nickname, username));
128 if (existingUser.length > 0) {
129 log.warn('Username already exists', { username });
130 return new Response(JSON.stringify({ error: 'This username is already taken' } as RegisterResponseError), { status: 409 });
131 }
132
133 // Securely hash the user's password.
134 let hashedPassword: string;
135 try {
136 hashedPassword = await hashPassword(password);
137 } catch {
138 log.error('Error hashing password');
139 return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 });
140 }
141 // Insert the new user into the database.
142 try {
143 await db.insert(users).values({
144 nickname: username,
145 password: hashedPassword,
146 createdAt: Date.now()
147 });
148 log.info(`User ${username} registered successfully`);
149 } catch (err: unknown) {
150 log.error('Database insertion error', { error: err as object });
151 // Assume a duplicate user error if the email (or derived unique field) already exists.
152 return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 });
153 }
154
155 return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), { status: 201 });
156};