detached c5cc06c

feat: Add Cloudflare Turnstile bot protection for login and registration

- Integrate Turnstile CAPTCHA verification for sign-in and sign-up routes
- Add Turnstile token validation on server-side for both login and register endpoints
- Update .env example
- Implement Turnstile component in aim-login for client-side verification
- Enhance security by requiring bot protection during authentication flows
commit
c5cc06cad1e774c9d5fda3dd4b523b1ced3180e9
author
Kit · · 2025-02-16 15:01 UTC
parent
96cccd7
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
8 files · +309 -53
M .cursor/rules/global.mdc +1 -1
@@ -3,7 +3,7 @@ description:
33 globs:
44 ---
55 # Debugging
6-- Always add console.debug for client sided code .
6+- Always add console.debug for client sided code.
77 - Always add either console.log or follow the logger implementation if any server/backend code.
88
99 # Language
@@ -3,7 +3,7 @@ description:
3 globs: 3 globs:
4 ---4 ---
5 # Debugging5 # Debugging
6-- Always add console.debug for client sided code .6+- Always add console.debug for client sided code.
7 - Always add either console.log or follow the logger implementation if any server/backend code.7 - Always add either console.log or follow the logger implementation if any server/backend code.
8 8
9 # Language9 # Language
M .env example +12 -1
@@ -1,2 +1,13 @@
1+# Turso database
12 TURSO_DATABASE_URL=
2-TURSO_AUTH_TOKEN=⏎̸
3+TURSO_AUTH_TOKEN=
4+
5+# Log level
6+LOG_LEVEL=debug
7+
8+# Default chat room
9+PUBLIC_DEFAULT_CHAT_ROOM_ID=00000000-0000-0000-0000-000000000001
10+
11+# Cloudflare Turnstile test keys (no quotes around the value)
12+PUBLIC_TURNSTILE_SITE_KEY=
13+TURNSTILE_SECRET_KEY=⏎̸
@@ -1,2 +1,13 @@
1+# Turso database
1 TURSO_DATABASE_URL=2 TURSO_DATABASE_URL=
2-TURSO_AUTH_TOKEN=⏎̸3+TURSO_AUTH_TOKEN=
4+
5+# Log level
6+LOG_LEVEL=debug
7+
8+# Default chat room
9+PUBLIC_DEFAULT_CHAT_ROOM_ID=00000000-0000-0000-0000-000000000001
10+
11+# Cloudflare Turnstile test keys (no quotes around the value)
12+PUBLIC_TURNSTILE_SITE_KEY=
13+TURNSTILE_SECRET_KEY=⏎̸
M src/lib/components/aim-login.svelte +18 -6
@@ -5,6 +5,7 @@
55 import type { User } from '$lib/types/chat';
66 import { invalidateAll } from '$app/navigation';
77 import { chatState } from '$lib/states/chat.svelte';
8+ import Turnstile from './turnstile.svelte';
89
910 let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();
1011
@@ -15,6 +16,7 @@
1516 let siPassword = $state('');
1617 let error = $state('');
1718 let loginStatus = $state('idle');
19+ let siTurnstileToken = $state('');
1820
1921 // Sign Up state
2022 let suUsername = $state('');
@@ -23,6 +25,7 @@
2325 let captchaAnswer = $state('');
2426 let signupError = $state('');
2527 let signupStatus = $state('idle');
28+ let suTurnstileToken = $state('');
2629
2730 function handleClose() {
2831 console.debug("Closing AIM Login component.");
@@ -32,8 +35,8 @@
3235 async function handleSigninSubmit() {
3336 error = '';
3437 loginStatus = 'loading';
35- if (!siUsername || !siPassword) {
36- error = 'Please enter both username and password';
38+ if (!siUsername || !siPassword || !siTurnstileToken) {
39+ error = 'Please complete all fields including the security check';
3740 loginStatus = 'error';
3841 return;
3942 }
@@ -45,7 +48,8 @@
4548 headers: { 'Content-Type': 'application/json' },
4649 body: JSON.stringify({
4750 username: siUsername,
48- password: siPassword
51+ password: siPassword,
52+ turnstileToken: siTurnstileToken
4953 })
5054 });
5155
@@ -81,8 +85,8 @@
8185 async function handleSignupSubmit() {
8286 signupError = '';
8387 signupStatus = 'loading';
84- if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) {
85- signupError = 'Please fill all fields';
88+ if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) {
89+ signupError = 'Please complete all fields including the security check';
8690 signupStatus = 'error';
8791 return;
8892 }
@@ -97,7 +101,13 @@
97101 const res = await fetch('/api/register', {
98102 method: 'POST',
99103 headers: { 'Content-Type': 'application/json' },
100- body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer })
104+ body: JSON.stringify({
105+ suUsername,
106+ suPassword,
107+ suConfirmPassword,
108+ captchaAnswer,
109+ turnstileToken: suTurnstileToken
110+ })
101111 });
102112
103113 const data = await res.json() as RegisterResponse;
@@ -202,6 +212,7 @@
202212 <label for="si-password">Mot de passe</label>
203213 <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />
204214 </div>
215+ <Turnstile onVerify={(token: string) => siTurnstileToken = token} />
205216 {#if error}
206217 <div class="error">{error}</div>
207218 {/if}
@@ -251,6 +262,7 @@
251262 <label for="captcha">Quelle est la signification de PDR ?</label>
252263 <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />
253264 </div>
265+ <Turnstile onVerify={(token: string) => suTurnstileToken = token} />
254266 {#if signupError}
255267 <div class="error">{signupError}</div>
256268 {/if}
@@ -5,6 +5,7 @@
5 import type { User } from '$lib/types/chat';5 import type { User } from '$lib/types/chat';
6 import { invalidateAll } from '$app/navigation';6 import { invalidateAll } from '$app/navigation';
7 import { chatState } from '$lib/states/chat.svelte';7 import { chatState } from '$lib/states/chat.svelte';
8+ import Turnstile from './turnstile.svelte';
8 9
9 let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();10 let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props();
10 11
@@ -15,6 +16,7 @@
15 let siPassword = $state('');16 let siPassword = $state('');
16 let error = $state('');17 let error = $state('');
17 let loginStatus = $state('idle');18 let loginStatus = $state('idle');
19+ let siTurnstileToken = $state('');
18 20
19 // Sign Up state21 // Sign Up state
20 let suUsername = $state('');22 let suUsername = $state('');
@@ -23,6 +25,7 @@
23 let captchaAnswer = $state('');25 let captchaAnswer = $state('');
24 let signupError = $state('');26 let signupError = $state('');
25 let signupStatus = $state('idle');27 let signupStatus = $state('idle');
28+ let suTurnstileToken = $state('');
26 29
27 function handleClose() {30 function handleClose() {
28 console.debug("Closing AIM Login component.");31 console.debug("Closing AIM Login component.");
@@ -32,8 +35,8 @@
32 async function handleSigninSubmit() {35 async function handleSigninSubmit() {
33 error = '';36 error = '';
34 loginStatus = 'loading';37 loginStatus = 'loading';
35- if (!siUsername || !siPassword) {38+ if (!siUsername || !siPassword || !siTurnstileToken) {
36- error = 'Please enter both username and password';39+ error = 'Please complete all fields including the security check';
37 loginStatus = 'error';40 loginStatus = 'error';
38 return;41 return;
39 }42 }
@@ -45,7 +48,8 @@
45 headers: { 'Content-Type': 'application/json' },48 headers: { 'Content-Type': 'application/json' },
46 body: JSON.stringify({ 49 body: JSON.stringify({
47 username: siUsername, 50 username: siUsername,
48- password: siPassword 51+ password: siPassword,
52+ turnstileToken: siTurnstileToken
49 })53 })
50 });54 });
51 55
@@ -81,8 +85,8 @@
81 async function handleSignupSubmit() {85 async function handleSignupSubmit() {
82 signupError = '';86 signupError = '';
83 signupStatus = 'loading';87 signupStatus = 'loading';
84- if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) {88+ if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !suTurnstileToken) {
85- signupError = 'Please fill all fields';89+ signupError = 'Please complete all fields including the security check';
86 signupStatus = 'error';90 signupStatus = 'error';
87 return;91 return;
88 }92 }
@@ -97,7 +101,13 @@
97 const res = await fetch('/api/register', {101 const res = await fetch('/api/register', {
98 method: 'POST',102 method: 'POST',
99 headers: { 'Content-Type': 'application/json' },103 headers: { 'Content-Type': 'application/json' },
100- body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer })104+ body: JSON.stringify({
105+ suUsername,
106+ suPassword,
107+ suConfirmPassword,
108+ captchaAnswer,
109+ turnstileToken: suTurnstileToken
110+ })
101 });111 });
102 112
103 const data = await res.json() as RegisterResponse;113 const data = await res.json() as RegisterResponse;
@@ -202,6 +212,7 @@
202 <label for="si-password">Mot de passe</label>212 <label for="si-password">Mot de passe</label>
203 <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />213 <input type="password" id="si-password" bind:value={siPassword} placeholder="Mot de passe" />
204 </div>214 </div>
215+ <Turnstile onVerify={(token: string) => siTurnstileToken = token} />
205 {#if error}216 {#if error}
206 <div class="error">{error}</div>217 <div class="error">{error}</div>
207 {/if}218 {/if}
@@ -251,6 +262,7 @@
251 <label for="captcha">Quelle est la signification de PDR ?</label>262 <label for="captcha">Quelle est la signification de PDR ?</label>
252 <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />263 <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Entrez votre réponse" />
253 </div>264 </div>
265+ <Turnstile onVerify={(token: string) => suTurnstileToken = token} />
254 {#if signupError}266 {#if signupError}
255 <div class="error">{signupError}</div>267 <div class="error">{signupError}</div>
256 {/if}268 {/if}
A src/lib/components/turnstile.svelte +123 -0
@@ -0,0 +1,123 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { fade } from 'svelte/transition';
4+ import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
5+ import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile';
6+
7+ let isVerified = $state(false);
8+
9+ type OnVerifyCallback = (token: string) => void;
10+ let { onVerify } = $props<{ onVerify: OnVerifyCallback }>();
11+
12+ let turnstileElement = $state<HTMLDivElement | null>(null);
13+ let widgetId = '';
14+
15+ // Ensure site key is a string and trim any whitespace
16+ const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim();
17+
18+ onMount(() => {
19+ if (!window.turnstile) {
20+ const script = document.createElement('script');
21+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
22+ script.async = true;
23+ script.defer = true;
24+ document.head.appendChild(script);
25+
26+ script.onload = initTurnstile;
27+ } else {
28+ initTurnstile();
29+ }
30+
31+ return () => {
32+ if (widgetId) {
33+ window.turnstile?.remove(widgetId);
34+ }
35+ };
36+ });
37+
38+ function initTurnstile() {
39+ if (!turnstileElement || !window.turnstile) return;
40+
41+ // Validate site key is a non-empty string
42+ if (!siteKey) {
43+ console.error('Turnstile site key is missing');
44+ return;
45+ }
46+
47+ const options: TurnstileOptions = {
48+ sitekey: siteKey,
49+ callback: (token: string) => {
50+ if (typeof onVerify === 'function') {
51+ onVerify(token);
52+ isVerified = true;
53+ } else {
54+ console.error('onVerify callback is not a function');
55+ }
56+ },
57+ 'refresh-expired': 'auto',
58+ size: 'compact',
59+ 'error-callback': (error: unknown) => {
60+ console.error('Turnstile error:', error);
61+ isVerified = false; // Reset verification state on error
62+ }
63+ };
64+
65+ try {
66+ widgetId = window.turnstile.render(turnstileElement, options);
67+
68+ // Add resize handler
69+ const handleResize = () => scaleTurnstileToFit();
70+ window.addEventListener('resize', handleResize);
71+ // Initial scale
72+ scaleTurnstileToFit();
73+
74+ } catch (error) {
75+ console.error('Failed to render Turnstile:', error);
76+ }
77+ }
78+
79+ function scaleTurnstileToFit() {
80+ const container = turnstileElement;
81+ if (!container) return;
82+
83+ const iframe = container.querySelector('iframe');
84+ if (!iframe) return;
85+
86+ const containerWidth = container.offsetWidth;
87+ const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width
88+
89+ iframe.style.transform = `scale(${scale})`;
90+ iframe.style.transformOrigin = 'center center';
91+ }
92+</script>
93+
94+{#if !isVerified}
95+ <div
96+ class="turnstile-wrapper"
97+ transition:fade={{ duration: 300 }}
98+ >
99+ <div bind:this={turnstileElement} class="cf-turnstile" data-theme="light"></div>
100+ </div>
101+{/if}
102+
103+<style>
104+ .turnstile-wrapper {
105+ width: 100%;
106+ display: flex;
107+ justify-content: center;
108+ align-items: center;
109+ min-height: 65px; /* Approximate height for compact size */
110+ overflow: hidden;
111+ }
112+
113+ .cf-turnstile {
114+ display: flex;
115+ justify-content: center;
116+ align-items: center;
117+ }
118+
119+ /* Handle iframe scaling */
120+ :global(.turnstile-wrapper iframe) {
121+ transition: transform 0.2s ease-in-out;
122+ }
123+</style> ⏎̸
@@ -0,0 +1,123 @@
1+<script lang="ts">
2+ import { onMount } from 'svelte';
3+ import { fade } from 'svelte/transition';
4+ import { PUBLIC_TURNSTILE_SITE_KEY } from '$env/static/public';
5+ import type { Turnstile, TurnstileOptions } from '$lib/types/turnstile';
6+
7+ let isVerified = $state(false);
8+
9+ type OnVerifyCallback = (token: string) => void;
10+ let { onVerify } = $props<{ onVerify: OnVerifyCallback }>();
11+
12+ let turnstileElement = $state<HTMLDivElement | null>(null);
13+ let widgetId = '';
14+
15+ // Ensure site key is a string and trim any whitespace
16+ const siteKey = String(PUBLIC_TURNSTILE_SITE_KEY).trim();
17+
18+ onMount(() => {
19+ if (!window.turnstile) {
20+ const script = document.createElement('script');
21+ script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js';
22+ script.async = true;
23+ script.defer = true;
24+ document.head.appendChild(script);
25+
26+ script.onload = initTurnstile;
27+ } else {
28+ initTurnstile();
29+ }
30+
31+ return () => {
32+ if (widgetId) {
33+ window.turnstile?.remove(widgetId);
34+ }
35+ };
36+ });
37+
38+ function initTurnstile() {
39+ if (!turnstileElement || !window.turnstile) return;
40+
41+ // Validate site key is a non-empty string
42+ if (!siteKey) {
43+ console.error('Turnstile site key is missing');
44+ return;
45+ }
46+
47+ const options: TurnstileOptions = {
48+ sitekey: siteKey,
49+ callback: (token: string) => {
50+ if (typeof onVerify === 'function') {
51+ onVerify(token);
52+ isVerified = true;
53+ } else {
54+ console.error('onVerify callback is not a function');
55+ }
56+ },
57+ 'refresh-expired': 'auto',
58+ size: 'compact',
59+ 'error-callback': (error: unknown) => {
60+ console.error('Turnstile error:', error);
61+ isVerified = false; // Reset verification state on error
62+ }
63+ };
64+
65+ try {
66+ widgetId = window.turnstile.render(turnstileElement, options);
67+
68+ // Add resize handler
69+ const handleResize = () => scaleTurnstileToFit();
70+ window.addEventListener('resize', handleResize);
71+ // Initial scale
72+ scaleTurnstileToFit();
73+
74+ } catch (error) {
75+ console.error('Failed to render Turnstile:', error);
76+ }
77+ }
78+
79+ function scaleTurnstileToFit() {
80+ const container = turnstileElement;
81+ if (!container) return;
82+
83+ const iframe = container.querySelector('iframe');
84+ if (!iframe) return;
85+
86+ const containerWidth = container.offsetWidth;
87+ const scale = Math.min(1, containerWidth / 300); // 300px is approximate base width
88+
89+ iframe.style.transform = `scale(${scale})`;
90+ iframe.style.transformOrigin = 'center center';
91+ }
92+</script>
93+
94+{#if !isVerified}
95+ <div
96+ class="turnstile-wrapper"
97+ transition:fade={{ duration: 300 }}
98+ >
99+ <div bind:this={turnstileElement} class="cf-turnstile" data-theme="light"></div>
100+ </div>
101+{/if}
102+
103+<style>
104+ .turnstile-wrapper {
105+ width: 100%;
106+ display: flex;
107+ justify-content: center;
108+ align-items: center;
109+ min-height: 65px; /* Approximate height for compact size */
110+ overflow: hidden;
111+ }
112+
113+ .cf-turnstile {
114+ display: flex;
115+ justify-content: center;
116+ align-items: center;
117+ }
118+
119+ /* Handle iframe scaling */
120+ :global(.turnstile-wrapper iframe) {
121+ transition: transform 0.2s ease-in-out;
122+ }
123+</style> ⏎̸
A src/lib/types/turnstile.d.ts +23 -0
@@ -0,0 +1,23 @@
1+interface Turnstile {
2+ render: (element: HTMLElement, options: TurnstileOptions) => string;
3+ remove: (widgetId: string) => void;
4+}
5+
6+interface TurnstileOptions {
7+ sitekey: string;
8+ callback: (token: string) => void;
9+ "refresh-expired"?: "auto" | "manual";
10+ size?: "invisible" | "normal" | "compact";
11+ "error-callback"?: (error: unknown) => void;
12+ "expired-callback"?: () => void;
13+ theme?: "light" | "dark" | "auto";
14+ language?: string;
15+}
16+
17+declare global {
18+ interface Window {
19+ turnstile?: Turnstile;
20+ }
21+}
22+
23+export type { Turnstile, TurnstileOptions }; ⏎̸
@@ -0,0 +1,23 @@
1+interface Turnstile {
2+ render: (element: HTMLElement, options: TurnstileOptions) => string;
3+ remove: (widgetId: string) => void;
4+}
5+
6+interface TurnstileOptions {
7+ sitekey: string;
8+ callback: (token: string) => void;
9+ "refresh-expired"?: "auto" | "manual";
10+ size?: "invisible" | "normal" | "compact";
11+ "error-callback"?: (error: unknown) => void;
12+ "expired-callback"?: () => void;
13+ theme?: "light" | "dark" | "auto";
14+ language?: string;
15+}
16+
17+declare global {
18+ interface Window {
19+ turnstile?: Turnstile;
20+ }
21+}
22+
23+export type { Turnstile, TurnstileOptions }; ⏎̸
A src/lib/utils/turnstile.server.ts +44 -0
@@ -0,0 +1,44 @@
1+import { TURNSTILE_SECRET_KEY } from '$env/static/private';
2+import { createLogger } from './logger.server';
3+
4+const log = createLogger('turnstile');
5+
6+interface TurnstileVerifyResponse {
7+ "success": boolean;
8+ "error-codes"?: string[];
9+ "challenge_ts"?: string;
10+ "hostname"?: string;
11+ "action"?: string;
12+ "cdata"?: string;
13+}
14+
15+export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> {
16+ try {
17+ const formData = new FormData();
18+ formData.append('secret', TURNSTILE_SECRET_KEY);
19+ formData.append('response', token);
20+ if (remoteip) {
21+ formData.append('remoteip', remoteip);
22+ }
23+
24+ const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
25+ const result = await fetch(url, {
26+ body: formData,
27+ method: 'POST',
28+ });
29+
30+ const outcome: TurnstileVerifyResponse = await result.json();
31+
32+ if (!outcome.success) {
33+ log.warn('Turnstile validation failed', {
34+ errors: outcome["error-codes"],
35+ remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined
36+ });
37+ }
38+
39+ return outcome.success;
40+ } catch (error) {
41+ log.error('Error validating Turnstile token', { error });
42+ return false;
43+ }
44+} ⏎̸
@@ -0,0 +1,44 @@
1+import { TURNSTILE_SECRET_KEY } from '$env/static/private';
2+import { createLogger } from './logger.server';
3+
4+const log = createLogger('turnstile');
5+
6+interface TurnstileVerifyResponse {
7+ "success": boolean;
8+ "error-codes"?: string[];
9+ "challenge_ts"?: string;
10+ "hostname"?: string;
11+ "action"?: string;
12+ "cdata"?: string;
13+}
14+
15+export async function validateTurnstileToken(token: string, remoteip?: string): Promise<boolean> {
16+ try {
17+ const formData = new FormData();
18+ formData.append('secret', TURNSTILE_SECRET_KEY);
19+ formData.append('response', token);
20+ if (remoteip) {
21+ formData.append('remoteip', remoteip);
22+ }
23+
24+ const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
25+ const result = await fetch(url, {
26+ body: formData,
27+ method: 'POST',
28+ });
29+
30+ const outcome: TurnstileVerifyResponse = await result.json();
31+
32+ if (!outcome.success) {
33+ log.warn('Turnstile validation failed', {
34+ errors: outcome["error-codes"],
35+ remoteip: remoteip ? `${remoteip.split('.')[0]}.xxx.xxx.xxx` : undefined
36+ });
37+ }
38+
39+ return outcome.success;
40+ } catch (error) {
41+ log.error('Error validating Turnstile token', { error });
42+ return false;
43+ }
44+} ⏎̸
M src/routes/api/register/+server.ts +75 -41
@@ -3,33 +3,90 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/
33 import db from '$lib/db/db.server';
44 import { users } from '$lib/db/schema';
55 import { hashPassword } from '$lib/utils/password';
6+import { validateTurnstileToken } from '$lib/utils/turnstile.server';
7+import { createLogger } from '$lib/utils/logger.server';
8+
9+const log = createLogger('register-server');
610
711 // In-memory map to track failed captcha attempts per IP
812 const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>();
9-const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
13+
14+// Cleanup old entries every hour
15+setInterval(() => {
16+ const now = Date.now();
17+ const ONE_HOUR = 60 * 60 * 1000;
18+ for (const [ip, data] of captchaAttempts.entries()) {
19+ if (now - data.lastAttempt > ONE_HOUR) {
20+ captchaAttempts.delete(ip);
21+ }
22+ }
23+}, 60 * 60 * 1000);
1024
1125 export const POST: RequestHandler = async ({ request }) => {
12- console.log("[Register] New registration attempt received");
26+ log.debug('New registration attempt received');
1327
14- // Only allow POST
1528 if (request.method !== 'POST') {
29+ log.warn('Invalid method used', { method: request.method });
1630 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 });
1731 }
1832
33+ // Get the IP address for rate limiting and Turnstile validation
34+ const ip = request.headers.get('x-forwarded-for') || 'unknown';
35+ const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
36+ const now = Date.now();
37+ const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
38+
1939 let body: unknown;
2040 try {
2141 body = await request.json();
2242 } catch {
23- console.log("[Register] Invalid JSON payload received");
43+ log.warn('Invalid JSON payload received');
2444 return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 });
2545 }
2646
27- const { suUsername, suPassword, suConfirmPassword, captchaAnswer } = body as { suUsername: string, suPassword: string, suConfirmPassword: string, captchaAnswer: string };
47+ const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as {
48+ suUsername: string,
49+ suPassword: string,
50+ suConfirmPassword: string,
51+ captchaAnswer: string,
52+ turnstileToken: string
53+ };
54+
55+ if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !turnstileToken) {
56+ log.warn('Missing required fields', {
57+ hasUsername: Boolean(suUsername),
58+ hasPassword: Boolean(suPassword),
59+ hasConfirmPassword: Boolean(suConfirmPassword),
60+ hasCaptcha: Boolean(captchaAnswer),
61+ hasTurnstileToken: Boolean(turnstileToken)
62+ });
63+ return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 });
64+ }
65+
66+ // Validate Turnstile token
67+ const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
68+ if (!isValidTurnstile) {
69+ log.warn('Invalid Turnstile token', { maskedIp });
70+ return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 });
71+ }
72+
73+ // Validate PDR captcha
74+ const normalizedAnswer = captchaAnswer.trim().toLowerCase();
75+ if (normalizedAnswer !== 'poudlard' && normalizedAnswer !== 'poudlard rp') {
76+ attemptData.count++;
77+ attemptData.lastAttempt = now;
78+ captchaAttempts.set(ip, attemptData);
79+ log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count });
80+ return new Response(JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError), { status: 400 });
81+ }
82+
83+ // Reset captcha attempts on success
84+ captchaAttempts.delete(ip);
2885
2986 // Destructure and validate required fields from the payload.
3087 if (typeof suUsername !== 'string' || typeof suPassword !== 'string' ||
31- typeof suConfirmPassword !== 'string' || typeof captchaAnswer !== 'string') {
32- console.log("[Register] Missing or invalid input fields");
88+ typeof suConfirmPassword !== 'string') {
89+ log.warn('Missing or invalid input fields');
3390 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 });
3491 }
3592
@@ -37,75 +94,52 @@ export const POST: RequestHandler = async ({ request }) => {
3794 const username = suUsername.trim();
3895 const password = suPassword.trim();
3996 const confirmPassword = suConfirmPassword.trim();
40- const captcha = captchaAnswer.trim().toLowerCase();
4197
4298 // Basic validations.
43- if (!username || !password || !confirmPassword || !captcha) {
99+ if (!username || !password || !confirmPassword) {
100+ log.warn('All fields must be filled');
44101 return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 });
45102 }
46103
47104 if (username.length > 32) {
105+ log.warn('Username must be at most 32 characters');
48106 return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 });
49107 }
50108 // Only allow letters, numbers, underscores, and dashes in the username.
51109 const usernameRegex = /^[a-zA-Z0-9_-]+$/;
52110 if (!usernameRegex.test(username)) {
111+ log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.');
53112 return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 });
54113 }
55114
56115 if (password !== confirmPassword) {
116+ log.warn('Passwords do not match');
57117 return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 });
58118 }
59119
60120 if (password.length < 8 || password.length > 64) {
121+ log.warn('Password must be between 8 and 64 characters');
61122 return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 });
62123 }
63124
64- // Captcha validation logic.
65- // Expected captcha answer.
66- const expectedCaptcha = "point de rencontre";
67- // Get the IP address for rate limiting. In production, a more reliable method should be used.
68- const ip = request.headers.get('x-forwarded-for') || 'unknown';
69- const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
70- const now = Date.now();
71- const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
72- // If the user has failed three or more times, apply aggressive exponential backoff.
73- if (attemptData.count >= 3) {
74- const delay = Math.pow(2, attemptData.count - 3 + 1) * 1000; // delay in milliseconds
75- console.log(`[Register] Rate limit exceeded for IP ${maskedIp} - applying ${delay}ms delay`);
76- await sleep(delay);
77- }
78- if (captcha !== expectedCaptcha) {
79- attemptData.count++;
80- attemptData.lastAttempt = now;
81- captchaAttempts.set(ip, attemptData);
82- console.log(`[Register] Captcha failed for IP ${maskedIp}. Attempt count: ${attemptData.count}`);
83- const remaining = Math.max(0, 3 - attemptData.count);
84- return new Response(JSON.stringify({ error: `Incorrect captcha answer. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` } as RegisterResponseError), { status: 400 });
85- }
86- // Reset captcha attempt data on success.
87- if (captcha === expectedCaptcha) {
88- captchaAttempts.delete(ip);
89- }
90-
91125 // Securely hash the user's password.
92126 let hashedPassword: string;
93127 try {
94128 hashedPassword = await hashPassword(password);
95129 } catch {
96- console.log("[Register] Error hashing password");
130+ log.error('Error hashing password');
97131 return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 });
98132 }
99133 // Insert the new user into the database.
100134 try {
101135 await db.insert(users).values({
102- password: hashedPassword,
103136 nickname: username,
137+ password: hashedPassword,
104138 createdAt: Date.now()
105139 });
106- console.log(`[Register] User ${username} registered successfully`);
107- } catch (err) {
108- console.log("Database insertion error", err);
140+ log.info(`User ${username} registered successfully`);
141+ } catch (err: unknown) {
142+ log.error('Database insertion error', { error: err as object });
109143 // Assume a duplicate user error if the email (or derived unique field) already exists.
110144 return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 });
111145 }
@@ -3,33 +3,90 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/
3 import db from '$lib/db/db.server';3 import db from '$lib/db/db.server';
4 import { users } from '$lib/db/schema';4 import { users } from '$lib/db/schema';
5 import { hashPassword } from '$lib/utils/password';5 import { hashPassword } from '$lib/utils/password';
6+import { validateTurnstileToken } from '$lib/utils/turnstile.server';
7+import { createLogger } from '$lib/utils/logger.server';
8+
9+const log = createLogger('register-server');
6 10
7 // In-memory map to track failed captcha attempts per IP11 // In-memory map to track failed captcha attempts per IP
8 const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>();12 const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>();
9-const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));13+
14+// Cleanup old entries every hour
15+setInterval(() => {
16+ const now = Date.now();
17+ const ONE_HOUR = 60 * 60 * 1000;
18+ for (const [ip, data] of captchaAttempts.entries()) {
19+ if (now - data.lastAttempt > ONE_HOUR) {
20+ captchaAttempts.delete(ip);
21+ }
22+ }
23+}, 60 * 60 * 1000);
10 24
11 export const POST: RequestHandler = async ({ request }) => {25 export const POST: RequestHandler = async ({ request }) => {
12- console.log("[Register] New registration attempt received");26+ log.debug('New registration attempt received');
13 27
14- // Only allow POST
15 if (request.method !== 'POST') {28 if (request.method !== 'POST') {
29+ log.warn('Invalid method used', { method: request.method });
16 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 });30 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 });
17 }31 }
18 32
33+ // Get the IP address for rate limiting and Turnstile validation
34+ const ip = request.headers.get('x-forwarded-for') || 'unknown';
35+ const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
36+ const now = Date.now();
37+ const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
38+
19 let body: unknown;39 let body: unknown;
20 try {40 try {
21 body = await request.json();41 body = await request.json();
22 } catch {42 } catch {
23- console.log("[Register] Invalid JSON payload received");43+ log.warn('Invalid JSON payload received');
24 return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 });44 return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 });
25 }45 }
26 46
27- const { suUsername, suPassword, suConfirmPassword, captchaAnswer } = body as { suUsername: string, suPassword: string, suConfirmPassword: string, captchaAnswer: string };47+ const { suUsername, suPassword, suConfirmPassword, captchaAnswer, turnstileToken } = body as {
48+ suUsername: string,
49+ suPassword: string,
50+ suConfirmPassword: string,
51+ captchaAnswer: string,
52+ turnstileToken: string
53+ };
54+
55+ if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || !turnstileToken) {
56+ log.warn('Missing required fields', {
57+ hasUsername: Boolean(suUsername),
58+ hasPassword: Boolean(suPassword),
59+ hasConfirmPassword: Boolean(suConfirmPassword),
60+ hasCaptcha: Boolean(captchaAnswer),
61+ hasTurnstileToken: Boolean(turnstileToken)
62+ });
63+ return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 });
64+ }
65+
66+ // Validate Turnstile token
67+ const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
68+ if (!isValidTurnstile) {
69+ log.warn('Invalid Turnstile token', { maskedIp });
70+ return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 });
71+ }
72+
73+ // Validate PDR captcha
74+ const normalizedAnswer = captchaAnswer.trim().toLowerCase();
75+ if (normalizedAnswer !== 'poudlard' && normalizedAnswer !== 'poudlard rp') {
76+ attemptData.count++;
77+ attemptData.lastAttempt = now;
78+ captchaAttempts.set(ip, attemptData);
79+ log.warn('Invalid captcha answer', { maskedIp, attemptCount: attemptData.count });
80+ return new Response(JSON.stringify({ error: 'Invalid answer to the PDR question' } as RegisterResponseError), { status: 400 });
81+ }
82+
83+ // Reset captcha attempts on success
84+ captchaAttempts.delete(ip);
28 85
29 // Destructure and validate required fields from the payload.86 // Destructure and validate required fields from the payload.
30 if (typeof suUsername !== 'string' || typeof suPassword !== 'string' ||87 if (typeof suUsername !== 'string' || typeof suPassword !== 'string' ||
31- typeof suConfirmPassword !== 'string' || typeof captchaAnswer !== 'string') {88+ typeof suConfirmPassword !== 'string') {
32- console.log("[Register] Missing or invalid input fields");89+ log.warn('Missing or invalid input fields');
33 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 });90 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 });
34 }91 }
35 92
@@ -37,75 +94,52 @@ export const POST: RequestHandler = async ({ request }) => {
37 const username = suUsername.trim();94 const username = suUsername.trim();
38 const password = suPassword.trim();95 const password = suPassword.trim();
39 const confirmPassword = suConfirmPassword.trim();96 const confirmPassword = suConfirmPassword.trim();
40- const captcha = captchaAnswer.trim().toLowerCase();
41 97
42 // Basic validations.98 // Basic validations.
43- if (!username || !password || !confirmPassword || !captcha) {99+ if (!username || !password || !confirmPassword) {
100+ log.warn('All fields must be filled');
44 return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 });101 return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 });
45 }102 }
46 103
47 if (username.length > 32) {104 if (username.length > 32) {
105+ log.warn('Username must be at most 32 characters');
48 return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 });106 return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 });
49 }107 }
50 // Only allow letters, numbers, underscores, and dashes in the username.108 // Only allow letters, numbers, underscores, and dashes in the username.
51 const usernameRegex = /^[a-zA-Z0-9_-]+$/;109 const usernameRegex = /^[a-zA-Z0-9_-]+$/;
52 if (!usernameRegex.test(username)) {110 if (!usernameRegex.test(username)) {
111+ log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.');
53 return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 });112 return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 });
54 }113 }
55 114
56 if (password !== confirmPassword) {115 if (password !== confirmPassword) {
116+ log.warn('Passwords do not match');
57 return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 });117 return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 });
58 }118 }
59 119
60 if (password.length < 8 || password.length > 64) {120 if (password.length < 8 || password.length > 64) {
121+ log.warn('Password must be between 8 and 64 characters');
61 return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 });122 return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 });
62 }123 }
63 124
64- // Captcha validation logic.
65- // Expected captcha answer.
66- const expectedCaptcha = "point de rencontre";
67- // Get the IP address for rate limiting. In production, a more reliable method should be used.
68- const ip = request.headers.get('x-forwarded-for') || 'unknown';
69- const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
70- const now = Date.now();
71- const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
72- // If the user has failed three or more times, apply aggressive exponential backoff.
73- if (attemptData.count >= 3) {
74- const delay = Math.pow(2, attemptData.count - 3 + 1) * 1000; // delay in milliseconds
75- console.log(`[Register] Rate limit exceeded for IP ${maskedIp} - applying ${delay}ms delay`);
76- await sleep(delay);
77- }
78- if (captcha !== expectedCaptcha) {
79- attemptData.count++;
80- attemptData.lastAttempt = now;
81- captchaAttempts.set(ip, attemptData);
82- console.log(`[Register] Captcha failed for IP ${maskedIp}. Attempt count: ${attemptData.count}`);
83- const remaining = Math.max(0, 3 - attemptData.count);
84- return new Response(JSON.stringify({ error: `Incorrect captcha answer. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` } as RegisterResponseError), { status: 400 });
85- }
86- // Reset captcha attempt data on success.
87- if (captcha === expectedCaptcha) {
88- captchaAttempts.delete(ip);
89- }
90-
91 // Securely hash the user's password.125 // Securely hash the user's password.
92 let hashedPassword: string;126 let hashedPassword: string;
93 try {127 try {
94 hashedPassword = await hashPassword(password);128 hashedPassword = await hashPassword(password);
95 } catch {129 } catch {
96- console.log("[Register] Error hashing password");130+ log.error('Error hashing password');
97 return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 });131 return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 });
98 }132 }
99 // Insert the new user into the database.133 // Insert the new user into the database.
100 try {134 try {
101 await db.insert(users).values({135 await db.insert(users).values({
102- password: hashedPassword,
103 nickname: username,136 nickname: username,
137+ password: hashedPassword,
104 createdAt: Date.now()138 createdAt: Date.now()
105 });139 });
106- console.log(`[Register] User ${username} registered successfully`);140+ log.info(`User ${username} registered successfully`);
107- } catch (err) {141+ } catch (err: unknown) {
108- console.log("Database insertion error", err);142+ log.error('Database insertion error', { error: err as object });
109 // Assume a duplicate user error if the email (or derived unique field) already exists.143 // Assume a duplicate user error if the email (or derived unique field) already exists.
110 return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 });144 return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 });
111 }145 }
M src/routes/api/session/login/+server.ts +13 -4
@@ -8,6 +8,7 @@ import { generateSessionToken, createSession } from '$lib/api/session.server';
88 import { setSessionTokenCookie } from '$lib/api/session.cookie';
99 import { createSafeUser } from '$lib/types/chat';
1010 import { createLogger } from '$lib/utils/logger.server';
11+import { validateTurnstileToken } from '$lib/utils/turnstile.server';
1112
1213 const log = createLogger('login-server');
1314
@@ -35,7 +36,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
3536 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });
3637 }
3738
38- // Get the IP address for rate limiting
39+ // Get the IP address for rate limiting and Turnstile validation
3940 const ip = request.headers.get('x-forwarded-for') || 'unknown';
4041 const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
4142 const now = Date.now();
@@ -56,16 +57,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
5657 return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 });
5758 }
5859
59- const { username, password } = body as { username: string, password: string };
60+ const { username, password, turnstileToken } = body as { username: string, password: string, turnstileToken: string };
6061
61- if (typeof username !== 'string' || typeof password !== 'string') {
62+ if (typeof username !== 'string' || typeof password !== 'string' || typeof turnstileToken !== 'string') {
6263 log.warn('Missing or invalid input fields', {
6364 hasUsername: typeof username === 'string',
64- hasPassword: typeof password === 'string'
65+ hasPassword: typeof password === 'string',
66+ hasTurnstileToken: typeof turnstileToken === 'string'
6567 });
6668 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });
6769 }
6870
71+ // Validate Turnstile token
72+ const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
73+ if (!isValidTurnstile) {
74+ log.warn('Invalid Turnstile token', { maskedIp });
75+ return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 });
76+ }
77+
6978 // Find user by username
7079 const user = await db.query.users.findFirst({
7180 where: eq(users.nickname, username.trim())
@@ -8,6 +8,7 @@ import { generateSessionToken, createSession } from '$lib/api/session.server';
8 import { setSessionTokenCookie } from '$lib/api/session.cookie';8 import { setSessionTokenCookie } from '$lib/api/session.cookie';
9 import { createSafeUser } from '$lib/types/chat';9 import { createSafeUser } from '$lib/types/chat';
10 import { createLogger } from '$lib/utils/logger.server';10 import { createLogger } from '$lib/utils/logger.server';
11+import { validateTurnstileToken } from '$lib/utils/turnstile.server';
11 12
12 const log = createLogger('login-server');13 const log = createLogger('login-server');
13 14
@@ -35,7 +36,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
35 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });36 return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });
36 }37 }
37 38
38- // Get the IP address for rate limiting39+ // Get the IP address for rate limiting and Turnstile validation
39 const ip = request.headers.get('x-forwarded-for') || 'unknown';40 const ip = request.headers.get('x-forwarded-for') || 'unknown';
40 const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');41 const maskedIp = ip.split('.').map((octet, idx) => idx < 3 ? 'xxx' : octet).join('.');
41 const now = Date.now();42 const now = Date.now();
@@ -56,16 +57,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
56 return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 });57 return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 });
57 }58 }
58 59
59- const { username, password } = body as { username: string, password: string };60+ const { username, password, turnstileToken } = body as { username: string, password: string, turnstileToken: string };
60 61
61- if (typeof username !== 'string' || typeof password !== 'string') {62+ if (typeof username !== 'string' || typeof password !== 'string' || typeof turnstileToken !== 'string') {
62 log.warn('Missing or invalid input fields', { 63 log.warn('Missing or invalid input fields', {
63 hasUsername: typeof username === 'string',64 hasUsername: typeof username === 'string',
64- hasPassword: typeof password === 'string'65+ hasPassword: typeof password === 'string',
66+ hasTurnstileToken: typeof turnstileToken === 'string'
65 });67 });
66 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });68 return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });
67 }69 }
68 70
71+ // Validate Turnstile token
72+ const isValidTurnstile = await validateTurnstileToken(turnstileToken, ip);
73+ if (!isValidTurnstile) {
74+ log.warn('Invalid Turnstile token', { maskedIp });
75+ return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), { status: 400 });
76+ }
77+
69 // Find user by username78 // Find user by username
70 const user = await db.query.users.findFirst({79 const user = await db.query.users.findFirst({
71 where: eq(users.nickname, username.trim())80 where: eq(users.nickname, username.trim())

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 8

↑↓ moveenter openesc close