+server.ts RawHistoryFolder

1import { messages, chatRooms } from '$lib/db/convex.server';
2import { getDefaultChatRoomId } from '$lib/utils/chat.server';
3import type { Message } from '$lib/types/chat';
4import type { SendMessageResponse, GetMessagesResponse } from '$lib/types/payloads';
5import { error, isHttpError } from '@sveltejs/kit';
6import { createLogger } from '$lib/utils/logger.server';
7import { sanitizeStyleData } from '$lib/validation/text-formatting';
8import { sendMessageSchema, PUBLIC_PREVIEW_MESSAGES } from '$lib/validation/message';
9import type { RequestHandler } from './$types';
10
11const log = createLogger('chat-server');
12
13// Rate limiting configuration
14const INITIAL_COOLDOWN = 1000; // 1 second
15const MAX_COOLDOWN = 30000; // 30 seconds
16const userMessageTimestamps = new Map<
17	string,
18	{ lastMessageTime: number; currentCooldown: number }
19>();
20
21function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } {
22	const now = Date.now();
23	const userState = userMessageTimestamps.get(userId) || {
24		lastMessageTime: 0,
25		currentCooldown: INITIAL_COOLDOWN
26	};
27
28	const timeSinceLastMessage = now - userState.lastMessageTime;
29	if (timeSinceLastMessage < userState.currentCooldown) {
30		return {
31			canSend: false,
32			retryAfter: userState.currentCooldown - timeSinceLastMessage
33		};
34	}
35
36	if (timeSinceLastMessage > userState.currentCooldown * 2) {
37		userState.currentCooldown = INITIAL_COOLDOWN;
38	} else {
39		userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
40	}
41
42	userState.lastMessageTime = now;
43	userMessageTimestamps.set(userId, userState);
44
45	return { canSend: true };
46}
47
48// GET endpoint: fetch messages from Convex
49export const GET: RequestHandler = async ({ url, locals }) => {
50	const beforeTimestamp = url.searchParams.get('before');
51	const roomIdParam = url.searchParams.get('roomId');
52	const isPublic = url.searchParams.get('public') === 'true' || !locals.session;
53
54	// Use default room if not specified
55	const roomId = roomIdParam || getDefaultChatRoomId();
56
57	if (!roomId) {
58		log.error('No room ID available');
59		throw error(500, 'Chat room not configured');
60	}
61
62	try {
63		// Direct (IM) rooms are private to their two members
64		const room = await chatRooms.getById(roomId);
65		if (!room) {
66			throw error(404, 'Chat room not found');
67		}
68		if (room.type === 'direct') {
69			if (!locals.user || !(room.memberIds ?? []).includes(locals.user.id)) {
70				throw error(403, 'Not a member of this conversation');
71			}
72		}
73		// Visitors only get a preview of the default room
74		if (isPublic && roomId !== getDefaultChatRoomId()) {
75			throw error(403, 'Sign in to read this room');
76		}
77
78		log.debug('Fetching messages from Convex', {
79			beforeTimestamp,
80			roomId,
81			isAuthenticated: !!locals.session,
82			isPublic
83		});
84
85		if (isPublic && beforeTimestamp) {
86			const response: GetMessagesResponse = {
87				success: true,
88				messages: [],
89				hasMore: false
90			};
91
92			return new Response(JSON.stringify(response), {
93				headers: { 'Content-Type': 'application/json' }
94			});
95		}
96
97		const fetchLimit = isPublic ? PUBLIC_PREVIEW_MESSAGES : 100;
98
99		const result = await messages.getByRoom(
100			roomId,
101			fetchLimit,
102			beforeTimestamp ? parseInt(beforeTimestamp) : undefined
103		);
104
105		// Map Convex messages to our Message type
106		const fetchedMessages: Message[] = result.messages.map((msg) => ({
107			id: msg._id,
108			chatRoomId: msg.chatRoomId,
109			senderId: msg.senderId,
110			content: msg.content,
111			type: msg.type as Message['type'],
112			timestamp: msg.timestamp,
113			styleData: msg.styleData,
114			hasFormatting: msg.hasFormatting ?? false
115		}));
116
117		const response: GetMessagesResponse = {
118			success: true,
119			messages: fetchedMessages,
120			hasMore: result.hasMore
121		};
122
123		return new Response(JSON.stringify(response), {
124			headers: { 'Content-Type': 'application/json' }
125		});
126	} catch (err) {
127		if (isHttpError(err)) throw err;
128		log.error('Error fetching messages:', { error: err });
129		throw error(500, 'Failed to fetch messages');
130	}
131};
132
133// POST endpoint: send a new message via Convex
134export const POST: RequestHandler = async ({ request, locals }) => {
135	if (!locals.user) {
136		log.warn('Authentication required');
137		throw error(401, 'Authentication required');
138	}
139
140	log.debug('New message received');
141	try {
142		// Check rate limiting
143		const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
144		if (!canSend) {
145			const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`;
146			log.warn('Rate limited', { userId: maskedUserId, retryAfter });
147			const errorResponse: SendMessageResponse = {
148				success: false,
149				error: 'Please wait before sending another message',
150				retryAfter,
151				isRateLimited: true
152			};
153			return new Response(JSON.stringify(errorResponse), {
154				status: 429,
155				headers: {
156					'Content-Type': 'application/json',
157					'Retry-After': Math.ceil(retryAfter! / 1000).toString()
158				}
159			});
160		}
161
162		const rawData = await request.json();
163
164		// Validate with Zod schema
165		const parseResult = sendMessageSchema.safeParse(rawData);
166		if (!parseResult.success) {
167			const errorMessage = parseResult.error.issues[0]?.message || 'Invalid message payload';
168			log.warn('Message validation failed', {
169				error: errorMessage,
170				issues: parseResult.error.issues
171			});
172			const errorResponse: SendMessageResponse = {
173				success: false,
174				error: errorMessage
175			};
176			return new Response(JSON.stringify(errorResponse), { status: 400 });
177		}
178
179		const data = parseResult.data;
180
181		if (data.userId !== locals.user.id) {
182			log.warn('Unauthorized message attempt', {
183				requestedUserId: data.userId,
184				actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
185			});
186			throw error(403, 'Cannot post messages as another user');
187		}
188
189		// Use default room if not specified
190		const chatRoomId = data.chatRoomId || getDefaultChatRoomId();
191
192		if (!chatRoomId) {
193			log.error('No chat room ID available');
194			const errorResponse: SendMessageResponse = {
195				success: false,
196				error: 'Chat room not configured'
197			};
198			return new Response(JSON.stringify(errorResponse), { status: 500 });
199		}
200
201		const validatedStyleData = sanitizeStyleData(data.styleData);
202
203		// Send message via Convex
204		const convexMessage = await messages.send(
205			chatRoomId,
206			data.userId,
207			data.content,
208			data.type || 'chat',
209			validatedStyleData ? JSON.stringify(validatedStyleData) : undefined,
210			Boolean(validatedStyleData)
211		);
212
213		// Map to our Message type
214		const newMessage: Message = {
215			id: convexMessage._id,
216			chatRoomId: convexMessage.chatRoomId,
217			senderId: convexMessage.senderId,
218			content: convexMessage.content,
219			type: convexMessage.type as Message['type'],
220			timestamp: convexMessage.timestamp,
221			styleData: convexMessage.styleData,
222			hasFormatting: convexMessage.hasFormatting ?? false
223		};
224
225		log.debug('Message processed successfully', {
226			messageId: newMessage.id,
227			userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`,
228			roomId: newMessage.chatRoomId
229		});
230
231		const successResponse: SendMessageResponse = {
232			success: true,
233			message: newMessage
234		};
235
236		return new Response(JSON.stringify(successResponse), {
237			status: 201,
238			headers: { 'Content-Type': 'application/json' }
239		});
240	} catch (err) {
241		log.error('Error processing message', {
242			error: err instanceof Error ? err.message : 'Unknown error'
243		});
244		const errorResponse: SendMessageResponse = {
245			success: false,
246			error: 'Failed to save message'
247		};
248		return new Response(JSON.stringify(errorResponse), {
249			status: 500,
250			headers: { 'Content-Type': 'application/json' }
251		});
252	}
253};

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