+server.ts RawHistoryFolder

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};

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 9

↑↓ moveenter openesc close