detached a3b34f2

feat: Implement SafeUser type and sanitization across API routes

- Add SafeUser type to remove sensitive user information
- Create createSafeUser helper function for consistent user data sanitization
- Update API routes to use SafeUser for consistent user data exposure
- Refactor SSE and status update handlers to use new SafeUser type
- Improve type safety and reduce potential information leakage
commit
a3b34f240b8ef81b897e85632866cb1e07c828d1
author
Kit · · 2025-02-15 08:29 UTC
parent
3f69ff2
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
10 files · +136 -89
M src/lib/api/session.server.ts +1 -0
@@ -65,6 +65,7 @@ export async function createSession(token: string, userId: string): Promise<Sess
6565 /**
6666 * Type distributed through our API.
6767 * Returns an object with both session and user if valid, or nulls if not.
68+ * Note: This returns the full User type since it's used server-side in hooks.
6869 */
6970 export type SessionValidationResult =
7071 | { session: Session; user: User }
@@ -65,6 +65,7 @@ export async function createSession(token: string, userId: string): Promise<Sess
65 /**65 /**
66 * Type distributed through our API.66 * Type distributed through our API.
67 * Returns an object with both session and user if valid, or nulls if not.67 * Returns an object with both session and user if valid, or nulls if not.
68+ * Note: This returns the full User type since it's used server-side in hooks.
68 */69 */
69 export type SessionValidationResult =70 export type SessionValidationResult =
70 | { session: Session; user: User }71 | { session: Session; user: User }
M src/lib/states/chat.svelte.ts +33 -19
@@ -30,6 +30,8 @@ class ChatState {
3030 private _lastEnrichmentKey: string = '';
3131 // Flag to avoid re-registering SSE event handlers
3232 private sseHandlersRegistered = false;
33+ private lastBuddyListUpdate = 0;
34+ private lastBuddyListHash = '';
3335
3436 public async reinitialize() {
3537 if (this.isInitializing) {
@@ -361,12 +363,31 @@ class ChatState {
361363
362364 // Add method to update online users for public access
363365 updateOnlineUsers(users: User[]) {
364- console.debug('Updating online users:', users);
365- this.users = users;
366- // Also update cache
367- users.forEach(user => {
368- this.userCache[user.id] = user;
369- });
366+ const now = Date.now();
367+ // Create a hash of the new users data
368+ const newHash = JSON.stringify(users);
369+
370+ // Only update if the data has changed
371+ if (newHash !== this.lastBuddyListHash) {
372+ const statusCounts = users.reduce((acc, user) => {
373+ acc[user.status] = (acc[user.status] || 0) + 1;
374+ return acc;
375+ }, {} as Record<string, number>);
376+
377+ console.debug('Updating online users (data changed):', {
378+ total: users.length,
379+ byStatus: statusCounts,
380+ timestamp: new Date(now).toISOString()
381+ });
382+
383+ this.users = users;
384+ // Also update cache
385+ users.forEach(user => {
386+ this.userCache[user.id] = user;
387+ });
388+ this.lastBuddyListHash = newHash;
389+ this.lastBuddyListUpdate = now;
390+ }
370391 }
371392
372393 // Add method to update messages for public access
@@ -558,13 +579,7 @@ class ChatState {
558579
559580 // Register SSE event handlers only once per connection.
560581 private setupMessageHandlers() {
561- if (this.sseHandlersRegistered) {
562- console.debug('SSE message handlers already registered, skipping.');
563- return;
564- }
565-
566- if (!this.eventSource) {
567- console.debug('No SSE connection available for registering handlers, skipping.');
582+ if (this.sseHandlersRegistered || !this.eventSource) {
568583 return;
569584 }
570585
@@ -582,14 +597,13 @@ class ChatState {
582597 }
583598 });
584599
585- // Listen for user status updates to update the buddy list dynamically
586- this.eventSource.addEventListener('userStatusUpdate', async (event: MessageEvent) => {
600+ // Modify buddy list update handler
601+ this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {
587602 try {
588- const statusUpdate = JSON.parse(event.data) as { userId: string; status: User['status']; lastSeen: number };
589- console.debug('Received userStatusUpdate via SSE:', statusUpdate);
590- this.updateUserStatus(statusUpdate.userId, statusUpdate.status, statusUpdate.lastSeen);
603+ const buddyList = JSON.parse(event.data) as User[];
604+ this.updateOnlineUsers(buddyList);
591605 } catch (error) {
592- console.debug('Error handling userStatusUpdate via SSE:', error);
606+ console.debug('Error handling buddy list update via SSE:', error);
593607 }
594608 });
595609
@@ -30,6 +30,8 @@ class ChatState {
30 private _lastEnrichmentKey: string = '';30 private _lastEnrichmentKey: string = '';
31 // Flag to avoid re-registering SSE event handlers31 // Flag to avoid re-registering SSE event handlers
32 private sseHandlersRegistered = false;32 private sseHandlersRegistered = false;
33+ private lastBuddyListUpdate = 0;
34+ private lastBuddyListHash = '';
33 35
34 public async reinitialize() {36 public async reinitialize() {
35 if (this.isInitializing) {37 if (this.isInitializing) {
@@ -361,12 +363,31 @@ class ChatState {
361 363
362 // Add method to update online users for public access364 // Add method to update online users for public access
363 updateOnlineUsers(users: User[]) {365 updateOnlineUsers(users: User[]) {
364- console.debug('Updating online users:', users);366+ const now = Date.now();
365- this.users = users;367+ // Create a hash of the new users data
366- // Also update cache368+ const newHash = JSON.stringify(users);
367- users.forEach(user => {369+
368- this.userCache[user.id] = user;370+ // Only update if the data has changed
369- });371+ if (newHash !== this.lastBuddyListHash) {
372+ const statusCounts = users.reduce((acc, user) => {
373+ acc[user.status] = (acc[user.status] || 0) + 1;
374+ return acc;
375+ }, {} as Record<string, number>);
376+
377+ console.debug('Updating online users (data changed):', {
378+ total: users.length,
379+ byStatus: statusCounts,
380+ timestamp: new Date(now).toISOString()
381+ });
382+
383+ this.users = users;
384+ // Also update cache
385+ users.forEach(user => {
386+ this.userCache[user.id] = user;
387+ });
388+ this.lastBuddyListHash = newHash;
389+ this.lastBuddyListUpdate = now;
390+ }
370 }391 }
371 392
372 // Add method to update messages for public access393 // Add method to update messages for public access
@@ -558,13 +579,7 @@ class ChatState {
558 579
559 // Register SSE event handlers only once per connection.580 // Register SSE event handlers only once per connection.
560 private setupMessageHandlers() {581 private setupMessageHandlers() {
561- if (this.sseHandlersRegistered) {582+ if (this.sseHandlersRegistered || !this.eventSource) {
562- console.debug('SSE message handlers already registered, skipping.');
563- return;
564- }
565-
566- if (!this.eventSource) {
567- console.debug('No SSE connection available for registering handlers, skipping.');
568 return;583 return;
569 }584 }
570 585
@@ -582,14 +597,13 @@ class ChatState {
582 }597 }
583 });598 });
584 599
585- // Listen for user status updates to update the buddy list dynamically600+ // Modify buddy list update handler
586- this.eventSource.addEventListener('userStatusUpdate', async (event: MessageEvent) => {601+ this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {
587 try {602 try {
588- const statusUpdate = JSON.parse(event.data) as { userId: string; status: User['status']; lastSeen: number };603+ const buddyList = JSON.parse(event.data) as User[];
589- console.debug('Received userStatusUpdate via SSE:', statusUpdate);604+ this.updateOnlineUsers(buddyList);
590- this.updateUserStatus(statusUpdate.userId, statusUpdate.status, statusUpdate.lastSeen);
591 } catch (error) {605 } catch (error) {
592- console.debug('Error handling userStatusUpdate via SSE:', error);606+ console.debug('Error handling buddy list update via SSE:', error);
593 }607 }
594 });608 });
595 609
M src/lib/types/chat.ts +12 -1
@@ -29,8 +29,19 @@ export type UserStatus = 'offline' | 'online' | 'away' | string;
2929 timestamp: number; // Timestamp when the message was sent
3030 }
3131
32+ // Safe user type for API responses - omits sensitive information
33+ export type SafeUser = Omit<User, 'password' | 'createdAt'>;
34+
35+ // Helper function to create a safe user object
36+ export function createSafeUser(user: User): SafeUser {
37+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
38+ const { password, createdAt, ...safeUser } = user;
39+ return safeUser;
40+ }
41+
42+ // Update EnrichedMessage to use SafeUser
3243 export interface EnrichedMessage extends Message {
33- user: User;
44+ user: SafeUser;
3445 }
3546
3647 export type MessageType = 'chat' | 'emote' | 'system' | string;
@@ -29,8 +29,19 @@ export type UserStatus = 'offline' | 'online' | 'away' | string;
29 timestamp: number; // Timestamp when the message was sent29 timestamp: number; // Timestamp when the message was sent
30 }30 }
31 31
32+ // Safe user type for API responses - omits sensitive information
33+ export type SafeUser = Omit<User, 'password' | 'createdAt'>;
34+
35+ // Helper function to create a safe user object
36+ export function createSafeUser(user: User): SafeUser {
37+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
38+ const { password, createdAt, ...safeUser } = user;
39+ return safeUser;
40+ }
41+
42+ // Update EnrichedMessage to use SafeUser
32 export interface EnrichedMessage extends Message {43 export interface EnrichedMessage extends Message {
33- user: User;44+ user: SafeUser;
34 }45 }
35 46
36 export type MessageType = 'chat' | 'emote' | 'system' | string;47 export type MessageType = 'chat' | 'emote' | 'system' | string;
M src/lib/types/payloads.ts +4 -4
@@ -1,9 +1,9 @@
1-import type { User, Message } from "./chat";
1+import type { SafeUser, Message } from "./chat";
22
33 // Login
44 export interface LoginResponseSuccess {
55 success: true;
6- user: Omit<User, "password">;
6+ user: SafeUser;
77 }
88
99 export interface LoginResponseError {
@@ -31,7 +31,7 @@ export type RegisterResponse = RegisterResponseSuccess | RegisterResponseError;
3131 // Session
3232 export interface SessionResponseSuccess {
3333 success: true;
34- user: Omit<User, "password">;
34+ user: SafeUser;
3535 }
3636
3737 export interface SessionResponseError {
@@ -76,7 +76,7 @@ export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesRespon
7676
7777 export interface PublicRoomResponseSuccess {
7878 success: true;
79- buddyList: Omit<User, "password">[];
79+ buddyList: SafeUser[];
8080 }
8181
8282 export interface PublicRoomResponseError {
@@ -1,9 +1,9 @@
1-import type { User, Message } from "./chat";1+import type { SafeUser, Message } from "./chat";
2 2
3 // Login3 // Login
4 export interface LoginResponseSuccess {4 export interface LoginResponseSuccess {
5 success: true;5 success: true;
6- user: Omit<User, "password">;6+ user: SafeUser;
7 }7 }
8 8
9 export interface LoginResponseError {9 export interface LoginResponseError {
@@ -31,7 +31,7 @@ export type RegisterResponse = RegisterResponseSuccess | RegisterResponseError;
31 // Session31 // Session
32 export interface SessionResponseSuccess {32 export interface SessionResponseSuccess {
33 success: true;33 success: true;
34- user: Omit<User, "password">;34+ user: SafeUser;
35 }35 }
36 36
37 export interface SessionResponseError {37 export interface SessionResponseError {
@@ -76,7 +76,7 @@ export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesRespon
76 76
77 export interface PublicRoomResponseSuccess {77 export interface PublicRoomResponseSuccess {
78 success: true;78 success: true;
79- buddyList: Omit<User, "password">[];79+ buddyList: SafeUser[];
80 }80 }
81 81
82 export interface PublicRoomResponseError {82 export interface PublicRoomResponseError {
M src/routes/+layout.svelte +3 -3
@@ -13,7 +13,8 @@
1313 // New throttle state variables
1414 let lastSentStatus = $state<string | null>(null);
1515 let lastStatusSentAt = $state<number>(0);
16- const THROTTLE_TIME_MS = 5000;
16+ const STATUS_UPDATE_INTERVAL = 5000; // 5 seconds
17+ const THROTTLE_TIME_MS = 4000; // 4 seconds to prevent edge case overlaps
1718
1819 async function updateUserStatus(status: 'online' | 'offline' | 'busy') {
1920 if (!data.user?.id || updateInProgress) return;
@@ -82,8 +83,7 @@
8283 }
8384 };
8485
85- statusInterval = window.setInterval(tickHandler, 60000); // Every minute
86- // Removed immediate tickHandler() call to avoid duplicate update on mount
86+ statusInterval = window.setInterval(tickHandler, STATUS_UPDATE_INTERVAL);
8787 }
8888
8989 function stopStatusTicker() {
@@ -13,7 +13,8 @@
13 // New throttle state variables13 // New throttle state variables
14 let lastSentStatus = $state<string | null>(null);14 let lastSentStatus = $state<string | null>(null);
15 let lastStatusSentAt = $state<number>(0);15 let lastStatusSentAt = $state<number>(0);
16- const THROTTLE_TIME_MS = 5000;16+ const STATUS_UPDATE_INTERVAL = 5000; // 5 seconds
17+ const THROTTLE_TIME_MS = 4000; // 4 seconds to prevent edge case overlaps
17 18
18 async function updateUserStatus(status: 'online' | 'offline' | 'busy') {19 async function updateUserStatus(status: 'online' | 'offline' | 'busy') {
19 if (!data.user?.id || updateInProgress) return;20 if (!data.user?.id || updateInProgress) return;
@@ -82,8 +83,7 @@
82 }83 }
83 };84 };
84 85
85- statusInterval = window.setInterval(tickHandler, 60000); // Every minute86+ statusInterval = window.setInterval(tickHandler, STATUS_UPDATE_INTERVAL);
86- // Removed immediate tickHandler() call to avoid duplicate update on mount
87 }87 }
88 88
89 function stopStatusTicker() {89 function stopStatusTicker() {
M src/routes/api/rooms/[roomId]/+server.ts +4 -3
@@ -3,6 +3,8 @@ import db from '$lib/db/db.server';
33 import { messages, users } from '$lib/db/schema';
44 import type { PublicRoomResponse } from '$lib/types/payloads';
55 import { error } from '@sveltejs/kit';
6+import { createSafeUser } from '$lib/types/chat';
7+
68
79 export async function GET({ params, url, locals }): Promise<Response> {
810 const { roomId } = params;
@@ -37,9 +39,8 @@ export async function GET({ params, url, locals }): Promise<Response> {
3739 const fetchedUsers = await db.select()
3840 .from(users);
3941
40- // Sanitize user data by removing sensitive fields
41- // eslint-disable-next-line @typescript-eslint/no-unused-vars
42- const sanitizedUsers = fetchedUsers.map(({ password, ...userWithoutPassword }) => userWithoutPassword);
42+ // Sanitize user data using createSafeUser
43+ const sanitizedUsers = fetchedUsers.map(user => createSafeUser(user));
4344
4445 const responseData: PublicRoomResponse = {
4546 success: true,
@@ -3,6 +3,8 @@ import db from '$lib/db/db.server';
3 import { messages, users } from '$lib/db/schema';3 import { messages, users } from '$lib/db/schema';
4 import type { PublicRoomResponse } from '$lib/types/payloads';4 import type { PublicRoomResponse } from '$lib/types/payloads';
5 import { error } from '@sveltejs/kit';5 import { error } from '@sveltejs/kit';
6+import { createSafeUser } from '$lib/types/chat';
7+
6 8
7 export async function GET({ params, url, locals }): Promise<Response> {9 export async function GET({ params, url, locals }): Promise<Response> {
8 const { roomId } = params;10 const { roomId } = params;
@@ -37,9 +39,8 @@ export async function GET({ params, url, locals }): Promise<Response> {
37 const fetchedUsers = await db.select()39 const fetchedUsers = await db.select()
38 .from(users);40 .from(users);
39 41
40- // Sanitize user data by removing sensitive fields42+ // Sanitize user data using createSafeUser
41- // eslint-disable-next-line @typescript-eslint/no-unused-vars43+ const sanitizedUsers = fetchedUsers.map(user => createSafeUser(user));
42- const sanitizedUsers = fetchedUsers.map(({ password, ...userWithoutPassword }) => userWithoutPassword);
43 44
44 const responseData: PublicRoomResponse = {45 const responseData: PublicRoomResponse = {
45 success: true,46 success: true,
M src/routes/api/session/login/+server.ts +2 -7
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
66 import { verifyPassword } from '$lib/utils/password';
77 import { generateSessionToken, createSession } from '$lib/api/session.server';
88 import { setSessionTokenCookie } from '$lib/api/session.cookie';
9+import { createSafeUser } from '$lib/types/chat';
910
1011 // In-memory map to track failed login attempts per IP
1112 const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
@@ -112,13 +113,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
112113
113114 return new Response(JSON.stringify({
114115 success: true,
115- user: {
116- id: user.id,
117- nickname: user.nickname,
118- status: 'online',
119- avatarUrl: user.avatarUrl,
120- lastSeen: Date.now()
121- }
116+ user: createSafeUser(user)
122117 } as LoginResponseSuccess), {
123118 status: 200
124119 });
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
6 import { verifyPassword } from '$lib/utils/password';6 import { verifyPassword } from '$lib/utils/password';
7 import { generateSessionToken, createSession } from '$lib/api/session.server';7 import { generateSessionToken, createSession } from '$lib/api/session.server';
8 import { setSessionTokenCookie } from '$lib/api/session.cookie';8 import { setSessionTokenCookie } from '$lib/api/session.cookie';
9+import { createSafeUser } from '$lib/types/chat';
9 10
10 // In-memory map to track failed login attempts per IP11 // In-memory map to track failed login attempts per IP
11 const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();12 const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
@@ -112,13 +113,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
112 113
113 return new Response(JSON.stringify({ 114 return new Response(JSON.stringify({
114 success: true,115 success: true,
115- user: {116+ user: createSafeUser(user)
116- id: user.id,
117- nickname: user.nickname,
118- status: 'online',
119- avatarUrl: user.avatarUrl,
120- lastSeen: Date.now()
121- }
122 } as LoginResponseSuccess), { 117 } as LoginResponseSuccess), {
123 status: 200 118 status: 200
124 });119 });
M src/routes/api/sse/+server.ts +59 -29
@@ -1,8 +1,15 @@
1+// At the top of the file, before any code
2+declare global {
3+ // eslint-disable-next-line no-var
4+ var __sseIntervalsStarted: boolean;
5+}
6+
17 import type { RequestHandler } from './$types';
28 import { sseEmitter } from '$lib/sseEmitter';
39 import db from '$lib/db/db.server';
410 import { users } from '$lib/db/schema';
511 import { eq, and, lt } from 'drizzle-orm';
12+import { createSafeUser } from '$lib/types/chat';
613
714 console.log('[SSE] Server initialized');
815
@@ -10,7 +17,15 @@ console.log('[SSE] Server initialized');
1017 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
1118 const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
1219
20+// Update the interval constant
21+const BUDDY_LIST_BROADCAST_INTERVAL = 10000; // 10 seconds
22+
1323 let timeoutInterval: ReturnType<typeof setInterval>;
24+let buddyListInterval: ReturnType<typeof setInterval>;
25+
26+// Add a timestamp tracker for buddy list updates
27+let lastBuddyListUpdate = 0;
28+let lastBuddyListData: unknown[] = [];
1429
1530 // Initialize periodic check for timed out users
1631 function startTimeoutCheck() {
@@ -26,7 +41,6 @@ function startTimeoutCheck() {
2641 });
2742
2843 try {
29- // Find and update users who haven't been seen recently
3044 const now = Date.now();
3145 const result = await db.update(users)
3246 .set({
@@ -45,58 +59,82 @@ function startTimeoutCheck() {
4559 lastSeen: users.lastSeen
4660 });
4761
48- // Log timeout results
4962 if (result.length > 0) {
5063 console.log('[SSE] Found timed out users:', result.map(user => ({
5164 nickname: user.nickname,
5265 lastSeen: new Date(user.lastSeen || 0).toISOString()
5366 })));
5467 }
55-
56- // Broadcast status updates for timed out users
57- result.forEach(user => {
58- sseEmitter.emit('sse', {
59- type: 'userStatusUpdate',
60- data: {
61- userId: user.id,
62- status: 'offline',
63- lastSeen: now
64- }
65- });
66- console.log('[SSE] User timed out and marked offline:', {
67- nickname: user.nickname,
68- lastSeen: new Date(now).toISOString()
69- });
70- });
7168 } catch (error) {
7269 console.error('[SSE] Error during timeout check:', error);
7370 }
7471 }, CHECK_INTERVAL_MS);
7572 }
7673
77-// Start the timeout check when this module loads
78-startTimeoutCheck();
74+function startBuddyListUpdateInterval() {
75+ if (buddyListInterval) {
76+ clearInterval(buddyListInterval);
77+ }
78+
79+ buddyListInterval = setInterval(async () => {
80+ try {
81+ const now = Date.now();
82+ // Only fetch and broadcast if 10 seconds have passed since last update
83+ if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
84+ console.log('[SSE] Broadcasting buddy list update...');
85+ // Query the complete buddy list from the database.
86+ const buddyList = await db.select().from(users);
87+ // Use createSafeUser to properly sanitize user data
88+ const safeBuddyList = buddyList.map(user => createSafeUser(user));
89+
90+ // Only broadcast if data has changed
91+ if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) {
92+ lastBuddyListData = safeBuddyList;
93+ sseEmitter.emit('sse', {
94+ type: 'buddyListUpdate',
95+ data: safeBuddyList
96+ });
97+ }
98+ lastBuddyListUpdate = now;
99+ }
100+ } catch (error) {
101+ console.error('[SSE] Error fetching buddy list for update:', error);
102+ }
103+ }, 1000); // Check every second but only broadcast every 10 seconds
104+}
105+
106+// <<< NEW: Ensure intervals are started only once >>>
107+if (!globalThis.__sseIntervalsStarted) {
108+ startTimeoutCheck();
109+ startBuddyListUpdateInterval();
110+ globalThis.__sseIntervalsStarted = true;
111+ console.log('[SSE] Started global intervals for timeout and buddy list updates.');
112+} else {
113+ console.log('[SSE] Global intervals already started, skipping initialization.');
114+}
79115
80116 // Ensure the interval is cleared if the module is reloaded
81117 if (typeof process !== 'undefined') {
82118 process.on('beforeExit', () => {
83119 if (timeoutInterval) clearInterval(timeoutInterval);
120+ if (buddyListInterval) clearInterval(buddyListInterval);
84121 });
85122
86123 // Also handle SIGTERM and SIGINT
87124 process.on('SIGTERM', () => {
88125 if (timeoutInterval) clearInterval(timeoutInterval);
126+ if (buddyListInterval) clearInterval(buddyListInterval);
89127 process.exit(0);
90128 });
91129
92130 process.on('SIGINT', () => {
93131 if (timeoutInterval) clearInterval(timeoutInterval);
132+ if (buddyListInterval) clearInterval(buddyListInterval);
94133 process.exit(0);
95134 });
96135 }
97136
98137 export const GET: RequestHandler = async ({ request, locals, cookies }) => {
99- // Add detailed debug logging for authentication state
100138 console.log('[SSE] Connection attempt:', {
101139 hasLocals: !!locals,
102140 hasUser: !!locals.user,
@@ -120,7 +158,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
120158 let keepAliveInterval: ReturnType<typeof setInterval>;
121159 let onSSE: ((event: { type: string; data: unknown }) => void) | undefined;
122160
123- // Update user status to online and lastSeen timestamp when establishing connection
124161 const now = Date.now();
125162
126163 try {
@@ -136,7 +173,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
136173 timestamp: new Date(now).toISOString()
137174 });
138175
139- // Broadcast the status update
140176 sseEmitter.emit('sse', {
141177 type: 'userStatusUpdate',
142178 data: {
@@ -147,7 +183,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
147183 });
148184 } catch (error) {
149185 console.error('[SSE] Error updating user status:', error);
150- // Continue with SSE setup even if status update fails
151186 }
152187
153188 const stream = new ReadableStream({
@@ -157,7 +192,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
157192 const payload = `event: ${event.type}\n` +
158193 `data: ${JSON.stringify(event.data)}\n\n`;
159194 controller.enqueue(encoder.encode(payload));
160- // Only log non-sensitive event types
161195 if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) {
162196 console.log('[SSE] Event sent:', { type: event.type });
163197 }
@@ -179,10 +213,8 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
179213 });
180214 console.log('[SSE] Added new listener');
181215
182- // Send initial connection message
183216 controller.enqueue(encoder.encode('data: Connected\n\n'));
184217
185- // Keep connection alive and update lastSeen: send a comment every 20 seconds
186218 keepAliveInterval = setInterval(() => {
187219 controller.enqueue(encoder.encode(':\n\n'));
188220 }, 20000);
@@ -193,13 +225,11 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
193225 const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
194226 console.log('[SSE] Connection closed:', { userId: maskedUserId, reason });
195227
196- // Update user status to offline in the database
197228 const now = Date.now();
198229 await db.update(users)
199230 .set({ status: 'offline', lastSeen: now })
200231 .where(eq(users.id, userId));
201232
202- // Broadcast the status update
203233 sseEmitter.emit('sse', {
204234 type: 'userStatusUpdate',
205235 data: {
@@ -1,8 +1,15 @@
1+// At the top of the file, before any code
2+declare global {
3+ // eslint-disable-next-line no-var
4+ var __sseIntervalsStarted: boolean;
5+}
6+
1 import type { RequestHandler } from './$types';7 import type { RequestHandler } from './$types';
2 import { sseEmitter } from '$lib/sseEmitter';8 import { sseEmitter } from '$lib/sseEmitter';
3 import db from '$lib/db/db.server';9 import db from '$lib/db/db.server';
4 import { users } from '$lib/db/schema';10 import { users } from '$lib/db/schema';
5 import { eq, and, lt } from 'drizzle-orm';11 import { eq, and, lt } from 'drizzle-orm';
12+import { createSafeUser } from '$lib/types/chat';
6 13
7 console.log('[SSE] Server initialized');14 console.log('[SSE] Server initialized');
8 15
@@ -10,7 +17,15 @@ console.log('[SSE] Server initialized');
10 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes17 const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
11 const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds18 const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
12 19
20+// Update the interval constant
21+const BUDDY_LIST_BROADCAST_INTERVAL = 10000; // 10 seconds
22+
13 let timeoutInterval: ReturnType<typeof setInterval>;23 let timeoutInterval: ReturnType<typeof setInterval>;
24+let buddyListInterval: ReturnType<typeof setInterval>;
25+
26+// Add a timestamp tracker for buddy list updates
27+let lastBuddyListUpdate = 0;
28+let lastBuddyListData: unknown[] = [];
14 29
15 // Initialize periodic check for timed out users30 // Initialize periodic check for timed out users
16 function startTimeoutCheck() {31 function startTimeoutCheck() {
@@ -26,7 +41,6 @@ function startTimeoutCheck() {
26 });41 });
27 42
28 try {43 try {
29- // Find and update users who haven't been seen recently
30 const now = Date.now();44 const now = Date.now();
31 const result = await db.update(users)45 const result = await db.update(users)
32 .set({ 46 .set({
@@ -45,58 +59,82 @@ function startTimeoutCheck() {
45 lastSeen: users.lastSeen59 lastSeen: users.lastSeen
46 });60 });
47 61
48- // Log timeout results
49 if (result.length > 0) {62 if (result.length > 0) {
50 console.log('[SSE] Found timed out users:', result.map(user => ({63 console.log('[SSE] Found timed out users:', result.map(user => ({
51 nickname: user.nickname,64 nickname: user.nickname,
52 lastSeen: new Date(user.lastSeen || 0).toISOString()65 lastSeen: new Date(user.lastSeen || 0).toISOString()
53 })));66 })));
54 }67 }
55-
56- // Broadcast status updates for timed out users
57- result.forEach(user => {
58- sseEmitter.emit('sse', {
59- type: 'userStatusUpdate',
60- data: {
61- userId: user.id,
62- status: 'offline',
63- lastSeen: now
64- }
65- });
66- console.log('[SSE] User timed out and marked offline:', {
67- nickname: user.nickname,
68- lastSeen: new Date(now).toISOString()
69- });
70- });
71 } catch (error) {68 } catch (error) {
72 console.error('[SSE] Error during timeout check:', error);69 console.error('[SSE] Error during timeout check:', error);
73 }70 }
74 }, CHECK_INTERVAL_MS);71 }, CHECK_INTERVAL_MS);
75 }72 }
76 73
77-// Start the timeout check when this module loads74+function startBuddyListUpdateInterval() {
78-startTimeoutCheck();75+ if (buddyListInterval) {
76+ clearInterval(buddyListInterval);
77+ }
78+
79+ buddyListInterval = setInterval(async () => {
80+ try {
81+ const now = Date.now();
82+ // Only fetch and broadcast if 10 seconds have passed since last update
83+ if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
84+ console.log('[SSE] Broadcasting buddy list update...');
85+ // Query the complete buddy list from the database.
86+ const buddyList = await db.select().from(users);
87+ // Use createSafeUser to properly sanitize user data
88+ const safeBuddyList = buddyList.map(user => createSafeUser(user));
89+
90+ // Only broadcast if data has changed
91+ if (JSON.stringify(safeBuddyList) !== JSON.stringify(lastBuddyListData)) {
92+ lastBuddyListData = safeBuddyList;
93+ sseEmitter.emit('sse', {
94+ type: 'buddyListUpdate',
95+ data: safeBuddyList
96+ });
97+ }
98+ lastBuddyListUpdate = now;
99+ }
100+ } catch (error) {
101+ console.error('[SSE] Error fetching buddy list for update:', error);
102+ }
103+ }, 1000); // Check every second but only broadcast every 10 seconds
104+}
105+
106+// <<< NEW: Ensure intervals are started only once >>>
107+if (!globalThis.__sseIntervalsStarted) {
108+ startTimeoutCheck();
109+ startBuddyListUpdateInterval();
110+ globalThis.__sseIntervalsStarted = true;
111+ console.log('[SSE] Started global intervals for timeout and buddy list updates.');
112+} else {
113+ console.log('[SSE] Global intervals already started, skipping initialization.');
114+}
79 115
80 // Ensure the interval is cleared if the module is reloaded116 // Ensure the interval is cleared if the module is reloaded
81 if (typeof process !== 'undefined') {117 if (typeof process !== 'undefined') {
82 process.on('beforeExit', () => {118 process.on('beforeExit', () => {
83 if (timeoutInterval) clearInterval(timeoutInterval);119 if (timeoutInterval) clearInterval(timeoutInterval);
120+ if (buddyListInterval) clearInterval(buddyListInterval);
84 });121 });
85 122
86 // Also handle SIGTERM and SIGINT123 // Also handle SIGTERM and SIGINT
87 process.on('SIGTERM', () => {124 process.on('SIGTERM', () => {
88 if (timeoutInterval) clearInterval(timeoutInterval);125 if (timeoutInterval) clearInterval(timeoutInterval);
126+ if (buddyListInterval) clearInterval(buddyListInterval);
89 process.exit(0);127 process.exit(0);
90 });128 });
91 129
92 process.on('SIGINT', () => {130 process.on('SIGINT', () => {
93 if (timeoutInterval) clearInterval(timeoutInterval);131 if (timeoutInterval) clearInterval(timeoutInterval);
132+ if (buddyListInterval) clearInterval(buddyListInterval);
94 process.exit(0);133 process.exit(0);
95 });134 });
96 }135 }
97 136
98 export const GET: RequestHandler = async ({ request, locals, cookies }) => {137 export const GET: RequestHandler = async ({ request, locals, cookies }) => {
99- // Add detailed debug logging for authentication state
100 console.log('[SSE] Connection attempt:', {138 console.log('[SSE] Connection attempt:', {
101 hasLocals: !!locals,139 hasLocals: !!locals,
102 hasUser: !!locals.user,140 hasUser: !!locals.user,
@@ -120,7 +158,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
120 let keepAliveInterval: ReturnType<typeof setInterval>;158 let keepAliveInterval: ReturnType<typeof setInterval>;
121 let onSSE: ((event: { type: string; data: unknown }) => void) | undefined;159 let onSSE: ((event: { type: string; data: unknown }) => void) | undefined;
122 160
123- // Update user status to online and lastSeen timestamp when establishing connection
124 const now = Date.now();161 const now = Date.now();
125 162
126 try {163 try {
@@ -136,7 +173,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
136 timestamp: new Date(now).toISOString()173 timestamp: new Date(now).toISOString()
137 });174 });
138 175
139- // Broadcast the status update
140 sseEmitter.emit('sse', {176 sseEmitter.emit('sse', {
141 type: 'userStatusUpdate',177 type: 'userStatusUpdate',
142 data: {178 data: {
@@ -147,7 +183,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
147 });183 });
148 } catch (error) {184 } catch (error) {
149 console.error('[SSE] Error updating user status:', error);185 console.error('[SSE] Error updating user status:', error);
150- // Continue with SSE setup even if status update fails
151 }186 }
152 187
153 const stream = new ReadableStream({188 const stream = new ReadableStream({
@@ -157,7 +192,6 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
157 const payload = `event: ${event.type}\n` +192 const payload = `event: ${event.type}\n` +
158 `data: ${JSON.stringify(event.data)}\n\n`;193 `data: ${JSON.stringify(event.data)}\n\n`;
159 controller.enqueue(encoder.encode(payload));194 controller.enqueue(encoder.encode(payload));
160- // Only log non-sensitive event types
161 if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) {195 if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) {
162 console.log('[SSE] Event sent:', { type: event.type });196 console.log('[SSE] Event sent:', { type: event.type });
163 }197 }
@@ -179,10 +213,8 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
179 });213 });
180 console.log('[SSE] Added new listener');214 console.log('[SSE] Added new listener');
181 215
182- // Send initial connection message
183 controller.enqueue(encoder.encode('data: Connected\n\n'));216 controller.enqueue(encoder.encode('data: Connected\n\n'));
184 217
185- // Keep connection alive and update lastSeen: send a comment every 20 seconds
186 keepAliveInterval = setInterval(() => {218 keepAliveInterval = setInterval(() => {
187 controller.enqueue(encoder.encode(':\n\n'));219 controller.enqueue(encoder.encode(':\n\n'));
188 }, 20000);220 }, 20000);
@@ -193,13 +225,11 @@ export const GET: RequestHandler = async ({ request, locals, cookies }) => {
193 const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;225 const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
194 console.log('[SSE] Connection closed:', { userId: maskedUserId, reason });226 console.log('[SSE] Connection closed:', { userId: maskedUserId, reason });
195 227
196- // Update user status to offline in the database
197 const now = Date.now();228 const now = Date.now();
198 await db.update(users)229 await db.update(users)
199 .set({ status: 'offline', lastSeen: now })230 .set({ status: 'offline', lastSeen: now })
200 .where(eq(users.id, userId));231 .where(eq(users.id, userId));
201 232
202- // Broadcast the status update
203 sseEmitter.emit('sse', { 233 sseEmitter.emit('sse', {
204 type: 'userStatusUpdate', 234 type: 'userStatusUpdate',
205 data: { 235 data: {
M src/routes/api/status/+server.ts +11 -13
@@ -1,10 +1,10 @@
11 import { error } from '@sveltejs/kit';
2-import { sseEmitter } from '$lib/sseEmitter';
32 import db from '$lib/db/db.server';
43 import { users } from '$lib/db/schema';
54 import { eq } from 'drizzle-orm';
65 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
76 import { setSessionTokenCookie } from '$lib/api/session.cookie';
7+import { createSafeUser } from '$lib/types/chat';
88
99 export async function POST({ request, cookies, locals }) {
1010 if (!locals.user) {
@@ -25,12 +25,14 @@ export async function POST({ request, cookies, locals }) {
2525
2626 // Update the user status in the database
2727 // Always update lastSeen to support timeout detection
28- await db.update(users)
28+ const updatedUser = await db.update(users)
2929 .set({
3030 status,
3131 lastSeen: now // Always update lastSeen for proper timeout detection
3232 })
33- .where(eq(users.id, userId));
33+ .where(eq(users.id, userId))
34+ .returning()
35+ .get();
3436 console.log('[Status] Database updated successfully');
3537
3638 // Renew session if status is online and session is near expiry
@@ -51,15 +53,11 @@ export async function POST({ request, cookies, locals }) {
5153 }
5254 }
5355
54- // Broadcast the status update via SSE
55- sseEmitter.emit('sse', {
56- type: 'userStatusUpdate',
57- data: {
58- userId,
59- status,
60- lastSeen: now // Always send the current timestamp
61- }
56+ return new Response(JSON.stringify({
57+ success: true,
58+ user: createSafeUser(updatedUser)
59+ }), {
60+ status: 200,
61+ headers: { 'Content-Type': 'application/json' }
6262 });
63-
64- return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
6563 }⏎̸
@@ -1,10 +1,10 @@
1 import { error } from '@sveltejs/kit';1 import { error } from '@sveltejs/kit';
2-import { sseEmitter } from '$lib/sseEmitter';
3 import db from '$lib/db/db.server';2 import db from '$lib/db/db.server';
4 import { users } from '$lib/db/schema';3 import { users } from '$lib/db/schema';
5 import { eq } from 'drizzle-orm';4 import { eq } from 'drizzle-orm';
6 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';5 import { validateSessionToken, generateSessionToken, createSession } from '$lib/api/session.server';
7 import { setSessionTokenCookie } from '$lib/api/session.cookie';6 import { setSessionTokenCookie } from '$lib/api/session.cookie';
7+import { createSafeUser } from '$lib/types/chat';
8 8
9 export async function POST({ request, cookies, locals }) {9 export async function POST({ request, cookies, locals }) {
10 if (!locals.user) {10 if (!locals.user) {
@@ -25,12 +25,14 @@ export async function POST({ request, cookies, locals }) {
25 25
26 // Update the user status in the database26 // Update the user status in the database
27 // Always update lastSeen to support timeout detection27 // Always update lastSeen to support timeout detection
28- await db.update(users)28+ const updatedUser = await db.update(users)
29 .set({ 29 .set({
30 status, 30 status,
31 lastSeen: now // Always update lastSeen for proper timeout detection31 lastSeen: now // Always update lastSeen for proper timeout detection
32 })32 })
33- .where(eq(users.id, userId));33+ .where(eq(users.id, userId))
34+ .returning()
35+ .get();
34 console.log('[Status] Database updated successfully');36 console.log('[Status] Database updated successfully');
35 37
36 // Renew session if status is online and session is near expiry38 // Renew session if status is online and session is near expiry
@@ -51,15 +53,11 @@ export async function POST({ request, cookies, locals }) {
51 }53 }
52 }54 }
53 55
54- // Broadcast the status update via SSE56+ return new Response(JSON.stringify({
55- sseEmitter.emit('sse', { 57+ success: true,
56- type: 'userStatusUpdate', 58+ user: createSafeUser(updatedUser)
57- data: { 59+ }), {
58- userId, 60+ status: 200,
59- status, 61+ headers: { 'Content-Type': 'application/json' }
60- lastSeen: now // Always send the current timestamp
61- }
62 });62 });
63-
64- return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
65 }⏎̸63 }⏎̸
M src/routes/api/users/[userId]/+server.ts +7 -10
@@ -3,19 +3,14 @@ import db from '$lib/db/db.server';
33 import { users } from '$lib/db/schema';
44 import { eq } from 'drizzle-orm';
55 import type { RequestHandler } from './$types';
6+import { createSafeUser } from '$lib/types/chat';
67
78 export const GET: RequestHandler = async ({ params }) => {
89 const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`;
910 console.log('[Users] Fetching user data:', { userId: maskedUserId });
1011
1112 try {
12- const user = await db.select({
13- id: users.id,
14- nickname: users.nickname,
15- status: users.status,
16- avatarUrl: users.avatarUrl,
17- lastSeen: users.lastSeen
18- })
13+ const user = await db.select()
1914 .from(users)
2015 .where(eq(users.id, params.userId))
2116 .get();
@@ -25,14 +20,16 @@ export const GET: RequestHandler = async ({ params }) => {
2520 throw error(404, 'User not found');
2621 }
2722
23+ const safeUser = createSafeUser(user);
24+
2825 console.log('[Users] User data retrieved:', {
2926 userId: maskedUserId,
30- status: user.status,
31- lastSeen: user.lastSeen ? new Date(user.lastSeen).toISOString() : null
27+ status: safeUser.status,
28+ lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null
3229 });
3330 return json({
3431 success: true,
35- user
32+ user: safeUser
3633 });
3734 } catch {
3835 console.log('[Users] Error fetching user data:', { userId: maskedUserId });
@@ -3,19 +3,14 @@ import db from '$lib/db/db.server';
3 import { users } from '$lib/db/schema';3 import { users } from '$lib/db/schema';
4 import { eq } from 'drizzle-orm';4 import { eq } from 'drizzle-orm';
5 import type { RequestHandler } from './$types';5 import type { RequestHandler } from './$types';
6+import { createSafeUser } from '$lib/types/chat';
6 7
7 export const GET: RequestHandler = async ({ params }) => {8 export const GET: RequestHandler = async ({ params }) => {
8 const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`;9 const maskedUserId = `${params.userId.slice(0, 4)}...${params.userId.slice(-4)}`;
9 console.log('[Users] Fetching user data:', { userId: maskedUserId });10 console.log('[Users] Fetching user data:', { userId: maskedUserId });
10 11
11 try {12 try {
12- const user = await db.select({13+ const user = await db.select()
13- id: users.id,
14- nickname: users.nickname,
15- status: users.status,
16- avatarUrl: users.avatarUrl,
17- lastSeen: users.lastSeen
18- })
19 .from(users)14 .from(users)
20 .where(eq(users.id, params.userId))15 .where(eq(users.id, params.userId))
21 .get();16 .get();
@@ -25,14 +20,16 @@ export const GET: RequestHandler = async ({ params }) => {
25 throw error(404, 'User not found');20 throw error(404, 'User not found');
26 }21 }
27 22
23+ const safeUser = createSafeUser(user);
24+
28 console.log('[Users] User data retrieved:', { 25 console.log('[Users] User data retrieved:', {
29 userId: maskedUserId,26 userId: maskedUserId,
30- status: user.status,27+ status: safeUser.status,
31- lastSeen: user.lastSeen ? new Date(user.lastSeen).toISOString() : null28+ lastSeen: safeUser.lastSeen ? new Date(safeUser.lastSeen).toISOString() : null
32 });29 });
33 return json({30 return json({
34 success: true,31 success: true,
35- user32+ user: safeUser
36 });33 });
37 } catch {34 } catch {
38 console.log('[Users] Error fetching user data:', { userId: maskedUserId });35 console.log('[Users] Error fetching user data:', { userId: maskedUserId });

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