main f6d8405

http.ts RawHistoryFolder

1import { httpRouter } from 'convex/server';
2import { httpAction } from './_generated/server';
3import { internal } from './_generated/api';
4import type { Id } from './_generated/dataModel';
5
6const http = httpRouter();
7
8// Constant-time string comparison to prevent timing attacks
9function constantTimeCompare(a: string, b: string): boolean {
10	if (a.length !== b.length) {
11		// Still do work to avoid leaking length via timing
12		let result = a.length ^ b.length;
13		for (let i = 0; i < a.length; i++) {
14			result |= a.charCodeAt(i) ^ a.charCodeAt(i);
15		}
16		return result === 0; // always false when lengths differ
17	}
18	let result = 0;
19	for (let i = 0; i < a.length; i++) {
20		result |= a.charCodeAt(i) ^ b.charCodeAt(i);
21	}
22	return result === 0;
23}
24
25// Validate the secret header for server-to-server calls
26function validateSecret(request: Request): boolean {
27	const secret = request.headers.get('X-Convex-Secret');
28	const expectedSecret = process.env.CONVEX_API_SECRET;
29
30	if (!expectedSecret) {
31		console.error('CONVEX_API_SECRET environment variable not set');
32		return false;
33	}
34
35	if (!secret) {
36		return false;
37	}
38
39	return constantTimeCompare(secret, expectedSecret);
40}
41
42// Generic error response
43function unauthorizedResponse() {
44	return new Response(JSON.stringify({ error: 'Unauthorized' }), {
45		status: 401,
46		headers: { 'Content-Type': 'application/json' }
47	});
48}
49
50function errorResponse(message: string, status = 400) {
51	return new Response(JSON.stringify({ error: message }), {
52		status,
53		headers: { 'Content-Type': 'application/json' }
54	});
55}
56
57// Helper function to safely parse JSON body
58async function parseJsonBody<T>(request: Request): Promise<T | null> {
59	try {
60		return (await request.json()) as T;
61	} catch {
62		return null;
63	}
64}
65
66// ============ USERS (server-side only) ============
67
68// Get user by nickname - for login
69http.route({
70	path: '/api/users/getByNickname',
71	method: 'POST',
72	handler: httpAction(async (ctx, request) => {
73		if (!validateSecret(request)) return unauthorizedResponse();
74
75		const body = await parseJsonBody<{ nickname: string }>(request);
76		if (!body) return errorResponse('Invalid JSON', 400);
77
78		const user = await ctx.runQuery(internal.usersInternal.getByNickname, {
79			nickname: body.nickname
80		});
81
82		return new Response(JSON.stringify(user), {
83			headers: { 'Content-Type': 'application/json' }
84		});
85	})
86});
87
88// Create user - for registration
89http.route({
90	path: '/api/users/create',
91	method: 'POST',
92	handler: httpAction(async (ctx, request) => {
93		if (!validateSecret(request)) return unauthorizedResponse();
94
95		const body = await parseJsonBody<{ nickname: string; password: string }>(request);
96		if (!body) return errorResponse('Invalid JSON', 400);
97
98		try {
99			const userId = await ctx.runMutation(internal.usersInternal.create, {
100				nickname: body.nickname,
101				password: body.password
102			});
103			return new Response(JSON.stringify({ userId }), {
104				headers: { 'Content-Type': 'application/json' }
105			});
106		} catch (e) {
107			return errorResponse(e instanceof Error ? e.message : 'Failed to create user');
108		}
109	})
110});
111
112// Get user by ID
113http.route({
114	path: '/api/users/getById',
115	method: 'POST',
116	handler: httpAction(async (ctx, request) => {
117		if (!validateSecret(request)) return unauthorizedResponse();
118
119		const body = await parseJsonBody<{ id: string }>(request);
120		if (!body) return errorResponse('Invalid JSON', 400);
121
122		const user = await ctx.runQuery(internal.usersInternal.getById, { id: body.id as Id<'users'> });
123
124		return new Response(JSON.stringify(user), {
125			headers: { 'Content-Type': 'application/json' }
126		});
127	})
128});
129
130// List all users
131http.route({
132	path: '/api/users/list',
133	method: 'POST',
134	handler: httpAction(async (ctx, request) => {
135		if (!validateSecret(request)) return unauthorizedResponse();
136
137		const users = await ctx.runQuery(internal.usersInternal.list);
138
139		return new Response(JSON.stringify(users), {
140			headers: { 'Content-Type': 'application/json' }
141		});
142	})
143});
144
145// Update user status
146http.route({
147	path: '/api/users/updateStatus',
148	method: 'POST',
149	handler: httpAction(async (ctx, request) => {
150		if (!validateSecret(request)) return unauthorizedResponse();
151
152		const body = await parseJsonBody<{ userId: string; status: string }>(request);
153		if (!body) return errorResponse('Invalid JSON', 400);
154
155		try {
156			const user = await ctx.runMutation(internal.usersInternal.updateStatus, {
157				id: body.userId as Id<'users'>,
158				status: body.status
159			});
160			return new Response(JSON.stringify(user), {
161				headers: { 'Content-Type': 'application/json' }
162			});
163		} catch (e) {
164			return errorResponse(e instanceof Error ? e.message : 'Failed to update status');
165		}
166	})
167});
168
169// Set all users offline (server startup)
170http.route({
171	path: '/api/users/setAllOffline',
172	method: 'POST',
173	handler: httpAction(async (ctx, request) => {
174		if (!validateSecret(request)) return unauthorizedResponse();
175
176		const result = await ctx.runMutation(internal.usersInternal.setAllOffline);
177
178		return new Response(JSON.stringify(result), {
179			headers: { 'Content-Type': 'application/json' }
180		});
181	})
182});
183
184// Update last read mention timestamp
185http.route({
186	path: '/api/users/updateLastReadMentionTimestamp',
187	method: 'POST',
188	handler: httpAction(async (ctx, request) => {
189		if (!validateSecret(request)) return unauthorizedResponse();
190
191		const body = await parseJsonBody<{ userId: string; timestamp: number }>(request);
192		if (!body) return errorResponse('Invalid JSON', 400);
193
194		try {
195			const result = await ctx.runMutation(internal.usersInternal.updateLastReadMentionTimestamp, {
196				id: body.userId as Id<'users'>,
197				timestamp: body.timestamp
198			});
199			return new Response(JSON.stringify(result), {
200				headers: { 'Content-Type': 'application/json' }
201			});
202		} catch (e) {
203			return errorResponse(e instanceof Error ? e.message : 'Failed to update timestamp');
204		}
205	})
206});
207
208// ============ SESSIONS (server-side only) ============
209
210// Create session - for login
211http.route({
212	path: '/api/sessions/create',
213	method: 'POST',
214	handler: httpAction(async (ctx, request) => {
215		if (!validateSecret(request)) return unauthorizedResponse();
216
217		const body = await parseJsonBody<{ tokenHash: string; userId: string; expiresAt: number }>(
218			request
219		);
220		if (!body) return errorResponse('Invalid JSON', 400);
221
222		try {
223			const result = await ctx.runMutation(internal.sessionsInternal.create, {
224				tokenHash: body.tokenHash,
225				userId: body.userId as Id<'users'>,
226				expiresAt: body.expiresAt
227			});
228			return new Response(JSON.stringify(result), {
229				headers: { 'Content-Type': 'application/json' }
230			});
231		} catch (e) {
232			return errorResponse(e instanceof Error ? e.message : 'Failed to create session');
233		}
234	})
235});
236
237// Validate session
238http.route({
239	path: '/api/sessions/validate',
240	method: 'POST',
241	handler: httpAction(async (ctx, request) => {
242		if (!validateSecret(request)) return unauthorizedResponse();
243
244		const body = await parseJsonBody<{ tokenHash: string }>(request);
245		if (!body) return errorResponse('Invalid JSON', 400);
246
247		const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, {
248			tokenHash: body.tokenHash
249		});
250
251		return new Response(JSON.stringify(result), {
252			headers: { 'Content-Type': 'application/json' }
253		});
254	})
255});
256
257// Remove session - for logout
258http.route({
259	path: '/api/sessions/remove',
260	method: 'POST',
261	handler: httpAction(async (ctx, request) => {
262		if (!validateSecret(request)) return unauthorizedResponse();
263
264		const body = await parseJsonBody<{ tokenHash: string }>(request);
265		if (!body) return errorResponse('Invalid JSON', 400);
266
267		await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash: body.tokenHash });
268
269		return new Response(JSON.stringify({ success: true }), {
270			headers: { 'Content-Type': 'application/json' }
271		});
272	})
273});
274
275// ============ CHAT ROOMS (server-side only) ============
276
277// Get or create default room
278http.route({
279	path: '/api/chatRooms/getOrCreateDefault',
280	method: 'POST',
281	handler: httpAction(async (ctx, request) => {
282		if (!validateSecret(request)) return unauthorizedResponse();
283
284		const room = await ctx.runMutation(internal.chatRoomsInternal.getOrCreateDefault);
285
286		return new Response(JSON.stringify(room), {
287			headers: { 'Content-Type': 'application/json' }
288		});
289	})
290});
291
292// Get room by ID
293http.route({
294	path: '/api/chatRooms/getById',
295	method: 'POST',
296	handler: httpAction(async (ctx, request) => {
297		if (!validateSecret(request)) return unauthorizedResponse();
298
299		const body = await parseJsonBody<{ id: string }>(request);
300		if (!body) return errorResponse('Invalid JSON', 400);
301
302		const room = await ctx.runQuery(internal.chatRoomsInternal.getById, {
303			id: body.id as Id<'chatRooms'>
304		});
305
306		return new Response(JSON.stringify(room), {
307			headers: { 'Content-Type': 'application/json' }
308		});
309	})
310});
311
312// ============ MESSAGES (server-side only) ============
313
314// Get messages by room - for initial load
315http.route({
316	path: '/api/messages/getByRoom',
317	method: 'POST',
318	handler: httpAction(async (ctx, request) => {
319		if (!validateSecret(request)) return unauthorizedResponse();
320
321		const body = await parseJsonBody<{ roomId: string; limit?: number; beforeTimestamp?: number }>(
322			request
323		);
324		if (!body) return errorResponse('Invalid JSON', 400);
325
326		const result = await ctx.runQuery(internal.messagesInternal.getByRoom, {
327			roomId: body.roomId as Id<'chatRooms'>,
328			limit: body.limit,
329			beforeTimestamp: body.beforeTimestamp
330		});
331
332		return new Response(JSON.stringify(result), {
333			headers: { 'Content-Type': 'application/json' }
334		});
335	})
336});
337
338// Send message
339http.route({
340	path: '/api/messages/send',
341	method: 'POST',
342	handler: httpAction(async (ctx, request) => {
343		if (!validateSecret(request)) return unauthorizedResponse();
344
345		const body = await parseJsonBody<{
346			chatRoomId: string;
347			senderId: string;
348			content: string;
349			type: string;
350			styleData?: string;
351			hasFormatting?: boolean;
352			timestamp?: number;
353		}>(request);
354		if (!body) return errorResponse('Invalid JSON', 400);
355
356		try {
357			const message = await ctx.runMutation(internal.messagesInternal.send, {
358				chatRoomId: body.chatRoomId as Id<'chatRooms'>,
359				senderId: body.senderId as Id<'users'>,
360				content: body.content,
361				type: body.type,
362				styleData: body.styleData,
363				hasFormatting: body.hasFormatting,
364				timestamp: body.timestamp
365			});
366			return new Response(JSON.stringify(message), {
367				headers: { 'Content-Type': 'application/json' }
368			});
369		} catch (e) {
370			return errorResponse(e instanceof Error ? e.message : 'Failed to send message');
371		}
372	})
373});
374
375// ============ TEXT PREFERENCES (server-side only) ============
376
377// Get user text preferences
378http.route({
379	path: '/api/textPreferences/get',
380	method: 'POST',
381	handler: httpAction(async (ctx, request) => {
382		if (!validateSecret(request)) return unauthorizedResponse();
383
384		const body = await parseJsonBody<{ userId: string }>(request);
385		if (!body) return errorResponse('Invalid JSON', 400);
386
387		const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {
388			userId: body.userId as Id<'users'>
389		});
390
391		return new Response(JSON.stringify(preferences), {
392			headers: { 'Content-Type': 'application/json' }
393		});
394	})
395});
396
397// Save user text preferences
398http.route({
399	path: '/api/textPreferences/save',
400	method: 'POST',
401	handler: httpAction(async (ctx, request) => {
402		if (!validateSecret(request)) return unauthorizedResponse();
403
404		const body = await parseJsonBody<{
405			userId: string;
406			defaultStyle: {
407				fontFamily: string;
408				fontSize: number;
409				color?: string;
410				gradient?: string[];
411				bold: boolean;
412				italic: boolean;
413				underline: boolean;
414				strikethrough: boolean;
415			};
416			allowFormatting: boolean;
417			maxMessageLength: number;
418		}>(request);
419		if (!body) return errorResponse('Invalid JSON', 400);
420
421		try {
422			const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {
423				userId: body.userId as Id<'users'>,
424				defaultStyle: body.defaultStyle,
425				allowFormatting: body.allowFormatting,
426				maxMessageLength: body.maxMessageLength
427			});
428			return new Response(JSON.stringify({ success: true, id: result }), {
429				headers: { 'Content-Type': 'application/json' }
430			});
431		} catch (e) {
432			return errorResponse(e instanceof Error ? e.message : 'Failed to save preferences');
433		}
434	})
435});
436
437export default http;

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