v0.1.5 0b43029

+server.ts RawHistoryFolder

1import { v4 as uuidv4 } from 'uuid';
2import db from '$lib/db/db.server';
3import { messages } from '$lib/db/schema';
4import type { Message } from '$lib/types/chat';
5import type { 
6    SendMessageRequest, 
7    SendMessageResponse, 
8    GetMessagesResponse 
9} from '$lib/types/payloads';
10import { and, desc, eq, lt } from 'drizzle-orm/sql';
11import type { SQL } from 'drizzle-orm/sql';
12import { error } from '@sveltejs/kit';
13import { sseEmitter } from '$lib/sseEmitter';
14import { chatRooms } from '$lib/db/schema';
15import { users } from '$lib/db/schema';
16import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
17import { createLogger } from '$lib/utils/logger.server';
18
19const log = createLogger('chat-server');
20
21// Rate limiting configuration
22const INITIAL_COOLDOWN = 1000; // 1 second
23const MAX_COOLDOWN = 30000; // 30 seconds
24const userMessageTimestamps = new Map<string, { lastMessageTime: number; currentCooldown: number }>();
25
26function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } {
27    const now = Date.now();
28    const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN };
29    
30    // Check if enough time has passed since the last message
31    const timeSinceLastMessage = now - userState.lastMessageTime;
32    if (timeSinceLastMessage < userState.currentCooldown) {
33        return { 
34            canSend: false, 
35            retryAfter: userState.currentCooldown - timeSinceLastMessage 
36        };
37    }
38
39    // Reset cooldown if it's been long enough
40    if (timeSinceLastMessage > userState.currentCooldown * 2) {
41        userState.currentCooldown = INITIAL_COOLDOWN;
42    } else {
43        // Exponential backoff
44        userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN);
45    }
46
47    userState.lastMessageTime = now;
48    userMessageTimestamps.set(userId, userState);
49    
50    return { canSend: true };
51}
52
53// GET endpoint: fetch messages directly from DB
54export async function GET({ request, locals }) {
55    const url = new URL(request.url);
56    const beforeTimestamp = url.searchParams.get('before');
57    const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID;
58    const isPublic = url.searchParams.get('public') === 'true';
59
60    try {
61        log.debug('Fetching messages from database', { 
62            beforeTimestamp, 
63            roomId, 
64            isAuthenticated: !!locals.session,
65            isPublic
66        });
67
68        let conditions = eq(messages.chatRoomId, roomId) as SQL<unknown>;
69        
70        if (beforeTimestamp) {
71            conditions = and(
72                conditions,
73                lt(messages.timestamp, parseInt(beforeTimestamp))
74            ) as SQL<unknown>;
75        }
76
77        // Fetch messages with a limit based on authentication status
78        // Public requests are limited to 50 messages
79        // Authenticated users get 100 messages per request
80        const fetchLimit = (isPublic || !locals.session) ? 50 : 100;
81
82        const fetchedMessages = await db.select()
83            .from(messages)
84            .where(conditions)
85            .orderBy(desc(messages.timestamp))
86            .limit(fetchLimit);
87
88        const response: GetMessagesResponse = {
89            success: true,
90            messages: fetchedMessages,
91            hasMore: fetchedMessages.length === fetchLimit // Indicate if there might be more messages
92        };
93
94        return new Response(JSON.stringify(response), {
95            headers: {
96                'Content-Type': 'application/json'
97            }
98        });
99    } catch (err) {
100        log.error('Error fetching messages:', { error: err });
101        throw error(500, 'Failed to fetch messages');
102    }
103}
104
105// POST endpoint: receive a new message, save it in the DB, and broadcast via SSE
106export async function POST({ request, locals }: { request: Request, locals: App.Locals }) {
107    if (!locals.user) {
108        log.warn('Authentication required');
109        throw error(401, 'Authentication required');
110    }
111
112    log.debug('New message received');
113    try {
114        // Check rate limiting
115        const { canSend, retryAfter } = updateUserCooldown(locals.user.id);
116        if (!canSend) {
117            const maskedUserId = `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`;
118            log.warn('Rate limited', { userId: maskedUserId, retryAfter });
119            const errorResponse: SendMessageResponse = {
120                success: false,
121                error: 'Please wait before sending another message',
122                retryAfter,
123                isRateLimited: true
124            };
125            return new Response(JSON.stringify(errorResponse), {
126                status: 429,
127                headers: {
128                    'Content-Type': 'application/json',
129                    'Retry-After': Math.ceil(retryAfter! / 1000).toString()
130                }
131            });
132        }
133
134        const data = await request.json() as SendMessageRequest;
135        if (!data.content || !data.userId) {
136            log.warn('Invalid payload received', { hasContent: Boolean(data.content), hasUserId: Boolean(data.userId) });
137            const errorResponse: SendMessageResponse = {
138                success: false,
139                error: 'Invalid message payload'
140            };
141            return new Response(JSON.stringify(errorResponse), { status: 400 });
142        }
143
144        if (data.userId !== locals.user.id) {
145            log.warn('Unauthorized message attempt', {
146                requestedUserId: data.userId,
147                actualUserId: `${locals.user.id.slice(0, 4)}...${locals.user.id.slice(-4)}`
148            });
149            throw error(403, 'Cannot post messages as another user');
150        }
151
152        const chatRoomId = data.chatRoomId || DEFAULT_CHAT_ROOM_ID;
153        
154        // Check chat room existence
155        const chatRoom = await db.select()
156            .from(chatRooms)
157            .where(eq(chatRooms.id, chatRoomId))
158            .get();
159
160        if (!chatRoom) {
161            log.warn('Chat room not found', { chatRoomId });
162            const errorResponse: SendMessageResponse = {
163                success: false,
164                error: 'Chat room not found'
165            };
166            return new Response(JSON.stringify(errorResponse), { status: 404 });
167        }
168
169        // Check user existence
170        const user = await db.select()
171            .from(users)
172            .where(eq(users.id, data.userId))
173            .get();
174
175        if (!user) {
176            const maskedUserId = `${data.userId.slice(0, 4)}...${data.userId.slice(-4)}`;
177            log.warn('User not found', { maskedUserId });
178            const errorResponse: SendMessageResponse = {
179                success: false,
180                error: 'User not found'
181            };
182            return new Response(JSON.stringify(errorResponse), { status: 404 });
183        }
184
185        const newMessage: Message = {
186            id: uuidv4(),
187            chatRoomId,
188            senderId: data.userId,
189            content: data.content,
190            type: data.type || 'chat',
191            timestamp: Date.now()
192        };
193
194        // Save to DB
195        await db.insert(messages).values(newMessage);
196        log.debug('Message saved in DB', { messageId: newMessage.id, chatRoomId: newMessage.chatRoomId, type: newMessage.type, timestamp: newMessage.timestamp });
197
198        // Broadcast the new message via SSE
199        sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
200
201        log.debug('Message processed successfully', { messageId: newMessage.id, userId: `${newMessage.senderId.slice(0, 4)}...${newMessage.senderId.slice(-4)}`, roomId: newMessage.chatRoomId });
202
203        const successResponse: SendMessageResponse = {
204            success: true,
205            message: newMessage
206        };
207
208        return new Response(
209            JSON.stringify(successResponse), 
210            { 
211                status: 201, 
212                headers: { 'Content-Type': 'application/json' } 
213            }
214        );
215    } catch (error) {
216        log.error('Error processing message', { error: error instanceof Error ? error.message : 'Unknown error' });
217        const errorResponse: SendMessageResponse = {
218            success: false,
219            error: 'Failed to save message'
220        };
221        return new Response(
222            JSON.stringify(errorResponse), 
223            { 
224                status: 500, 
225                headers: { 'Content-Type': 'application/json' } 
226            }
227        );
228    }
229}

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