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