1import type { RequestHandler } from '@sveltejs/kit';
2import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/payloads';
3import { users } from '$lib/db/convex.server';
4import { hashPassword } from '$lib/utils/password';
5import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password';
6import { createLogger } from '$lib/utils/logger.server';
7
8const log = createLogger('register-server');
9
10// In-memory map to track failed captcha attempts per IP
11const captchaAttempts = new Map<string, { count: number; lastAttempt: number }>();
12
13// Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope
14const ONE_HOUR = 60 * 60 * 1000;
15let lastCleanup = 0;
16function cleanupOldCaptchaAttempts() {
17 const now = Date.now();
18 if (now - lastCleanup < ONE_HOUR) return;
19 lastCleanup = now;
20
21 for (const [ip, data] of captchaAttempts.entries()) {
22 if (now - data.lastAttempt > ONE_HOUR) {
23 captchaAttempts.delete(ip);
24 }
25 }
26}
27
28export const POST: RequestHandler = async ({ request, getClientAddress }) => {
29 log.debug('New registration attempt received');
30
31 // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
32 cleanupOldCaptchaAttempts();
33
34 if (request.method !== 'POST') {
35 log.warn('Invalid method used', { method: request.method });
36 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), {
37 status: 405
38 });
39 }
40
41 // Get the IP address for rate limiting and Turnstile validation
42 const ip = getClientAddress();
43 const maskedIp = ip
44 .split('.')
45 .map((octet, idx) => (idx < 3 ? 'xxx' : octet))
46 .join('.');
47 const now = Date.now();
48 const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
49
50 let body: unknown;
51 try {
52 body = await request.json();
53 } catch {
54 log.warn('Invalid JSON payload received');
55 return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), {
56 status: 400
57 });
58 }
59
60 const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as {
61 suUsername: string;
62 suPassword: string;
63 suConfirmPassword: string;
64 captchaAnswer: string;
65 turnstileToken?: string;
66 };
67
68 // Validate input using Zod schema
69 try {
70 const registrationSchema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS);
71 registrationSchema.parse({
72 suUsername,
73 suPassword,
74 suConfirmPassword,
75 captchaAnswer,
76 turnstileToken
77 });
78 } catch (err: unknown) {
79 const zodError = err as { errors?: Array<{ message?: string }> };
80 const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data';
81 log.warn('Registration validation failed', { error: errorMessage });
82 return new Response(JSON.stringify({ error: errorMessage } as RegisterResponseError), {
83 status: 400
84 });
85 }
86
87 // Validate PDR captcha
88 // SECURITY NOTE: Static CAPTCHA answer is intentional for this community app.
89 // The answer "point de rencontre" acts as a shared-knowledge gate, not bot prevention.
90 // Bot prevention is handled by Cloudflare Turnstile (when enabled in production).
91 const normalizedAnswer = captchaAnswer.trim().toLowerCase();
92 if (normalizedAnswer !== 'point de rencontre') {
93 attemptData.count++;
94 attemptData.lastAttempt = now;
95 captchaAttempts.set(ip, attemptData);
96 log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count });
97 return new Response(
98 JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError),
99 { status: 400 }
100 );
101 }
102
103 // Reset captcha attempts on success
104 captchaAttempts.delete(ip);
105
106 // Trim input values (validation already done by Zod)
107 const username = suUsername.trim();
108 const password = suPassword;
109
110 // Check if nickname already exists using Convex
111 const existingUser = await users.getByNickname(username);
112 if (existingUser) {
113 log.warn('Username already exists', { username });
114 return new Response(
115 JSON.stringify({ error: 'This username is already taken' } as RegisterResponseError),
116 { status: 409 }
117 );
118 }
119
120 // Securely hash the user's password.
121 let hashedPassword: string;
122 try {
123 hashedPassword = await hashPassword(password);
124 } catch {
125 log.error('Error hashing password');
126 return new Response(
127 JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError),
128 { status: 500 }
129 );
130 }
131
132 // Insert the new user into Convex
133 try {
134 await users.create(username, hashedPassword);
135 log.info(`User ${username} registered successfully`);
136 } catch (err: unknown) {
137 log.error('Database insertion error', { error: err as object });
138 return new Response(
139 JSON.stringify({
140 error: 'User registration failed. Possibly user already exists.'
141 } as RegisterResponseError),
142 { status: 409 }
143 );
144 }
145
146 return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), {
147 status: 201
148 });
149};