v0.1.5 0b43029

+server.ts RawHistoryFolder

1import type { RequestHandler } from '@sveltejs/kit';
2import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3import db from '$lib/db/db.server';
4import { users } from '$lib/db/schema';
5import { eq } from 'drizzle-orm/sql';
6import { verifyPassword } from '$lib/utils/password';
7import { generateSessionToken, createSession } from '$lib/api/session.server';
8import { setSessionTokenCookie } from '$lib/api/session.cookie';
9import { createSafeUser } from '$lib/types/chat';
10import { createLogger } from '$lib/utils/logger.server';
11import { validateTurnstileToken } from '$lib/utils/turnstile.server';
12
13const log = createLogger('login-server');
14const isDev = process.env.NODE_ENV === 'development';
15
16// In-memory map to track failed login attempts per IP
17const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
18const MAX_ATTEMPTS = 3;
19const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
20
21// Cleanup old entries every hour
22setInterval(() => {
23    const now = Date.now();
24    const ONE_HOUR = 60 * 60 * 1000;
25    for (const [ip, data] of loginAttempts.entries()) {
26        if (now - data.lastAttempt > ONE_HOUR) {
27            loginAttempts.delete(ip);
28        }
29    }
30}, 60 * 60 * 1000);
31
32// Helper to parse clearance cookie from headers
33function getClearanceCookie(cookieHeader: string | null): string | null {
34    if (!cookieHeader) return null;
35    // Use a case-insensitive regex to match either "cf-clearance" or "cf_clearance"
36    const regex = /cf[-_]clearance=([^;]+)/i;
37    const match = cookieHeader.match(regex);
38    return match ? match[1] : null;
39}
40
41export const POST: RequestHandler = async ({ request, cookies }) => {
42    log.debug('New login attempt received');
43
44    if (request.method !== 'POST') {
45        log.warn('Invalid method used', { method: request.method });
46        return new Response(
47            JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError),
48            { status: 405 }
49        );
50    }
51
52    // Get the IP address for rate limiting and Turnstile validation
53    const ip = request.headers.get('x-forwarded-for') || 'unknown';
54    const maskedIp = ip.split('.').map((octet, idx) => (idx < 3 ? 'xxx' : octet)).join('.');
55    const now = Date.now();
56    const attemptData = loginAttempts.get(ip) || { count: 0, lastAttempt: 0 };
57
58    if (attemptData.count >= MAX_ATTEMPTS) {
59        const delay = Math.pow(2, attemptData.count - MAX_ATTEMPTS + 1) * 1000;
60        log.warn('Rate limit exceeded', { maskedIp, delay });
61        await sleep(delay);
62    }
63
64    let body: unknown;
65    try {
66        body = await request.json();
67    } catch {
68        log.warn('Invalid JSON payload received');
69        return new Response(
70            JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError),
71            { status: 400 }
72        );
73    }
74
75    // Extract cookie header and clearance cookie BEFORE validating input.
76    const cookieHeader = request.headers.get('cookie');
77    const clearanceCookie = getClearanceCookie(cookieHeader);
78
79    // Note: Mark turnstileToken as optional if a valid clearance cookie exists or in dev mode.
80    const { username, password, turnstileToken } = body as {
81        username: string;
82        password: string;
83        turnstileToken?: string;
84    };
85
86    if (
87        typeof username !== 'string' ||
88        typeof password !== 'string' ||
89        (!isDev && !clearanceCookie && typeof turnstileToken !== 'string')
90    ) {
91        log.warn('Missing or invalid input fields', {
92            hasUsername: typeof username === 'string',
93            hasPassword: typeof password === 'string',
94            hasTurnstileToken: typeof turnstileToken === 'string',
95            isDev
96        });
97        return new Response(
98            JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError),
99            { status: 400 }
100        );
101    }
102
103    // Check for dev mode or clearance cookie before validating Turnstile token.
104    let isValidTurnstile = false;
105    if (isDev) {
106        log.debug('Development mode: bypassing token challenge.');
107        isValidTurnstile = true;
108    } else if (clearanceCookie) {
109        log.debug('Clearance cookie detected; bypassing token challenge.');
110        isValidTurnstile = true;
111    } else {
112        isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip);
113    }
114
115    if (!isValidTurnstile) {
116        log.warn('Invalid Turnstile token', { maskedIp });
117        return new Response(
118            JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError),
119            { status: 400 }
120        );
121    }
122
123    // Find user by username
124    const user = await db.query.users.findFirst({
125        where: eq(users.nickname, username.trim())
126    });
127
128    if (!user) {
129        attemptData.count++;
130        attemptData.lastAttempt = now;
131        loginAttempts.set(ip, attemptData);
132        log.warn('Login failed - user not found', { 
133            maskedIp, 
134            attemptCount: attemptData.count,
135            username: username.trim()
136        });
137        const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
138        return new Response(JSON.stringify({ 
139            error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` 
140        } as LoginResponseError), { status: 401 });
141    }
142
143    // Verify password
144    try {
145        const isValid = await verifyPassword(password.trim(), user.password);
146        if (!isValid) {
147            attemptData.count++;
148            attemptData.lastAttempt = now;
149            loginAttempts.set(ip, attemptData);
150            log.warn('Login failed - invalid password', { 
151                maskedIp, 
152                attemptCount: attemptData.count,
153                userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`
154            });
155            const remaining = Math.max(0, MAX_ATTEMPTS - attemptData.count);
156            return new Response(JSON.stringify({ 
157                error: `Invalid username or password. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` 
158            } as LoginResponseError), { status: 401 });
159        }
160    } catch (error) {
161        log.error('Error verifying password', { error });
162        return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
163    }
164
165    // Reset login attempt data on success
166    loginAttempts.delete(ip);
167
168    // Generate session token and create session
169    const token = generateSessionToken();
170    const session = await createSession(token, user.id);
171
172    // Update user status to online
173    await db.update(users)
174        .set({ 
175            status: 'online',
176            lastSeen: Date.now()
177        })
178        .where(eq(users.id, user.id));
179
180    // Set session cookie
181    setSessionTokenCookie({ cookies }, token, session.expiresAt);
182
183    log.info('Login successful', { 
184        userId: `${user.id.slice(0, 4)}...${user.id.slice(-4)}`,
185        expiresAt: new Date(session.expiresAt).toISOString()
186    });
187
188    return new Response(JSON.stringify({ 
189        success: true,
190        user: createSafeUser(user)
191    } as LoginResponseSuccess), { 
192        status: 200 
193    });
194}; 

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