+server.ts RawHistoryFolder

1import type { RequestHandler } from '@sveltejs/kit';
2import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3import { users } from '$lib/db/convex.server';
4import { verifyPassword } from '$lib/utils/password';
5import { generateSessionToken, createSession } from '$lib/api/session.server';
6import { setSessionTokenCookie } from '$lib/api/session.cookie';
7import { createSafeUser } from '$lib/types/chat';
8import { createLogger } from '$lib/utils/logger.server';
9import { loginSchema } from '$lib/validation/password';
10import { z } from 'zod';
11
12const log = createLogger('login-server');
13
14// In-memory map to track failed login attempts per IP
15const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
16const MAX_ATTEMPTS = 3;
17const ONE_HOUR = 60 * 60 * 1000;
18const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
19
20// Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope
21let lastCleanup = 0;
22function cleanupOldAttempts() {
23	const now = Date.now();
24	if (now - lastCleanup < ONE_HOUR) return;
25	lastCleanup = now;
26
27	for (const [ip, data] of loginAttempts.entries()) {
28		if (now - data.lastAttempt > ONE_HOUR) {
29			loginAttempts.delete(ip);
30		}
31	}
32}
33
34export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => {
35	log.debug('New login attempt received');
36
37	// Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility)
38	cleanupOldAttempts();
39
40	if (request.method !== 'POST') {
41		log.warn('Invalid method used', { method: request.method });
42		return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), {
43			status: 405
44		});
45	}
46
47	// Get the IP address for rate limiting
48	const ip = getClientAddress();
49	const maskedIp = ip
50		.split('.')
51		.map((octet, idx) => (idx < 3 ? 'xxx' : octet))
52		.join('.');
53	const now = Date.now();
54	const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
55
56	if (attemptData.count >= MAX_ATTEMPTS) {
57		const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000;
58		log.warn('Rate limit exceeded', { maskedIp, delay });
59		await sleep(delay);
60	}
61
62	let body: unknown;
63	try {
64		body = await request.json();
65	} catch {
66		log.warn('Invalid JSON payload received');
67		return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), {
68			status: 400
69		});
70	}
71
72	const { username, password } = body as {
73		username: string;
74		password: string;
75	};
76
77	// Validate input using Zod schema
78	try {
79		loginSchema.parse({ username, password });
80	} catch (err) {
81		const zodError = err as z.ZodError;
82		const errorMessage = zodError.issues?.[0]?.message || 'Invalid input data';
83		log.warn('Login validation failed', { error: errorMessage });
84		return new Response(JSON.stringify({ error: errorMessage } as LoginResponseError), {
85			status: 400
86		});
87	}
88
89	// Find user by username using Convex
90	const user = await users.getByNickname(username.trim());
91
92	if (!user) {
93		attemptData.count++;
94		attemptData.lastAttempt = now;
95		loginAttempts.set(ip, attemptData);
96		log.warn('Login failed - user not found', {
97			maskedIp,
98			attemptCount: attemptData.count,
99			username: username.trim()
100		});
101		const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
102		return new Response(
103			JSON.stringify({
104				error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}`
105			} as LoginResponseError),
106			{ status: 401 }
107		);
108	}
109
110	// Use native Convex _id
111	const userId = user._id;
112
113	// Verify password
114	try {
115		const isValid = await verifyPassword(password, user.password);
116		if (!isValid) {
117			attemptData.count++;
118			attemptData.lastAttempt = now;
119			loginAttempts.set(ip, attemptData);
120			log.warn('Login failed - invalid password', {
121				maskedIp,
122				attemptCount: attemptData.count,
123				userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
124			});
125			const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
126			return new Response(
127				JSON.stringify({
128					error: `Invalid username or password. ${remaining > 0 ? remaining + ' attempt(s) remaining.' : 'Please wait before trying again.'}`
129				} as LoginResponseError),
130				{ status: 401 }
131			);
132		}
133	} catch (error) {
134		log.error('Error verifying password', { error });
135		return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), {
136			status: 500
137		});
138	}
139
140	// Reset login attempt data on success
141	loginAttempts.delete(ip);
142
143	// Generate session token and create session
144	const token = generateSessionToken();
145	const session = await createSession(token, userId);
146
147	// Update user status to online using Convex
148	await users.updateStatus(userId, 'online');
149
150	// Set session cookie
151	setSessionTokenCookie({ cookies }, token, session.expiresAt);
152
153	log.info('Login successful', {
154		userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
155		expiresAt: new Date(session.expiresAt).toISOString()
156	});
157
158	// Return user with native Convex ID
159	return new Response(
160		JSON.stringify({
161			success: true,
162			user: createSafeUser({
163				id: userId,
164				nickname: user.nickname,
165				status: 'online',
166				avatarUrl: user.avatarUrl,
167				lastSeen: user.lastSeen,
168				lastReadMentionTimestamp: user.lastReadMentionTimestamp
169			})
170		} as LoginResponseSuccess),
171		{
172			status: 200
173		}
174	);
175};

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