detached 64c03e0

feat: Implement full-stack chat system with auth integration

- Add complete authentication flow with session management
- Implement real-time chat functionality with SSE
- Set up proper database schema with user/room/message relations
- Enhance UI components for login/chat with proper state management
- Add error handling and loading states across UI
- Implement proper password hashing and session security
- Add user presence tracking and status updates
- Configure automatic database initialization
- Add comprehensive type definitions for chat system
- Implement message persistence and history loading
commit
64c03e0366c5aa7f049147eddb283647876f32f9
author
Kit · · 2025-02-12 21:37 UTC
parent
872a426
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
refs
tag: v0.1
20 files · +1439 -154
M src/app.d.ts +7 -1
@@ -1,13 +1,19 @@
11 // See https://svelte.dev/docs/kit/types#app.d.ts
22 // for information about these interfaces
3+import type { User, Session } from "$lib/types/chat";
4+
35 declare global {
46 namespace App {
57 // interface Error {}
6- // interface Locals {}
8+ interface Locals {
9+ user: User | null;
10+ session: Session | null;
11+ }
712 // interface PageData {}
813 // interface PageState {}
914 // interface Platform {}
1015 }
16+ type Window = object
1117 }
1218
1319 export {};
@@ -1,13 +1,19 @@
1 // See https://svelte.dev/docs/kit/types#app.d.ts1 // See https://svelte.dev/docs/kit/types#app.d.ts
2 // for information about these interfaces2 // for information about these interfaces
3+import type { User, Session } from "$lib/types/chat";
4+
3 declare global {5 declare global {
4 namespace App {6 namespace App {
5 // interface Error {}7 // interface Error {}
6- // interface Locals {}8+ interface Locals {
9+ user: User | null;
10+ session: Session | null;
11+ }
7 // interface PageData {}12 // interface PageData {}
8 // interface PageState {}13 // interface PageState {}
9 // interface Platform {}14 // interface Platform {}
10 }15 }
16+ type Window = object
11 }17 }
12 18
13 export {};19 export {};
A src/hooks.server.ts +78 -0
@@ -0,0 +1,78 @@
1+import type { Handle } from "@sveltejs/kit";
2+import { validateSessionToken } from "$lib/api/session.server";
3+import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie";
4+
5+export const handle: Handle = async ({ event, resolve }) => {
6+ console.debug("Handling request:", event.url.pathname);
7+
8+ // Check if this is a public chat messages request
9+ const isPublicChatRequest =
10+ event.url.pathname === '/api/chat/messages' &&
11+ event.url.searchParams.get('public') === 'true' &&
12+ event.request.method === 'GET';
13+
14+ // Check if this is a public room request
15+ const isPublicRoomRequest =
16+ event.url.pathname.startsWith('/api/rooms/') &&
17+ event.url.searchParams.get('public') === 'true' &&
18+ event.request.method === 'GET';
19+
20+ // Public routes that don't require authentication
21+ const publicRoutes = [
22+ "/",
23+ "/api/session/login",
24+ "/api/session/validate",
25+ "/api/register",
26+ "/login",
27+ "/register"
28+ ];
29+
30+ // Get session token from cookies
31+ const token = event.cookies.get("session") ?? null;
32+
33+ // Handle public requests separately: always clear session
34+ if (isPublicChatRequest || isPublicRoomRequest) {
35+ console.debug("Public request accessed:", event.url.pathname);
36+ event.locals.user = null;
37+ event.locals.session = null; // Public access doesn't require a session
38+ return resolve(event);
39+ }
40+
41+ // Handle public routes: if token exists, validate and propagate
42+ if (publicRoutes.includes(event.url.pathname)) {
43+ console.debug("Public route accessed:", event.url.pathname);
44+ if (token) {
45+ const { session, user } = await validateSessionToken(token);
46+ if (session !== null) {
47+ console.debug("Valid session found on public route for user:", user?.nickname);
48+ setSessionTokenCookie(event, token, session.expiresAt);
49+ event.locals.session = session;
50+ event.locals.user = user;
51+ }
52+ }
53+ return resolve(event);
54+ }
55+
56+ // Protected routes: ensure authentication
57+ if (token === null) {
58+ console.debug("No session token found");
59+ event.locals.user = null;
60+ event.locals.session = null;
61+ return new Response("Unauthorized", { status: 401 });
62+ }
63+
64+ const { session, user } = await validateSessionToken(token);
65+ if (session !== null) {
66+ console.debug("Valid session found for user:", user?.nickname);
67+ setSessionTokenCookie(event, token, session.expiresAt);
68+ event.locals.session = session;
69+ event.locals.user = user;
70+ return resolve(event);
71+ } else {
72+ console.debug("Invalid session token");
73+ deleteSessionTokenCookie(event);
74+ event.locals.session = null;
75+ event.locals.user = null;
76+ return new Response("Unauthorized", { status: 401 });
77+ }
78+}; ⏎̸
@@ -0,0 +1,78 @@
1+import type { Handle } from "@sveltejs/kit";
2+import { validateSessionToken } from "$lib/api/session.server";
3+import { setSessionTokenCookie, deleteSessionTokenCookie } from "$lib/api/session.cookie";
4+
5+export const handle: Handle = async ({ event, resolve }) => {
6+ console.debug("Handling request:", event.url.pathname);
7+
8+ // Check if this is a public chat messages request
9+ const isPublicChatRequest =
10+ event.url.pathname === '/api/chat/messages' &&
11+ event.url.searchParams.get('public') === 'true' &&
12+ event.request.method === 'GET';
13+
14+ // Check if this is a public room request
15+ const isPublicRoomRequest =
16+ event.url.pathname.startsWith('/api/rooms/') &&
17+ event.url.searchParams.get('public') === 'true' &&
18+ event.request.method === 'GET';
19+
20+ // Public routes that don't require authentication
21+ const publicRoutes = [
22+ "/",
23+ "/api/session/login",
24+ "/api/session/validate",
25+ "/api/register",
26+ "/login",
27+ "/register"
28+ ];
29+
30+ // Get session token from cookies
31+ const token = event.cookies.get("session") ?? null;
32+
33+ // Handle public requests separately: always clear session
34+ if (isPublicChatRequest || isPublicRoomRequest) {
35+ console.debug("Public request accessed:", event.url.pathname);
36+ event.locals.user = null;
37+ event.locals.session = null; // Public access doesn't require a session
38+ return resolve(event);
39+ }
40+
41+ // Handle public routes: if token exists, validate and propagate
42+ if (publicRoutes.includes(event.url.pathname)) {
43+ console.debug("Public route accessed:", event.url.pathname);
44+ if (token) {
45+ const { session, user } = await validateSessionToken(token);
46+ if (session !== null) {
47+ console.debug("Valid session found on public route for user:", user?.nickname);
48+ setSessionTokenCookie(event, token, session.expiresAt);
49+ event.locals.session = session;
50+ event.locals.user = user;
51+ }
52+ }
53+ return resolve(event);
54+ }
55+
56+ // Protected routes: ensure authentication
57+ if (token === null) {
58+ console.debug("No session token found");
59+ event.locals.user = null;
60+ event.locals.session = null;
61+ return new Response("Unauthorized", { status: 401 });
62+ }
63+
64+ const { session, user } = await validateSessionToken(token);
65+ if (session !== null) {
66+ console.debug("Valid session found for user:", user?.nickname);
67+ setSessionTokenCookie(event, token, session.expiresAt);
68+ event.locals.session = session;
69+ event.locals.user = user;
70+ return resolve(event);
71+ } else {
72+ console.debug("Invalid session token");
73+ deleteSessionTokenCookie(event);
74+ event.locals.session = null;
75+ event.locals.user = null;
76+ return new Response("Unauthorized", { status: 401 });
77+ }
78+}; ⏎̸
A src/lib/api/session.cookie.ts +26 -0
@@ -0,0 +1,26 @@
1+import type { Cookies } from "@sveltejs/kit";
2+
3+export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void {
4+ console.debug("Setting session cookie with expiry:", new Date(expiresAt));
5+ cookies.set("session", token, {
6+ httpOnly: true,
7+ sameSite: "lax",
8+ expires: new Date(expiresAt),
9+ path: "/"
10+ });
11+}
12+
13+export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void {
14+ console.debug("Deleting session cookie");
15+ cookies.set("session", "", {
16+ httpOnly: true,
17+ sameSite: "lax",
18+ maxAge: 0,
19+ path: "/"
20+ });
21+}
22+
23+export function getSessionTokenCookie({ cookies }: { cookies: Cookies }): string | undefined {
24+ console.debug("Getting session cookie");
25+ return cookies.get("session");
26+} ⏎̸
@@ -0,0 +1,26 @@
1+import type { Cookies } from "@sveltejs/kit";
2+
3+export function setSessionTokenCookie({ cookies }: { cookies: Cookies }, token: string, expiresAt: number): void {
4+ console.debug("Setting session cookie with expiry:", new Date(expiresAt));
5+ cookies.set("session", token, {
6+ httpOnly: true,
7+ sameSite: "lax",
8+ expires: new Date(expiresAt),
9+ path: "/"
10+ });
11+}
12+
13+export function deleteSessionTokenCookie({ cookies }: { cookies: Cookies }): void {
14+ console.debug("Deleting session cookie");
15+ cookies.set("session", "", {
16+ httpOnly: true,
17+ sameSite: "lax",
18+ maxAge: 0,
19+ path: "/"
20+ });
21+}
22+
23+export function getSessionTokenCookie({ cookies }: { cookies: Cookies }): string | undefined {
24+ console.debug("Getting session cookie");
25+ return cookies.get("session");
26+} ⏎̸
M src/lib/api/session.server.ts +1 -1
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm';
44 import type { Session, User } from '../types/chat';
55
66 // Helper: Compute SHA-256 hash of a message and encode it as a hex string.
7-async function sha256(message: string): Promise<string> {
7+export async function sha256(message: string): Promise<string> {
88 const encoder = new TextEncoder();
99 const data = encoder.encode(message);
1010 const hashBuffer = await crypto.subtle.digest('SHA-256', data);
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm';
4 import type { Session, User } from '../types/chat';4 import type { Session, User } from '../types/chat';
5 5
6 // Helper: Compute SHA-256 hash of a message and encode it as a hex string.6 // Helper: Compute SHA-256 hash of a message and encode it as a hex string.
7-async function sha256(message: string): Promise<string> {7+export async function sha256(message: string): Promise<string> {
8 const encoder = new TextEncoder();8 const encoder = new TextEncoder();
9 const data = encoder.encode(message);9 const data = encoder.encode(message);
10 const hashBuffer = await crypto.subtle.digest('SHA-256', data);10 const hashBuffer = await crypto.subtle.digest('SHA-256', data);
M src/lib/components/aim-login.svelte +179 -19
@@ -1,10 +1,14 @@
11 <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->
22 <script lang="ts">
33 import { draggable } from '$lib/actions/draggable';
4+ import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5+ import type { User } from '$lib/types/chat';
6+ import { invalidate } from '$app/navigation';
47 import { createEventDispatcher } from 'svelte';
58
9+ let { onLoginSuccess }: { onLoginSuccess?: (user: User) => void } = $props();
610 const dispatch = createEventDispatcher();
7-
11+
812 let activeTab = $state('signin');
913
1014 // Sign In state
@@ -13,6 +17,7 @@
1317 let savePassword = $state(true);
1418 let autoLogin = $state(false);
1519 let error = $state('');
20+ let loginStatus = $state('idle');
1621
1722 // Sign Up state
1823 let suUsername = $state('');
@@ -20,33 +25,135 @@
2025 let suConfirmPassword = $state('');
2126 let captchaAnswer = $state('');
2227 let signupError = $state('');
28+ let signupStatus = $state('idle');
2329
24- function handleSigninSubmit() {
30+ async function handleSigninSubmit() {
2531 error = '';
32+ loginStatus = 'loading';
2633 if (!siUsername || !siPassword) {
2734 error = 'Please enter both username and password';
35+ loginStatus = 'error';
36+ return;
37+ }
38+
39+ console.debug("Attempting login for", siUsername);
40+ try {
41+ const res = await fetch('/api/session/login', {
42+ method: 'POST',
43+ headers: { 'Content-Type': 'application/json' },
44+ body: JSON.stringify({
45+ username: siUsername,
46+ password: siPassword
47+ })
48+ });
49+
50+ const data = await res.json();
51+ if (!res.ok) {
52+ error = data.error || "Login failed.";
53+ console.debug("Login failed:", data.error);
54+ loginStatus = 'error';
55+ return;
56+ }
57+
58+ console.debug("Login succeeded:", data);
59+ loginStatus = 'success';
60+
61+ // Invalidate all session-related dependencies
62+ await Promise.all([
63+ invalidate('app:session'),
64+ invalidate('app:chat'),
65+ invalidate('custom:chat:session'),
66+ invalidate('chat:session')
67+ ]);
68+
69+ // Call the callback prop if provided
70+ onLoginSuccess?.(data.user);
71+ // Auto close login window after 3 seconds on successful login
72+ setTimeout(() => {
73+ console.debug("Auto closing AIM Login component after successful login");
74+ handleClose();
75+ }, 3000);
76+
2877 return;
78+ } catch (err) {
79+ console.debug("Login error:", err);
80+ error = "Login error. Please try again.";
81+ loginStatus = 'error';
2982 }
30- // TODO: Implement authentication for sign in
31- console.log('Sign in', siUsername, siPassword, savePassword, autoLogin);
3283 }
3384
34- function handleSignupSubmit() {
85+ async function handleSignupSubmit() {
3586 signupError = '';
87+ signupStatus = 'loading';
3688 if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) {
3789 signupError = 'Please fill all fields';
90+ signupStatus = 'error';
3891 return;
3992 }
4093 if (suPassword !== suConfirmPassword) {
4194 signupError = 'Passwords do not match';
95+ signupStatus = 'error';
4296 return;
4397 }
44- if (captchaAnswer.toLowerCase() !== 'personal digital repository') {
45- signupError = 'Incorrect captcha answer';
98+
99+ console.debug("Attempting registration for", suUsername);
100+ try {
101+ const res = await fetch('/api/register', {
102+ method: 'POST',
103+ headers: { 'Content-Type': 'application/json' },
104+ body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer })
105+ });
106+
107+ const data = await res.json() as RegisterResponse;
108+ if (!res.ok) {
109+ const errorData = data as RegisterResponseError;
110+ signupError = errorData.error || "Registration failed.";
111+ signupStatus = 'error';
112+ return;
113+ }
114+ console.debug("Registration succeeded:", data);
115+ signupStatus = 'success';
116+
117+ // Auto login after registration
118+ await handleAutoLoginSignup();
46119 return;
120+ } catch (err) {
121+ console.debug("Registration error", err);
122+ signupError = "Registration error. Please try again.";
123+ signupStatus = 'error';
124+ }
125+ }
126+
127+ async function handleAutoLoginSignup() {
128+ try {
129+ const res = await fetch('/api/session/login', {
130+ method: 'POST',
131+ headers: { 'Content-Type': 'application/json' },
132+ body: JSON.stringify({ username: suUsername, password: suPassword })
133+ });
134+ const data = await res.json();
135+ if (!res.ok) {
136+ signupError = data.error || "Login failed after registration.";
137+ signupStatus = 'error';
138+ return;
139+ }
140+ console.debug("Auto login after registration succeeded:", data);
141+ await Promise.all([
142+ invalidate('app:session'),
143+ invalidate('app:chat'),
144+ invalidate('custom:chat:session'),
145+ invalidate('chat:session')
146+ ]);
147+ onLoginSuccess?.(data.user);
148+ setTimeout(() => {
149+ console.debug("Auto closing AIM Login component after auto login on registration");
150+ handleClose();
151+ }, 3000);
152+ } catch (err) {
153+ console.debug("Auto login error:", err);
154+ signupError = "Auto login error. Please try to login manually.";
155+ signupStatus = 'error';
47156 }
48- // TODO: Implement registration for sign up
49- console.log('Sign up', suUsername, suPassword);
50157 }
51158
52159 function switchTab(tab: string) {
@@ -56,9 +163,17 @@
56163 }
57164
58165 function handleClose() {
166+ console.debug("Closing AIM Login component.");
59167 dispatch('close');
60168 }
61169
170+ function handleKeydown(event: KeyboardEvent) {
171+ if (event.key === 'Escape') {
172+ console.debug("Escape key pressed, closing AIM Login component.");
173+ handleClose();
174+ }
175+ }
176+
62177 // Added dragging state and handler similar to chat-room
63178 let windowX = $state(0);
64179 let windowY = $state(0);
@@ -69,6 +184,8 @@
69184 }
70185 </script>
71186
187+<svelte:window onkeydown={handleKeydown} />
188+
72189 <!-- Updated outer div to handle dragging with inline style and drag event -->
73190 <div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;">
74191 <div class="title-bar">
@@ -111,10 +228,29 @@
111228 <div class="error">{error}</div>
112229 {/if}
113230 <div class="button-bar">
114- <button type="submit" class="icon-button">
115- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
116- <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>
117- </svg>
231+ <button type="submit" class="icon-button {loginStatus === 'success' ? 'success-border' : loginStatus === 'error' ? 'error-border' : ''}">
232+ {#if loginStatus === 'loading'}
233+ <svg width="24" height="24" viewBox="0 0 24 24">
234+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
235+ <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
236+ <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/>
237+ </path>
238+ </svg>
239+ {:else if loginStatus === 'success'}
240+ <svg width="24" height="24" viewBox="0 0 24 24">
241+ <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
242+ </svg>
243+ {:else if loginStatus === 'error'}
244+ <svg width="24" height="24" viewBox="0 0 24 24">
245+ <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"/>
246+ <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"/>
247+ <circle cx="12" cy="17" r="1" fill="red"/>
248+ </svg>
249+ {:else}
250+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
251+ <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>
252+ </svg>
253+ {/if}
118254 Sign In
119255 </button>
120256 </div>
@@ -141,10 +277,29 @@
141277 <div class="error">{signupError}</div>
142278 {/if}
143279 <div class="button-bar">
144- <button type="submit" class="icon-button">
145- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
146- <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>
147- </svg>
280+ <button type="submit" class="icon-button {signupStatus === 'success' ? 'success-border' : signupStatus === 'error' ? 'error-border' : ''}">
281+ {#if signupStatus === 'loading'}
282+ <svg width="24" height="24" viewBox="0 0 24 24">
283+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
284+ <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
285+ <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"></animateTransform>
286+ </path>
287+ </svg>
288+ {:else if signupStatus === 'success'}
289+ <svg width="24" height="24" viewBox="0 0 24 24">
290+ <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"></path>
291+ </svg>
292+ {:else if signupStatus === 'error'}
293+ <svg width="24" height="24" viewBox="0 0 24 24">
294+ <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle>
295+ <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"></line>
296+ <circle cx="12" cy="17" r="1" fill="red"></circle>
297+ </svg>
298+ {:else}
299+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
300+ <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"></path>
301+ </svg>
302+ {/if}
148303 Sign Up
149304 </button>
150305 </div>
@@ -207,8 +362,7 @@
207362 margin-bottom: 4px;
208363 font-size: 1rem;
209364 }
210- .form-group input,
211- .form-group select {
365+ .form-group input {
212366 width: 100%;
213367 padding: 4px;
214368 font-size: 1rem;
@@ -245,4 +399,10 @@
245399 font-size: 1rem;
246400 margin-top: 8px;
247401 }
402+ .success-border {
403+ border: 2px solid green;
404+ }
405+ .error-border {
406+ border: 2px solid red;
407+ }
248408 </style> ⏎̸
@@ -1,10 +1,14 @@
1 <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->1 <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->
2 <script lang="ts">2 <script lang="ts">
3 import { draggable } from '$lib/actions/draggable';3 import { draggable } from '$lib/actions/draggable';
4+ import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads';
5+ import type { User } from '$lib/types/chat';
6+ import { invalidate } from '$app/navigation';
4 import { createEventDispatcher } from 'svelte';7 import { createEventDispatcher } from 'svelte';
5 8
9+ let { onLoginSuccess }: { onLoginSuccess?: (user: User) => void } = $props();
6 const dispatch = createEventDispatcher();10 const dispatch = createEventDispatcher();
7-11+
8 let activeTab = $state('signin');12 let activeTab = $state('signin');
9 13
10 // Sign In state14 // Sign In state
@@ -13,6 +17,7 @@
13 let savePassword = $state(true);17 let savePassword = $state(true);
14 let autoLogin = $state(false);18 let autoLogin = $state(false);
15 let error = $state('');19 let error = $state('');
20+ let loginStatus = $state('idle');
16 21
17 // Sign Up state22 // Sign Up state
18 let suUsername = $state('');23 let suUsername = $state('');
@@ -20,33 +25,135 @@
20 let suConfirmPassword = $state('');25 let suConfirmPassword = $state('');
21 let captchaAnswer = $state('');26 let captchaAnswer = $state('');
22 let signupError = $state('');27 let signupError = $state('');
28+ let signupStatus = $state('idle');
23 29
24- function handleSigninSubmit() {30+ async function handleSigninSubmit() {
25 error = '';31 error = '';
32+ loginStatus = 'loading';
26 if (!siUsername || !siPassword) {33 if (!siUsername || !siPassword) {
27 error = 'Please enter both username and password';34 error = 'Please enter both username and password';
35+ loginStatus = 'error';
36+ return;
37+ }
38+
39+ console.debug("Attempting login for", siUsername);
40+ try {
41+ const res = await fetch('/api/session/login', {
42+ method: 'POST',
43+ headers: { 'Content-Type': 'application/json' },
44+ body: JSON.stringify({
45+ username: siUsername,
46+ password: siPassword
47+ })
48+ });
49+
50+ const data = await res.json();
51+ if (!res.ok) {
52+ error = data.error || "Login failed.";
53+ console.debug("Login failed:", data.error);
54+ loginStatus = 'error';
55+ return;
56+ }
57+
58+ console.debug("Login succeeded:", data);
59+ loginStatus = 'success';
60+
61+ // Invalidate all session-related dependencies
62+ await Promise.all([
63+ invalidate('app:session'),
64+ invalidate('app:chat'),
65+ invalidate('custom:chat:session'),
66+ invalidate('chat:session')
67+ ]);
68+
69+ // Call the callback prop if provided
70+ onLoginSuccess?.(data.user);
71+ // Auto close login window after 3 seconds on successful login
72+ setTimeout(() => {
73+ console.debug("Auto closing AIM Login component after successful login");
74+ handleClose();
75+ }, 3000);
76+
28 return;77 return;
78+ } catch (err) {
79+ console.debug("Login error:", err);
80+ error = "Login error. Please try again.";
81+ loginStatus = 'error';
29 }82 }
30- // TODO: Implement authentication for sign in
31- console.log('Sign in', siUsername, siPassword, savePassword, autoLogin);
32 }83 }
33 84
34- function handleSignupSubmit() {85+ async function handleSignupSubmit() {
35 signupError = '';86 signupError = '';
87+ signupStatus = 'loading';
36 if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) {88 if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) {
37 signupError = 'Please fill all fields';89 signupError = 'Please fill all fields';
90+ signupStatus = 'error';
38 return;91 return;
39 }92 }
40 if (suPassword !== suConfirmPassword) {93 if (suPassword !== suConfirmPassword) {
41 signupError = 'Passwords do not match';94 signupError = 'Passwords do not match';
95+ signupStatus = 'error';
42 return;96 return;
43 }97 }
44- if (captchaAnswer.toLowerCase() !== 'personal digital repository') {98+
45- signupError = 'Incorrect captcha answer';99+ console.debug("Attempting registration for", suUsername);
100+ try {
101+ const res = await fetch('/api/register', {
102+ method: 'POST',
103+ headers: { 'Content-Type': 'application/json' },
104+ body: JSON.stringify({ suUsername, suPassword, suConfirmPassword, captchaAnswer })
105+ });
106+
107+ const data = await res.json() as RegisterResponse;
108+ if (!res.ok) {
109+ const errorData = data as RegisterResponseError;
110+ signupError = errorData.error || "Registration failed.";
111+ signupStatus = 'error';
112+ return;
113+ }
114+ console.debug("Registration succeeded:", data);
115+ signupStatus = 'success';
116+
117+ // Auto login after registration
118+ await handleAutoLoginSignup();
46 return;119 return;
120+ } catch (err) {
121+ console.debug("Registration error", err);
122+ signupError = "Registration error. Please try again.";
123+ signupStatus = 'error';
124+ }
125+ }
126+
127+ async function handleAutoLoginSignup() {
128+ try {
129+ const res = await fetch('/api/session/login', {
130+ method: 'POST',
131+ headers: { 'Content-Type': 'application/json' },
132+ body: JSON.stringify({ username: suUsername, password: suPassword })
133+ });
134+ const data = await res.json();
135+ if (!res.ok) {
136+ signupError = data.error || "Login failed after registration.";
137+ signupStatus = 'error';
138+ return;
139+ }
140+ console.debug("Auto login after registration succeeded:", data);
141+ await Promise.all([
142+ invalidate('app:session'),
143+ invalidate('app:chat'),
144+ invalidate('custom:chat:session'),
145+ invalidate('chat:session')
146+ ]);
147+ onLoginSuccess?.(data.user);
148+ setTimeout(() => {
149+ console.debug("Auto closing AIM Login component after auto login on registration");
150+ handleClose();
151+ }, 3000);
152+ } catch (err) {
153+ console.debug("Auto login error:", err);
154+ signupError = "Auto login error. Please try to login manually.";
155+ signupStatus = 'error';
47 }156 }
48- // TODO: Implement registration for sign up
49- console.log('Sign up', suUsername, suPassword);
50 }157 }
51 158
52 function switchTab(tab: string) {159 function switchTab(tab: string) {
@@ -56,9 +163,17 @@
56 }163 }
57 164
58 function handleClose() {165 function handleClose() {
166+ console.debug("Closing AIM Login component.");
59 dispatch('close');167 dispatch('close');
60 }168 }
61 169
170+ function handleKeydown(event: KeyboardEvent) {
171+ if (event.key === 'Escape') {
172+ console.debug("Escape key pressed, closing AIM Login component.");
173+ handleClose();
174+ }
175+ }
176+
62 // Added dragging state and handler similar to chat-room177 // Added dragging state and handler similar to chat-room
63 let windowX = $state(0);178 let windowX = $state(0);
64 let windowY = $state(0);179 let windowY = $state(0);
@@ -69,6 +184,8 @@
69 }184 }
70 </script>185 </script>
71 186
187+<svelte:window onkeydown={handleKeydown} />
188+
72 <!-- Updated outer div to handle dragging with inline style and drag event -->189 <!-- Updated outer div to handle dragging with inline style and drag event -->
73 <div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;">190 <div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;">
74 <div class="title-bar">191 <div class="title-bar">
@@ -111,10 +228,29 @@
111 <div class="error">{error}</div>228 <div class="error">{error}</div>
112 {/if}229 {/if}
113 <div class="button-bar">230 <div class="button-bar">
114- <button type="submit" class="icon-button">231+ <button type="submit" class="icon-button {loginStatus === 'success' ? 'success-border' : loginStatus === 'error' ? 'error-border' : ''}">
115- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">232+ {#if loginStatus === 'loading'}
116- <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>233+ <svg width="24" height="24" viewBox="0 0 24 24">
117- </svg>234+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
235+ <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
236+ <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"/>
237+ </path>
238+ </svg>
239+ {:else if loginStatus === 'success'}
240+ <svg width="24" height="24" viewBox="0 0 24 24">
241+ <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
242+ </svg>
243+ {:else if loginStatus === 'error'}
244+ <svg width="24" height="24" viewBox="0 0 24 24">
245+ <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"/>
246+ <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"/>
247+ <circle cx="12" cy="17" r="1" fill="red"/>
248+ </svg>
249+ {:else}
250+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
251+ <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>
252+ </svg>
253+ {/if}
118 Sign In254 Sign In
119 </button>255 </button>
120 </div>256 </div>
@@ -141,10 +277,29 @@
141 <div class="error">{signupError}</div>277 <div class="error">{signupError}</div>
142 {/if}278 {/if}
143 <div class="button-bar">279 <div class="button-bar">
144- <button type="submit" class="icon-button">280+ <button type="submit" class="icon-button {signupStatus === 'success' ? 'success-border' : signupStatus === 'error' ? 'error-border' : ''}">
145- <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">281+ {#if signupStatus === 'loading'}
146- <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>282+ <svg width="24" height="24" viewBox="0 0 24 24">
147- </svg>283+ <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" fill="none" opacity="0.25"></circle>
284+ <path d="M12 2a10 10 0 0 1 0 20" stroke="currentColor" stroke-width="2" fill="none">
285+ <animateTransform attributeName="transform" type="rotate" values="0 12 12;360 12 12" dur="1s" repeatCount="indefinite"></animateTransform>
286+ </path>
287+ </svg>
288+ {:else if signupStatus === 'success'}
289+ <svg width="24" height="24" viewBox="0 0 24 24">
290+ <path d="M20 6L9 17l-5-5" stroke="green" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"></path>
291+ </svg>
292+ {:else if signupStatus === 'error'}
293+ <svg width="24" height="24" viewBox="0 0 24 24">
294+ <circle cx="12" cy="12" r="10" stroke="red" stroke-width="2" fill="none"></circle>
295+ <line x1="12" y1="8" x2="12" y2="13" stroke="red" stroke-width="2" stroke-linecap="round"></line>
296+ <circle cx="12" cy="17" r="1" fill="red"></circle>
297+ </svg>
298+ {:else}
299+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
300+ <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"></path>
301+ </svg>
302+ {/if}
148 Sign Up303 Sign Up
149 </button>304 </button>
150 </div>305 </div>
@@ -207,8 +362,7 @@
207 margin-bottom: 4px;362 margin-bottom: 4px;
208 font-size: 1rem;363 font-size: 1rem;
209 }364 }
210- .form-group input,365+ .form-group input {
211- .form-group select {
212 width: 100%;366 width: 100%;
213 padding: 4px;367 padding: 4px;
214 font-size: 1rem;368 font-size: 1rem;
@@ -245,4 +399,10 @@
245 font-size: 1rem;399 font-size: 1rem;
246 margin-top: 8px;400 margin-top: 8px;
247 }401 }
402+ .success-border {
403+ border: 2px solid green;
404+ }
405+ .error-border {
406+ border: 2px solid red;
407+ }
248 </style> ⏎̸408 </style> ⏎̸
M src/lib/components/chat-room.svelte +104 -15
@@ -1,6 +1,6 @@
11 <script lang="ts">
22 import { chatState } from '../states/chat.svelte';
3-import type { MessageType, User, ChatRoom, Message, UserStatus } from '../types/chat';
3+import type { MessageType, User, ChatRoom, Message, EnrichedMessage } from '../types/chat';
44 import { onMount } from 'svelte';
55 import { browser } from '$app/environment';
66 import { draggable } from '$lib/actions/draggable';
@@ -17,8 +17,6 @@ let currentMessage = $state('');
1717 let showUserList = $state(false);
1818 let isMaximized = $state(false);
1919
20-
21-// let { value = $bindable(), ...props } = $props();
2220 let { showChatRoom = $bindable() } = $props();
2321
2422 function handleClose() {
@@ -26,9 +24,85 @@ function handleClose() {
2624 }
2725
2826 // Reactive state using derived values
29-let messages = $derived(chatState.getMessages());
30-let onlineUsers = $derived(chatState.getOnlineUsers());
31-let currentUser = $derived(chatState.getCurrentUser());
27+let messages = $state<EnrichedMessage[]>([]);
28+let onlineUsers = $state<User[]>([]);
29+let currentUser = $state<User | null>(null);
30+
31+// Compute visible messages based on login status
32+let visibleMessages = $derived((() => {
33+ const isLoggedIn = Boolean(currentUser);
34+ const messageCount = messages.length;
35+ const result: EnrichedMessage[] = isLoggedIn
36+ ? messages
37+ : messages.slice(Math.max(0, messageCount - 10));
38+ return result;
39+})());
40+
41+// Single effect to handle all state updates
42+$effect(() => {
43+ const user = chatState.getCurrentUser();
44+ if (user?.id !== currentUser?.id) {
45+ currentUser = user;
46+ }
47+
48+ // Only update messages if we have new ones
49+ const stateMessages = chatState.getMessages();
50+ if (stateMessages.length !== messages.length) {
51+ messages = stateMessages;
52+
53+ // Scroll to bottom when new messages arrive
54+ const chatArea = document.querySelector('.chat-area');
55+ if (chatArea) {
56+ setTimeout(() => {
57+ chatArea.scrollTop = chatArea.scrollHeight;
58+ }, 0);
59+ }
60+ }
61+
62+ // Update online users
63+ const stateOnlineUsers = chatState.getOnlineUsers();
64+ if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
65+ onlineUsers = stateOnlineUsers;
66+ }
67+});
68+
69+// Fetch public messages only once when needed
70+let fetchedPublicMessages = false;
71+let fetchedPublicRoom = false;
72+
73+$effect(() => {
74+ if (!currentUser) {
75+ // Fetch both public room data and messages in parallel but handle them in sequence
76+ if (!fetchedPublicRoom && !fetchedPublicMessages) {
77+ fetchedPublicRoom = true;
78+ fetchedPublicMessages = true;
79+
80+ // Fetch both in parallel
81+ Promise.all([
82+ fetch('/api/rooms/default?public=true').then(r => r.json()),
83+ fetch('/api/chat/messages?public=true').then(r => r.json())
84+ ])
85+ .then(([roomData, messagesData]) => {
86+ if (roomData.success) {
87+ // First update the chat state with the buddy list
88+ chatState.updateUserCache(roomData.buddyList);
89+ onlineUsers = roomData.buddyList;
90+ }
91+
92+ // Then update messages after buddy list is cached
93+ if (messagesData.success && messagesData.messages) {
94+ chatState.updateMessages(messagesData.messages);
95+ messages = chatState.enrichMessages(messagesData.messages);
96+ }
97+ })
98+ .catch(error => {
99+ console.error("Error fetching public data:", error);
100+ onlineUsers = [];
101+ messages = [];
102+ });
103+ }
104+ }
105+});
32106
33107 onMount(() => {
34108 if (!browser) return;
@@ -53,13 +127,20 @@ onMount(() => {
53127
54128 // Add resize listener
55129 window.addEventListener('resize', handleResize);
130+
56131 return () => window.removeEventListener('resize', handleResize);
57132 });
58133
59-function handleSubmit() {
60- if (currentMessage.trim()) {
61- chatState.sendMessage(currentMessage);
134+async function handleSubmit() {
135+ if (!currentMessage.trim()) return;
136+
137+ try {
138+ await chatState.sendMessage(currentMessage);
62139 currentMessage = '';
140+ } catch (error) {
141+ console.error('Failed to send message:', error);
142+ // You might want to show this error to the user in a more friendly way
143+ alert('Failed to send message. Please try again.');
63144 }
64145 }
65146
@@ -158,14 +239,14 @@ function handleMaximizeEvent(event: CustomEvent<{
158239 class="sunken-panel chat-area"
159240 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
160241 >
161- {#each messages as message}
242+ {#each visibleMessages as message (message.id)}
162243 <div class="message {message.type} text">
163244 {#if message.type === 'emote'}
164- <span class="timestamp">{formatTimestamp(message.timestamp)}</span>
165- <span class="emote-text">{chatState.getUserById(message.userId)?.nickname} {message.content}</span>
245+ <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
246+ <span class="emote-text">{message.user.nickname} {message.content}</span>
166247 {:else}
167- <span class="timestamp">{formatTimestamp(message.timestamp)}</span>
168- <span class="nickname">{chatState.getUserById(message.userId)?.nickname}:</span>
248+ <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
249+ <span class="nickname">{message.user.nickname}:</span>
169250 <span class="content">{message.content}</span>
170251 {/if}
171252 </div>
@@ -179,8 +260,9 @@ function handleMaximizeEvent(event: CustomEvent<{
179260 style="flex: 1;"
180261 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
181262 placeholder="Type a message..."
263+ disabled={!currentUser}
182264 />
183- <button onclick={handleSubmit}>Send</button>
265+ <button onclick={handleSubmit} disabled={!currentUser}>Send</button>
184266 </div>
185267 </div>
186268
@@ -306,6 +388,13 @@ function handleMaximizeEvent(event: CustomEvent<{
306388 z-index: 2;
307389 }
308390
391+ .input-container input:disabled,
392+ .input-container button:disabled {
393+ opacity: 0.7;
394+ background-color: rgba(128, 128, 128, 0.1);
395+ cursor: not-allowed;
396+ }
397+
309398 .hidden {
310399 display: none !important;
311400 }
@@ -1,6 +1,6 @@
1 <script lang="ts">1 <script lang="ts">
2 import { chatState } from '../states/chat.svelte';2 import { chatState } from '../states/chat.svelte';
3-import type { MessageType, User, ChatRoom, Message, UserStatus } from '../types/chat';3+import type { MessageType, User, ChatRoom, Message, EnrichedMessage } from '../types/chat';
4 import { onMount } from 'svelte';4 import { onMount } from 'svelte';
5 import { browser } from '$app/environment';5 import { browser } from '$app/environment';
6 import { draggable } from '$lib/actions/draggable';6 import { draggable } from '$lib/actions/draggable';
@@ -17,8 +17,6 @@ let currentMessage = $state('');
17 let showUserList = $state(false);17 let showUserList = $state(false);
18 let isMaximized = $state(false);18 let isMaximized = $state(false);
19 19
20-
21-// let { value = $bindable(), ...props } = $props();
22 let { showChatRoom = $bindable() } = $props();20 let { showChatRoom = $bindable() } = $props();
23 21
24 function handleClose() {22 function handleClose() {
@@ -26,9 +24,85 @@ function handleClose() {
26 }24 }
27 25
28 // Reactive state using derived values26 // Reactive state using derived values
29-let messages = $derived(chatState.getMessages());27+let messages = $state<EnrichedMessage[]>([]);
30-let onlineUsers = $derived(chatState.getOnlineUsers());28+let onlineUsers = $state<User[]>([]);
31-let currentUser = $derived(chatState.getCurrentUser());29+let currentUser = $state<User | null>(null);
30+
31+// Compute visible messages based on login status
32+let visibleMessages = $derived((() => {
33+ const isLoggedIn = Boolean(currentUser);
34+ const messageCount = messages.length;
35+ const result: EnrichedMessage[] = isLoggedIn
36+ ? messages
37+ : messages.slice(Math.max(0, messageCount - 10));
38+ return result;
39+})());
40+
41+// Single effect to handle all state updates
42+$effect(() => {
43+ const user = chatState.getCurrentUser();
44+ if (user?.id !== currentUser?.id) {
45+ currentUser = user;
46+ }
47+
48+ // Only update messages if we have new ones
49+ const stateMessages = chatState.getMessages();
50+ if (stateMessages.length !== messages.length) {
51+ messages = stateMessages;
52+
53+ // Scroll to bottom when new messages arrive
54+ const chatArea = document.querySelector('.chat-area');
55+ if (chatArea) {
56+ setTimeout(() => {
57+ chatArea.scrollTop = chatArea.scrollHeight;
58+ }, 0);
59+ }
60+ }
61+
62+ // Update online users
63+ const stateOnlineUsers = chatState.getOnlineUsers();
64+ if (JSON.stringify(stateOnlineUsers) !== JSON.stringify(onlineUsers)) {
65+ onlineUsers = stateOnlineUsers;
66+ }
67+});
68+
69+// Fetch public messages only once when needed
70+let fetchedPublicMessages = false;
71+let fetchedPublicRoom = false;
72+
73+$effect(() => {
74+ if (!currentUser) {
75+ // Fetch both public room data and messages in parallel but handle them in sequence
76+ if (!fetchedPublicRoom && !fetchedPublicMessages) {
77+ fetchedPublicRoom = true;
78+ fetchedPublicMessages = true;
79+
80+ // Fetch both in parallel
81+ Promise.all([
82+ fetch('/api/rooms/default?public=true').then(r => r.json()),
83+ fetch('/api/chat/messages?public=true').then(r => r.json())
84+ ])
85+ .then(([roomData, messagesData]) => {
86+ if (roomData.success) {
87+ // First update the chat state with the buddy list
88+ chatState.updateUserCache(roomData.buddyList);
89+ onlineUsers = roomData.buddyList;
90+ }
91+
92+ // Then update messages after buddy list is cached
93+ if (messagesData.success && messagesData.messages) {
94+ chatState.updateMessages(messagesData.messages);
95+ messages = chatState.enrichMessages(messagesData.messages);
96+ }
97+ })
98+ .catch(error => {
99+ console.error("Error fetching public data:", error);
100+ onlineUsers = [];
101+ messages = [];
102+ });
103+ }
104+ }
105+});
32 106
33 onMount(() => {107 onMount(() => {
34 if (!browser) return;108 if (!browser) return;
@@ -53,13 +127,20 @@ onMount(() => {
53 127
54 // Add resize listener128 // Add resize listener
55 window.addEventListener('resize', handleResize);129 window.addEventListener('resize', handleResize);
130+
56 return () => window.removeEventListener('resize', handleResize);131 return () => window.removeEventListener('resize', handleResize);
57 });132 });
58 133
59-function handleSubmit() {134+async function handleSubmit() {
60- if (currentMessage.trim()) {135+ if (!currentMessage.trim()) return;
61- chatState.sendMessage(currentMessage);136+
137+ try {
138+ await chatState.sendMessage(currentMessage);
62 currentMessage = '';139 currentMessage = '';
140+ } catch (error) {
141+ console.error('Failed to send message:', error);
142+ // You might want to show this error to the user in a more friendly way
143+ alert('Failed to send message. Please try again.');
63 }144 }
64 }145 }
65 146
@@ -158,14 +239,14 @@ function handleMaximizeEvent(event: CustomEvent<{
158 class="sunken-panel chat-area"239 class="sunken-panel chat-area"
159 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"240 style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;"
160 >241 >
161- {#each messages as message}242+ {#each visibleMessages as message (message.id)}
162 <div class="message {message.type} text">243 <div class="message {message.type} text">
163 {#if message.type === 'emote'}244 {#if message.type === 'emote'}
164- <span class="timestamp">{formatTimestamp(message.timestamp)}</span>245+ <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
165- <span class="emote-text">{chatState.getUserById(message.userId)?.nickname} {message.content}</span>246+ <span class="emote-text">{message.user.nickname} {message.content}</span>
166 {:else}247 {:else}
167- <span class="timestamp">{formatTimestamp(message.timestamp)}</span>248+ <span class="timestamp">{formatTimestamp(new Date(message.timestamp))}</span>
168- <span class="nickname">{chatState.getUserById(message.userId)?.nickname}:</span>249+ <span class="nickname">{message.user.nickname}:</span>
169 <span class="content">{message.content}</span>250 <span class="content">{message.content}</span>
170 {/if}251 {/if}
171 </div>252 </div>
@@ -179,8 +260,9 @@ function handleMaximizeEvent(event: CustomEvent<{
179 style="flex: 1;"260 style="flex: 1;"
180 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}261 onkeydown={(e) => e.key === 'Enter' && handleSubmit()}
181 placeholder="Type a message..."262 placeholder="Type a message..."
263+ disabled={!currentUser}
182 />264 />
183- <button onclick={handleSubmit}>Send</button>265+ <button onclick={handleSubmit} disabled={!currentUser}>Send</button>
184 </div>266 </div>
185 </div>267 </div>
186 268
@@ -306,6 +388,13 @@ function handleMaximizeEvent(event: CustomEvent<{
306 z-index: 2;388 z-index: 2;
307 }389 }
308 390
391+ .input-container input:disabled,
392+ .input-container button:disabled {
393+ opacity: 0.7;
394+ background-color: rgba(128, 128, 128, 0.1);
395+ cursor: not-allowed;
396+ }
397+
309 .hidden {398 .hidden {
310 display: none !important;399 display: none !important;
311 }400 }
M src/lib/db/db.server.ts +20 -3
@@ -1,14 +1,31 @@
11 import { drizzle } from 'drizzle-orm/libsql';
22 import { createClient } from '@libsql/client';
33 import { TURSO_AUTH_TOKEN, TURSO_DATABASE_URL } from '$env/static/private';
4-import * as schema from './schema';
4+import schema, { ensureDefaultChatRoom } from './schema';
55
66 const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN });
77 const db = drizzle(client, { schema });
88
9+// Initialize database with required data
10+export const initializeDatabase = async () => {
11+ console.debug('Initializing database...');
12+ try {
13+ await ensureDefaultChatRoom(db);
14+ console.debug('Database initialization complete');
15+ } catch (error) {
16+ console.error('Error initializing database:', error);
17+ throw error;
18+ }
19+};
20+
21+// Call initialization when the module is loaded
22+initializeDatabase().catch(error => {
23+ console.error('Failed to initialize database:', error);
24+});
25+
926 export const testConnection = async () => {
10- const result = await db.query.users.findMany();
11- console.log(result);
27+ const result = await db.query.users.findMany();
28+ console.log(result);
1229 };
1330
1431 export default db;⏎̸
@@ -1,14 +1,31 @@
1 import { drizzle } from 'drizzle-orm/libsql';1 import { drizzle } from 'drizzle-orm/libsql';
2 import { createClient } from '@libsql/client';2 import { createClient } from '@libsql/client';
3 import { TURSO_AUTH_TOKEN, TURSO_DATABASE_URL } from '$env/static/private';3 import { TURSO_AUTH_TOKEN, TURSO_DATABASE_URL } from '$env/static/private';
4-import * as schema from './schema';4+import schema, { ensureDefaultChatRoom } from './schema';
5 5
6 const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN });6 const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN });
7 const db = drizzle(client, { schema });7 const db = drizzle(client, { schema });
8 8
9+// Initialize database with required data
10+export const initializeDatabase = async () => {
11+ console.debug('Initializing database...');
12+ try {
13+ await ensureDefaultChatRoom(db);
14+ console.debug('Database initialization complete');
15+ } catch (error) {
16+ console.error('Error initializing database:', error);
17+ throw error;
18+ }
19+};
20+
21+// Call initialization when the module is loaded
22+initializeDatabase().catch(error => {
23+ console.error('Failed to initialize database:', error);
24+});
25+
9 export const testConnection = async () => {26 export const testConnection = async () => {
10- const result = await db.query.users.findMany();27+ const result = await db.query.users.findMany();
11- console.log(result);28+ console.log(result);
12 };29 };
13 30
14 export default db;⏎̸31 export default db;⏎̸
M src/lib/db/schema.ts +42 -1
@@ -3,12 +3,12 @@ import * as t from "drizzle-orm/sqlite-core";
33 import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat";
44 import { v4 as uuidv4 } from "uuid";
55 import { eq } from "drizzle-orm";
6+import type { LibSQLDatabase } from "drizzle-orm/libsql";
67
78 export const users = table(
89 "users",
910 {
1011 id: t.text("id").primaryKey().$default(() => uuidv4()),
11- email: t.text("email").notNull().unique(),
1212 password: t.text("password").notNull(),
1313 nickname: t.text("nickname").notNull(),
1414 status: t.text("status").notNull().$type<UserStatus>().default("offline"),
@@ -94,3 +94,44 @@ export const messageView = t.sqliteView("message_view").as((qb) =>
9494 .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
9595 );
9696
97+// Create a schema object with all our tables and views
98+const schema = {
99+ users,
100+ sessions,
101+ chatRooms,
102+ messages,
103+ userView,
104+ chatRoomView,
105+ messageView
106+};
107+
108+// Function to ensure default chat room exists
109+export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) {
110+ console.debug('Ensuring default chat room exists...');
111+ try {
112+ // Check if default room exists
113+ const defaultRoom = await db.select()
114+ .from(chatRooms)
115+ .where(eq(chatRooms.id, 'default'))
116+ .get();
117+
118+ if (!defaultRoom) {
119+ console.debug('Creating default chat room...');
120+ await db.insert(chatRooms).values({
121+ id: 'default',
122+ name: 'General',
123+ type: 'group',
124+ createdAt: Date.now()
125+ });
126+ console.debug('Default chat room created successfully');
127+ } else {
128+ console.debug('Default chat room already exists');
129+ }
130+ } catch (error) {
131+ console.error('Error ensuring default chat room:', error);
132+ throw error;
133+ }
134+}
135+
136+export default schema;
137+
@@ -3,12 +3,12 @@ import * as t from "drizzle-orm/sqlite-core";
3 import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat";3 import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat";
4 import { v4 as uuidv4 } from "uuid";4 import { v4 as uuidv4 } from "uuid";
5 import { eq } from "drizzle-orm";5 import { eq } from "drizzle-orm";
6+import type { LibSQLDatabase } from "drizzle-orm/libsql";
6 7
7 export const users = table(8 export const users = table(
8 "users",9 "users",
9 {10 {
10 id: t.text("id").primaryKey().$default(() => uuidv4()),11 id: t.text("id").primaryKey().$default(() => uuidv4()),
11- email: t.text("email").notNull().unique(),
12 password: t.text("password").notNull(),12 password: t.text("password").notNull(),
13 nickname: t.text("nickname").notNull(),13 nickname: t.text("nickname").notNull(),
14 status: t.text("status").notNull().$type<UserStatus>().default("offline"),14 status: t.text("status").notNull().$type<UserStatus>().default("offline"),
@@ -94,3 +94,44 @@ export const messageView = t.sqliteView("message_view").as((qb) =>
94 .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))94 .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
95 );95 );
96 96
97+// Create a schema object with all our tables and views
98+const schema = {
99+ users,
100+ sessions,
101+ chatRooms,
102+ messages,
103+ userView,
104+ chatRoomView,
105+ messageView
106+};
107+
108+// Function to ensure default chat room exists
109+export async function ensureDefaultChatRoom(db: LibSQLDatabase<typeof schema>) {
110+ console.debug('Ensuring default chat room exists...');
111+ try {
112+ // Check if default room exists
113+ const defaultRoom = await db.select()
114+ .from(chatRooms)
115+ .where(eq(chatRooms.id, 'default'))
116+ .get();
117+
118+ if (!defaultRoom) {
119+ console.debug('Creating default chat room...');
120+ await db.insert(chatRooms).values({
121+ id: 'default',
122+ name: 'General',
123+ type: 'group',
124+ createdAt: Date.now()
125+ });
126+ console.debug('Default chat room created successfully');
127+ } else {
128+ console.debug('Default chat room already exists');
129+ }
130+ } catch (error) {
131+ console.error('Error ensuring default chat room:', error);
132+ throw error;
133+ }
134+}
135+
136+export default schema;
137+
M src/lib/states/chat.svelte.ts +323 -88
@@ -1,75 +1,40 @@
1-import type { User, ChatMessage } from '../types/chat';
2-
3-// Mock users
4-const mockUsers: User[] = [
5- {
6- id: '1',
7- nickname: 'CoolDude98',
8- status: 'online',
9- statusMessage: '🎮 Gaming all day!'
10- },
11- {
12- id: '2',
13- nickname: 'PixelPrincess',
14- status: 'away',
15- statusMessage: 'brb - food time'
16- },
17- {
18- id: '3',
19- nickname: 'WebSurfer2000',
20- status: 'online',
21- statusMessage: 'Surfing the information superhighway'
22- },
23- {
24- id: '4',
25- nickname: 'ChatBot3000',
26- status: 'busy',
27- statusMessage: '🤖 Computing...'
28- }
29-];
30-
31-// Mock initial messages
32-const initialMessages: ChatMessage[] = [
33- {
34- id: '1',
35- userId: '1',
36- content: 'sup everyone! who\'s up for some gaming?',
37- timestamp: new Date(Date.now() - 300000),
38- type: 'chat'
39- },
40- {
41- id: '2',
42- userId: '2',
43- content: 'is getting a snack',
44- timestamp: new Date(Date.now() - 240000),
45- type: 'emote'
46- },
47- {
48- id: '3',
49- userId: '3',
50- content: 'Just downloaded the new Internet Explorer 6! So fast! 🚀',
51- timestamp: new Date(Date.now() - 180000),
52- type: 'chat'
53- },
54- {
55- id: '4',
56- userId: '4',
57- content: '*beep boop* Greetings humans!',
58- timestamp: new Date(Date.now() - 120000),
59- type: 'chat'
60- }
61-];
62-
63-// Helper function to generate message ID
64-const generateId = () => Math.random().toString(36).substring(2, 15);
1+import type { User, Message, EnrichedMessage } from '../types/chat';
2+import type {
3+ SendMessageRequest,
4+ SendMessageResponse,
5+ GetMessagesResponse
6+} from '../types/payloads';
7+import { invalidate } from '$app/navigation';
8+
659
6610 class ChatState {
67- private users = $state(mockUsers);
68- private messages = $state(initialMessages);
69- private currentUser = $state(mockUsers[0]);
11+ private users = $state<User[]>([]);
12+ private messages = $state<Message[]>([]);
13+ private currentUser = $state<User | null>(null);
14+ private eventSource: EventSource | null = null;
15+ private reconnectAttempts = 0;
16+ private maxReconnectAttempts = 5;
17+ private reconnectDelay = 1000; // Start with 1 second
18+ private isInitializing = $state(false);
19+ private currentRoomId = $state<string>('default');
20+ private userCache = $state<Record<string, User>>({});
7021
71- constructor() {
72- // Initialize any necessary subscriptions or cleanup
22+ public async reinitialize() {
23+ console.debug('Reinitializing chat state...');
24+ if (this.eventSource) {
25+ this.eventSource.close();
26+ this.eventSource = null;
27+ }
28+ this.reconnectAttempts = 0;
29+ this.reconnectDelay = 1000;
30+ this.messages = [];
31+ if (this.currentUser) {
32+ await Promise.all([
33+ this.initializeMessages(),
34+ this.initializeRoomUsers()
35+ ]);
36+ this.setupSSE();
37+ }
7338 }
7439
7540 // User methods
@@ -77,42 +42,312 @@ class ChatState {
7742 return this.currentUser;
7843 }
7944
45+ async setCurrentUser(user: User | null) {
46+ console.debug('setCurrentUser called with', user);
47+ this.currentUser = user;
48+
49+ if (user) {
50+ await Promise.all([
51+ this.initializeMessages(),
52+ this.initializeRoomUsers()
53+ ]);
54+ this.setupSSE();
55+ } else {
56+ // If no global session, clear SSE and state
57+ if (this.eventSource) {
58+ this.eventSource.close();
59+ this.eventSource = null;
60+ }
61+ this.messages = [];
62+ this.users = [];
63+ }
64+
65+ // Invalidate both session and messages to trigger reloads
66+ await Promise.all([
67+ invalidate('chat:session'),
68+ invalidate('chat:messages')
69+ ]);
70+ }
71+
72+ private async initializeRoomUsers() {
73+ console.debug('Initializing room users for room:', this.currentRoomId);
74+ try {
75+ const response = await fetch(`/api/rooms/${this.currentRoomId}`, {
76+ credentials: 'include'
77+ });
78+
79+ if (!response.ok) {
80+ throw new Error('Failed to fetch room users');
81+ }
82+
83+ const data = await response.json();
84+ if (!data.success) {
85+ throw new Error(data.error);
86+ }
87+
88+ console.debug('Fetched room buddy list:', data.buddyList);
89+ this.users = data.buddyList;
90+ // Update cache with new buddy list users
91+ this.users.forEach(user => {
92+ this.userCache[user.id] = user;
93+ });
94+ } catch (error) {
95+ console.debug('Error fetching room users:', error);
96+ this.users = [];
97+ }
98+ }
99+
80100 getOnlineUsers() {
81- return this.users.filter(user => user.status !== 'offline');
101+ return this.users.filter((user: User) => user.status !== 'offline');
82102 }
83103
84104 getUserById(userId: string) {
85- return this.users.find(user => user.id === userId);
105+ // First check the cache
106+ if (this.userCache[userId]) {
107+ return this.userCache[userId];
108+ }
109+
110+ // If not in cache, look up in users array
111+ const user = this.users.find((user: User) => user.id === userId);
112+ if (user) {
113+ // Update cache
114+ this.userCache[userId] = user;
115+ console.debug('getUserById: cached user data', { userId, user });
116+ } else {
117+ console.debug('getUserById: user not found', { userId });
118+ }
119+ return user;
86120 }
87121
88- updateUserStatus(userId: string, status: User['status'], statusMessage?: string) {
89- this.users = this.users.map(user =>
90- user.id === userId
91- ? { ...user, status, ...(statusMessage && { statusMessage }) }
92- : user
93- );
122+ updateUserStatus(userId: string, status: User['status']) {
123+ this.users = this.users.map((user: User) => {
124+ if (user.id === userId) {
125+ const updatedUser = { ...user, status };
126+ // Update cache
127+ this.userCache[userId] = updatedUser;
128+ return updatedUser;
129+ }
130+ return user;
131+ });
94132 }
95133
96134 // Message methods
97135 getMessages() {
98- return this.messages;
136+ // Return messages with enriched user data
137+ return this.enrichMessages(this.messages);
138+ }
139+
140+ // Add method to update user cache for public access
141+ updateUserCache(users: User[]) {
142+ users.forEach(user => {
143+ this.userCache[user.id] = user;
144+ });
145+ // Also update the users array
146+ this.users = users;
147+ }
148+
149+ // Add method to update messages for public access
150+ updateMessages(messages: Message[]) {
151+ this.messages = messages;
152+ }
153+
154+ async initializeMessages() {
155+ try {
156+ const response = await fetch('/api/chat/messages', {
157+ credentials: 'include'
158+ });
159+ if (!response.ok) throw new Error('Failed to fetch messages');
160+
161+ const data = await response.json() as GetMessagesResponse;
162+ if (!data.success) {
163+ throw new Error(data.error);
164+ }
165+
166+ // Get unique user IDs from messages that aren't in the cache
167+ const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId));
168+ const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
169+
170+ // Fetch missing user data in parallel
171+ if (missingUserIds.length > 0) {
172+ console.debug('Fetching missing user data for IDs:', missingUserIds);
173+ await Promise.all(missingUserIds.map(async (userId) => {
174+ try {
175+ const userResponse = await fetch(`/api/users/${userId}`, {
176+ credentials: 'include'
177+ });
178+ if (userResponse.ok) {
179+ const userData = await userResponse.json();
180+ if (userData.success && userData.user) {
181+ this.userCache[userId] = userData.user;
182+ // Only add to users array if not already present
183+ if (!this.users.some(u => u.id === userId)) {
184+ this.users = [...this.users, userData.user];
185+ }
186+ }
187+ }
188+ } catch (error) {
189+ console.debug('Error fetching user data:', { userId, error });
190+ }
191+ }));
192+ }
193+
194+ this.messages = data.messages;
195+ } catch (error) {
196+ console.debug('Error fetching initial messages:', error);
197+ this.messages = [];
198+ }
99199 }
100200
101- sendMessage(content: string, type: ChatMessage['type'] = 'chat') {
102- if (!this.currentUser) return;
201+ async sendMessage(content: string, type: Message['type'] = 'chat') {
202+ const user = this.getCurrentUser();
203+ if (!user) {
204+ console.debug('Cannot send message: No current user');
205+ return;
206+ }
207+
208+ try {
209+ const payload: SendMessageRequest = {
210+ content,
211+ type,
212+ userId: user.id,
213+ chatRoomId: 'default'
214+ };
215+
216+ console.debug('Sending message with payload:', payload);
217+
218+ const response = await fetch('/api/chat/messages', {
219+ method: 'POST',
220+ headers: {
221+ 'Content-Type': 'application/json',
222+ },
223+ credentials: 'include',
224+ body: JSON.stringify(payload)
225+ });
103226
104- const newMessage: ChatMessage = {
105- id: generateId(),
106- userId: this.currentUser.id,
107- content,
108- timestamp: new Date(),
109- type
227+ if (!response.ok) {
228+ const errorData = await response.json();
229+ throw new Error(errorData.error || 'Failed to save message');
230+ }
231+
232+ const data = await response.json() as SendMessageResponse;
233+ if (!data.success) {
234+ throw new Error(data.error);
235+ }
236+
237+ console.debug('Message sent successfully:', data.message);
238+ await invalidate('chat:messages');
239+ } catch (error) {
240+ console.debug('Error sending message:', error);
241+ throw error;
242+ }
243+ }
244+
245+ private setupSSE() {
246+ console.debug('Setting up SSE connection for chat messages');
247+ this.connectSSE();
248+ }
249+
250+ private connectSSE() {
251+ if (this.eventSource) {
252+ this.eventSource.close();
253+ }
254+
255+ this.eventSource = new EventSource('/api/chat/messages', {
256+ withCredentials: true
257+ });
258+
259+ this.eventSource.onmessage = async (event) => {
260+ try {
261+ if (event.data === 'Connected') {
262+ console.debug('SSE connection established');
263+ this.reconnectAttempts = 0;
264+ this.reconnectDelay = 1000;
265+ return;
266+ }
267+
268+ const messageData = JSON.parse(event.data) as Message;
269+
270+ // Ensure we have the sender's data before adding the message
271+ if (!this.userCache[messageData.senderId]) {
272+ try {
273+ console.debug('Fetching user data for new message:', messageData.senderId);
274+ const response = await fetch(`/api/users/${messageData.senderId}`, {
275+ credentials: 'include'
276+ });
277+ if (response.ok) {
278+ const userData = await response.json();
279+ if (userData.success && userData.user) {
280+ this.userCache[userData.user.id] = userData.user;
281+ // Only add to users array if not already present
282+ if (!this.users.some(u => u.id === userData.user.id)) {
283+ this.users = [...this.users, userData.user];
284+ }
285+ }
286+ }
287+ } catch (error) {
288+ console.debug('Error fetching user data for new message:', error);
289+ }
290+ }
291+
292+ // Update messages state
293+ this.messages = [...this.messages, messageData];
294+ await invalidate('chat:messages');
295+ } catch (error) {
296+ console.debug('Error parsing SSE message data:', error);
297+ }
110298 };
111299
112- this.messages = [...this.messages, newMessage];
300+ this.eventSource.onerror = (event) => {
301+ console.debug('SSE encountered an error:', event);
302+ this.handleSSEError();
303+ };
113304 }
114305
115- // Add more methods as needed for your implementation
306+ private handleSSEError() {
307+ if (this.eventSource) {
308+ this.eventSource.close();
309+ this.eventSource = null;
310+ }
311+
312+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
313+ console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);
314+ setTimeout(() => {
315+ this.reconnectAttempts++;
316+ this.reconnectDelay *= 2; // Exponential backoff
317+ this.connectSSE();
318+ }, this.reconnectDelay);
319+ } else {
320+ console.debug('Max reconnection attempts reached');
321+ }
322+ }
323+
324+ private reconnectSSE() {
325+ this.reconnectAttempts = 0;
326+ this.reconnectDelay = 1000;
327+ this.connectSSE();
328+ }
329+
330+ // Add new method to enrich multiple messages at once
331+ enrichMessages(messages: Message[]): EnrichedMessage[] {
332+ return messages.map(msg => this.enrichMessageWithUser(msg));
333+ }
334+
335+ // Add new method to efficiently get user data for messages
336+ enrichMessageWithUser(message: Message): EnrichedMessage {
337+ const user = this.getUserById(message.senderId);
338+ return {
339+ ...message,
340+ user: user || {
341+ id: message.senderId,
342+ nickname: 'Unknown User',
343+ status: 'offline',
344+ password: '', // This is a system user, no password needed
345+ createdAt: Date.now(),
346+ lastSeen: null,
347+ avatarUrl: null
348+ }
349+ };
350+ }
116351 }
117352
118353 // Export a singleton instance
@@ -1,75 +1,40 @@
1-import type { User, ChatMessage } from '../types/chat';1+import type { User, Message, EnrichedMessage } from '../types/chat';
2-2+import type {
3-// Mock users3+ SendMessageRequest,
4-const mockUsers: User[] = [4+ SendMessageResponse,
5- {5+ GetMessagesResponse
6- id: '1',6+} from '../types/payloads';
7- nickname: 'CoolDude98',7+import { invalidate } from '$app/navigation';
8- status: 'online',8+
9- statusMessage: '🎮 Gaming all day!'
10- },
11- {
12- id: '2',
13- nickname: 'PixelPrincess',
14- status: 'away',
15- statusMessage: 'brb - food time'
16- },
17- {
18- id: '3',
19- nickname: 'WebSurfer2000',
20- status: 'online',
21- statusMessage: 'Surfing the information superhighway'
22- },
23- {
24- id: '4',
25- nickname: 'ChatBot3000',
26- status: 'busy',
27- statusMessage: '🤖 Computing...'
28- }
29-];
30-
31-// Mock initial messages
32-const initialMessages: ChatMessage[] = [
33- {
34- id: '1',
35- userId: '1',
36- content: 'sup everyone! who\'s up for some gaming?',
37- timestamp: new Date(Date.now() - 300000),
38- type: 'chat'
39- },
40- {
41- id: '2',
42- userId: '2',
43- content: 'is getting a snack',
44- timestamp: new Date(Date.now() - 240000),
45- type: 'emote'
46- },
47- {
48- id: '3',
49- userId: '3',
50- content: 'Just downloaded the new Internet Explorer 6! So fast! 🚀',
51- timestamp: new Date(Date.now() - 180000),
52- type: 'chat'
53- },
54- {
55- id: '4',
56- userId: '4',
57- content: '*beep boop* Greetings humans!',
58- timestamp: new Date(Date.now() - 120000),
59- type: 'chat'
60- }
61-];
62-
63-// Helper function to generate message ID
64-const generateId = () => Math.random().toString(36).substring(2, 15);
65 9
66 class ChatState {10 class ChatState {
67- private users = $state(mockUsers);11+ private users = $state<User[]>([]);
68- private messages = $state(initialMessages);12+ private messages = $state<Message[]>([]);
69- private currentUser = $state(mockUsers[0]);13+ private currentUser = $state<User | null>(null);
14+ private eventSource: EventSource | null = null;
15+ private reconnectAttempts = 0;
16+ private maxReconnectAttempts = 5;
17+ private reconnectDelay = 1000; // Start with 1 second
18+ private isInitializing = $state(false);
19+ private currentRoomId = $state<string>('default');
20+ private userCache = $state<Record<string, User>>({});
70 21
71- constructor() {22+ public async reinitialize() {
72- // Initialize any necessary subscriptions or cleanup23+ console.debug('Reinitializing chat state...');
24+ if (this.eventSource) {
25+ this.eventSource.close();
26+ this.eventSource = null;
27+ }
28+ this.reconnectAttempts = 0;
29+ this.reconnectDelay = 1000;
30+ this.messages = [];
31+ if (this.currentUser) {
32+ await Promise.all([
33+ this.initializeMessages(),
34+ this.initializeRoomUsers()
35+ ]);
36+ this.setupSSE();
37+ }
73 }38 }
74 39
75 // User methods40 // User methods
@@ -77,42 +42,312 @@ class ChatState {
77 return this.currentUser;42 return this.currentUser;
78 }43 }
79 44
45+ async setCurrentUser(user: User | null) {
46+ console.debug('setCurrentUser called with', user);
47+ this.currentUser = user;
48+
49+ if (user) {
50+ await Promise.all([
51+ this.initializeMessages(),
52+ this.initializeRoomUsers()
53+ ]);
54+ this.setupSSE();
55+ } else {
56+ // If no global session, clear SSE and state
57+ if (this.eventSource) {
58+ this.eventSource.close();
59+ this.eventSource = null;
60+ }
61+ this.messages = [];
62+ this.users = [];
63+ }
64+
65+ // Invalidate both session and messages to trigger reloads
66+ await Promise.all([
67+ invalidate('chat:session'),
68+ invalidate('chat:messages')
69+ ]);
70+ }
71+
72+ private async initializeRoomUsers() {
73+ console.debug('Initializing room users for room:', this.currentRoomId);
74+ try {
75+ const response = await fetch(`/api/rooms/${this.currentRoomId}`, {
76+ credentials: 'include'
77+ });
78+
79+ if (!response.ok) {
80+ throw new Error('Failed to fetch room users');
81+ }
82+
83+ const data = await response.json();
84+ if (!data.success) {
85+ throw new Error(data.error);
86+ }
87+
88+ console.debug('Fetched room buddy list:', data.buddyList);
89+ this.users = data.buddyList;
90+ // Update cache with new buddy list users
91+ this.users.forEach(user => {
92+ this.userCache[user.id] = user;
93+ });
94+ } catch (error) {
95+ console.debug('Error fetching room users:', error);
96+ this.users = [];
97+ }
98+ }
99+
80 getOnlineUsers() {100 getOnlineUsers() {
81- return this.users.filter(user => user.status !== 'offline');101+ return this.users.filter((user: User) => user.status !== 'offline');
82 }102 }
83 103
84 getUserById(userId: string) {104 getUserById(userId: string) {
85- return this.users.find(user => user.id === userId);105+ // First check the cache
106+ if (this.userCache[userId]) {
107+ return this.userCache[userId];
108+ }
109+
110+ // If not in cache, look up in users array
111+ const user = this.users.find((user: User) => user.id === userId);
112+ if (user) {
113+ // Update cache
114+ this.userCache[userId] = user;
115+ console.debug('getUserById: cached user data', { userId, user });
116+ } else {
117+ console.debug('getUserById: user not found', { userId });
118+ }
119+ return user;
86 }120 }
87 121
88- updateUserStatus(userId: string, status: User['status'], statusMessage?: string) {122+ updateUserStatus(userId: string, status: User['status']) {
89- this.users = this.users.map(user => 123+ this.users = this.users.map((user: User) => {
90- user.id === userId 124+ if (user.id === userId) {
91- ? { ...user, status, ...(statusMessage && { statusMessage }) }125+ const updatedUser = { ...user, status };
92- : user126+ // Update cache
93- );127+ this.userCache[userId] = updatedUser;
128+ return updatedUser;
129+ }
130+ return user;
131+ });
94 }132 }
95 133
96 // Message methods134 // Message methods
97 getMessages() {135 getMessages() {
98- return this.messages;136+ // Return messages with enriched user data
137+ return this.enrichMessages(this.messages);
138+ }
139+
140+ // Add method to update user cache for public access
141+ updateUserCache(users: User[]) {
142+ users.forEach(user => {
143+ this.userCache[user.id] = user;
144+ });
145+ // Also update the users array
146+ this.users = users;
147+ }
148+
149+ // Add method to update messages for public access
150+ updateMessages(messages: Message[]) {
151+ this.messages = messages;
152+ }
153+
154+ async initializeMessages() {
155+ try {
156+ const response = await fetch('/api/chat/messages', {
157+ credentials: 'include'
158+ });
159+ if (!response.ok) throw new Error('Failed to fetch messages');
160+
161+ const data = await response.json() as GetMessagesResponse;
162+ if (!data.success) {
163+ throw new Error(data.error);
164+ }
165+
166+ // Get unique user IDs from messages that aren't in the cache
167+ const uniqueUserIds = new Set(data.messages.map(msg => msg.senderId));
168+ const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]);
169+
170+ // Fetch missing user data in parallel
171+ if (missingUserIds.length > 0) {
172+ console.debug('Fetching missing user data for IDs:', missingUserIds);
173+ await Promise.all(missingUserIds.map(async (userId) => {
174+ try {
175+ const userResponse = await fetch(`/api/users/${userId}`, {
176+ credentials: 'include'
177+ });
178+ if (userResponse.ok) {
179+ const userData = await userResponse.json();
180+ if (userData.success && userData.user) {
181+ this.userCache[userId] = userData.user;
182+ // Only add to users array if not already present
183+ if (!this.users.some(u => u.id === userId)) {
184+ this.users = [...this.users, userData.user];
185+ }
186+ }
187+ }
188+ } catch (error) {
189+ console.debug('Error fetching user data:', { userId, error });
190+ }
191+ }));
192+ }
193+
194+ this.messages = data.messages;
195+ } catch (error) {
196+ console.debug('Error fetching initial messages:', error);
197+ this.messages = [];
198+ }
99 }199 }
100 200
101- sendMessage(content: string, type: ChatMessage['type'] = 'chat') {201+ async sendMessage(content: string, type: Message['type'] = 'chat') {
102- if (!this.currentUser) return;202+ const user = this.getCurrentUser();
203+ if (!user) {
204+ console.debug('Cannot send message: No current user');
205+ return;
206+ }
207+
208+ try {
209+ const payload: SendMessageRequest = {
210+ content,
211+ type,
212+ userId: user.id,
213+ chatRoomId: 'default'
214+ };
215+
216+ console.debug('Sending message with payload:', payload);
217+
218+ const response = await fetch('/api/chat/messages', {
219+ method: 'POST',
220+ headers: {
221+ 'Content-Type': 'application/json',
222+ },
223+ credentials: 'include',
224+ body: JSON.stringify(payload)
225+ });
103 226
104- const newMessage: ChatMessage = {227+ if (!response.ok) {
105- id: generateId(),228+ const errorData = await response.json();
106- userId: this.currentUser.id,229+ throw new Error(errorData.error || 'Failed to save message');
107- content,230+ }
108- timestamp: new Date(),231+
109- type232+ const data = await response.json() as SendMessageResponse;
233+ if (!data.success) {
234+ throw new Error(data.error);
235+ }
236+
237+ console.debug('Message sent successfully:', data.message);
238+ await invalidate('chat:messages');
239+ } catch (error) {
240+ console.debug('Error sending message:', error);
241+ throw error;
242+ }
243+ }
244+
245+ private setupSSE() {
246+ console.debug('Setting up SSE connection for chat messages');
247+ this.connectSSE();
248+ }
249+
250+ private connectSSE() {
251+ if (this.eventSource) {
252+ this.eventSource.close();
253+ }
254+
255+ this.eventSource = new EventSource('/api/chat/messages', {
256+ withCredentials: true
257+ });
258+
259+ this.eventSource.onmessage = async (event) => {
260+ try {
261+ if (event.data === 'Connected') {
262+ console.debug('SSE connection established');
263+ this.reconnectAttempts = 0;
264+ this.reconnectDelay = 1000;
265+ return;
266+ }
267+
268+ const messageData = JSON.parse(event.data) as Message;
269+
270+ // Ensure we have the sender's data before adding the message
271+ if (!this.userCache[messageData.senderId]) {
272+ try {
273+ console.debug('Fetching user data for new message:', messageData.senderId);
274+ const response = await fetch(`/api/users/${messageData.senderId}`, {
275+ credentials: 'include'
276+ });
277+ if (response.ok) {
278+ const userData = await response.json();
279+ if (userData.success && userData.user) {
280+ this.userCache[userData.user.id] = userData.user;
281+ // Only add to users array if not already present
282+ if (!this.users.some(u => u.id === userData.user.id)) {
283+ this.users = [...this.users, userData.user];
284+ }
285+ }
286+ }
287+ } catch (error) {
288+ console.debug('Error fetching user data for new message:', error);
289+ }
290+ }
291+
292+ // Update messages state
293+ this.messages = [...this.messages, messageData];
294+ await invalidate('chat:messages');
295+ } catch (error) {
296+ console.debug('Error parsing SSE message data:', error);
297+ }
110 };298 };
111 299
112- this.messages = [...this.messages, newMessage];300+ this.eventSource.onerror = (event) => {
301+ console.debug('SSE encountered an error:', event);
302+ this.handleSSEError();
303+ };
113 }304 }
114 305
115- // Add more methods as needed for your implementation306+ private handleSSEError() {
307+ if (this.eventSource) {
308+ this.eventSource.close();
309+ this.eventSource = null;
310+ }
311+
312+ if (this.reconnectAttempts < this.maxReconnectAttempts) {
313+ console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`);
314+ setTimeout(() => {
315+ this.reconnectAttempts++;
316+ this.reconnectDelay *= 2; // Exponential backoff
317+ this.connectSSE();
318+ }, this.reconnectDelay);
319+ } else {
320+ console.debug('Max reconnection attempts reached');
321+ }
322+ }
323+
324+ private reconnectSSE() {
325+ this.reconnectAttempts = 0;
326+ this.reconnectDelay = 1000;
327+ this.connectSSE();
328+ }
329+
330+ // Add new method to enrich multiple messages at once
331+ enrichMessages(messages: Message[]): EnrichedMessage[] {
332+ return messages.map(msg => this.enrichMessageWithUser(msg));
333+ }
334+
335+ // Add new method to efficiently get user data for messages
336+ enrichMessageWithUser(message: Message): EnrichedMessage {
337+ const user = this.getUserById(message.senderId);
338+ return {
339+ ...message,
340+ user: user || {
341+ id: message.senderId,
342+ nickname: 'Unknown User',
343+ status: 'offline',
344+ password: '', // This is a system user, no password needed
345+ createdAt: Date.now(),
346+ lastSeen: null,
347+ avatarUrl: null
348+ }
349+ };
350+ }
116 }351 }
117 352
118 // Export a singleton instance353 // Export a singleton instance
M src/lib/types/chat.ts +4 -1
@@ -6,7 +6,6 @@
66 // Users table: holds authentication and profile details
77 export interface User {
88 id: string;
9- email: string;
109 password: string; // hashed password
1110 nickname: string;
1211 status: UserStatus; // default is 'offline'
@@ -35,6 +34,10 @@ export type UserStatus = 'offline' | 'online' | 'away' | string;
3534 timestamp: number; // Timestamp when the message was sent
3635 }
3736
37+ export interface EnrichedMessage extends Message {
38+ user: User;
39+ }
40+
3841 export type MessageType = 'chat' | 'emote' | 'system' | string;
3942
4043 export interface Session {
@@ -6,7 +6,6 @@
6 // Users table: holds authentication and profile details6 // Users table: holds authentication and profile details
7 export interface User {7 export interface User {
8 id: string;8 id: string;
9- email: string;
10 password: string; // hashed password9 password: string; // hashed password
11 nickname: string;10 nickname: string;
12 status: UserStatus; // default is 'offline'11 status: UserStatus; // default is 'offline'
@@ -35,6 +34,10 @@ export type UserStatus = 'offline' | 'online' | 'away' | string;
35 timestamp: number; // Timestamp when the message was sent34 timestamp: number; // Timestamp when the message was sent
36 }35 }
37 36
37+ export interface EnrichedMessage extends Message {
38+ user: User;
39+ }
40+
38 export type MessageType = 'chat' | 'emote' | 'system' | string;41 export type MessageType = 'chat' | 'emote' | 'system' | string;
39 42
40 export interface Session {43 export interface Session {
A src/lib/types/payloads.ts +85 -0
@@ -0,0 +1,85 @@
1+import type { User, Message } from "./chat";
2+
3+// Login
4+export interface LoginResponseSuccess {
5+ success: true;
6+ user: Omit<User, "password">;
7+}
8+
9+export interface LoginResponseError {
10+ error: string;
11+}
12+
13+// Register
14+export interface RegisterRequest {
15+ suUsername: string;
16+ suPassword: string;
17+ suConfirmPassword: string;
18+ captchaAnswer: string;
19+}
20+
21+export interface RegisterResponseSuccess {
22+ success: true;
23+}
24+
25+export interface RegisterResponseError {
26+ error: string;
27+}
28+
29+export type RegisterResponse = RegisterResponseSuccess | RegisterResponseError;
30+
31+// Session
32+export interface SessionResponseSuccess {
33+ success: true;
34+ user: Omit<User, "password">;
35+}
36+
37+export interface SessionResponseError {
38+ error: string;
39+}
40+
41+// Messages
42+export interface SendMessageRequest {
43+ content: string;
44+ type?: Message['type'];
45+ userId: string;
46+ chatRoomId?: string;
47+}
48+
49+export interface SendMessageResponseSuccess {
50+ success: true;
51+ message: Message;
52+}
53+
54+export interface SendMessageResponseError {
55+ success: false;
56+ error: string;
57+}
58+
59+export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError;
60+
61+export interface GetMessagesResponseSuccess {
62+ success: true;
63+ messages: Message[];
64+}
65+
66+export interface GetMessagesResponseError {
67+ success: false;
68+ error: string;
69+}
70+
71+export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesResponseError;
72+
73+// ----- Public Room Payloads -----
74+
75+export interface PublicRoomResponseSuccess {
76+ success: true;
77+ buddyList: Omit<User, "password">[];
78+}
79+
80+export interface PublicRoomResponseError {
81+ success: false;
82+ error: string;
83+}
84+
85+export type PublicRoomResponse = PublicRoomResponseSuccess | PublicRoomResponseError; ⏎̸
@@ -0,0 +1,85 @@
1+import type { User, Message } from "./chat";
2+
3+// Login
4+export interface LoginResponseSuccess {
5+ success: true;
6+ user: Omit<User, "password">;
7+}
8+
9+export interface LoginResponseError {
10+ error: string;
11+}
12+
13+// Register
14+export interface RegisterRequest {
15+ suUsername: string;
16+ suPassword: string;
17+ suConfirmPassword: string;
18+ captchaAnswer: string;
19+}
20+
21+export interface RegisterResponseSuccess {
22+ success: true;
23+}
24+
25+export interface RegisterResponseError {
26+ error: string;
27+}
28+
29+export type RegisterResponse = RegisterResponseSuccess | RegisterResponseError;
30+
31+// Session
32+export interface SessionResponseSuccess {
33+ success: true;
34+ user: Omit<User, "password">;
35+}
36+
37+export interface SessionResponseError {
38+ error: string;
39+}
40+
41+// Messages
42+export interface SendMessageRequest {
43+ content: string;
44+ type?: Message['type'];
45+ userId: string;
46+ chatRoomId?: string;
47+}
48+
49+export interface SendMessageResponseSuccess {
50+ success: true;
51+ message: Message;
52+}
53+
54+export interface SendMessageResponseError {
55+ success: false;
56+ error: string;
57+}
58+
59+export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError;
60+
61+export interface GetMessagesResponseSuccess {
62+ success: true;
63+ messages: Message[];
64+}
65+
66+export interface GetMessagesResponseError {
67+ success: false;
68+ error: string;
69+}
70+
71+export type GetMessagesResponse = GetMessagesResponseSuccess | GetMessagesResponseError;
72+
73+// ----- Public Room Payloads -----
74+
75+export interface PublicRoomResponseSuccess {
76+ success: true;
77+ buddyList: Omit<User, "password">[];
78+}
79+
80+export interface PublicRoomResponseError {
81+ success: false;
82+ error: string;
83+}
84+
85+export type PublicRoomResponse = PublicRoomResponseSuccess | PublicRoomResponseError; ⏎̸
A src/lib/utils/password.ts +32 -0
@@ -0,0 +1,32 @@
1+import crypto from 'crypto';
2+
3+/**
4+ * Securely hashes a password using PBKDF2 with a random salt.
5+ * @param password The plain text password to hash
6+ * @returns A promise that resolves to the hashed password in the format 'salt:hash'
7+ */
8+export async function hashPassword(password: string): Promise<string> {
9+ return new Promise((resolve, reject) => {
10+ const salt = crypto.randomBytes(16).toString('hex');
11+ crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, derivedKey) => {
12+ if (err) return reject(err);
13+ resolve(`${salt}:${derivedKey.toString('hex')}`);
14+ });
15+ });
16+}
17+
18+/**
19+ * Verify a password hash created by hashPassword()
20+ * @param password The plain text password to verify
21+ * @param hash The hashed password to verify against
22+ * @returns A promise that resolves to true if the password matches, false otherwise
23+ */
24+export async function verifyPassword(password: string, hash: string): Promise<boolean> {
25+ return new Promise((resolve, reject) => {
26+ const [salt, key] = hash.split(':');
27+ crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, derivedKey) => {
28+ if (err) return reject(err);
29+ resolve(key === derivedKey.toString('hex'));
30+ });
31+ });
32+} ⏎̸
@@ -0,0 +1,32 @@
1+import crypto from 'crypto';
2+
3+/**
4+ * Securely hashes a password using PBKDF2 with a random salt.
5+ * @param password The plain text password to hash
6+ * @returns A promise that resolves to the hashed password in the format 'salt:hash'
7+ */
8+export async function hashPassword(password: string): Promise<string> {
9+ return new Promise((resolve, reject) => {
10+ const salt = crypto.randomBytes(16).toString('hex');
11+ crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, derivedKey) => {
12+ if (err) return reject(err);
13+ resolve(`${salt}:${derivedKey.toString('hex')}`);
14+ });
15+ });
16+}
17+
18+/**
19+ * Verify a password hash created by hashPassword()
20+ * @param password The plain text password to verify
21+ * @param hash The hashed password to verify against
22+ * @returns A promise that resolves to true if the password matches, false otherwise
23+ */
24+export async function verifyPassword(password: string, hash: string): Promise<boolean> {
25+ return new Promise((resolve, reject) => {
26+ const [salt, key] = hash.split(':');
27+ crypto.pbkdf2(password, salt, 100000, 64, 'sha512', (err, derivedKey) => {
28+ if (err) return reject(err);
29+ resolve(key === derivedKey.toString('hex'));
30+ });
31+ });
32+} ⏎̸
A src/routes/+layout.server.ts +10 -0
@@ -0,0 +1,10 @@
1+import type { LayoutServerLoad } from './$types';
2+
3+export const load: LayoutServerLoad = async ({ locals }) => {
4+
5+
6+ return {
7+ user: locals.user,
8+ session: locals.session
9+ };
10+}; ⏎̸
@@ -0,0 +1,10 @@
1+import type { LayoutServerLoad } from './$types';
2+
3+export const load: LayoutServerLoad = async ({ locals }) => {
4+
5+
6+ return {
7+ user: locals.user,
8+ session: locals.session
9+ };
10+}; ⏎̸
M src/routes/+layout.svelte +25 -3
@@ -2,10 +2,32 @@
22 import '../app.css';
33 import DesktopIcons from '$lib/components/desktop-icons.svelte';
44 import ChatRoom from '$lib/components/chat-room.svelte';
5- let { children } = $props();
5+ import { chatState } from '$lib/states/chat.svelte';
66
7- let showChatRoom = $state(false);
8- let showAuth = $state(false);
7+ let { children, data } = $props();
8+
9+ $effect(() => {
10+ console.debug('Layout reactive update - Session state:', {
11+ timestamp: new Date().toISOString(),
12+ hasUser: !!data.user,
13+ userDetails: data.user ? {
14+ nickname: data.user.nickname,
15+ status: data.user.status,
16+ id: data.user.id
17+ } : null,
18+ hasSession: !!data.session,
19+ sessionDetails: data.session ? {
20+ id: data.session.id,
21+ expiresAt: new Date(data.session.expiresAt)
22+ } : null,
23+ cookiePresent: document.cookie.includes('session=')
24+ });
25+ });
26+
27+ $effect(() => {
28+ console.debug('Updating global chatState with data.user:', data.user);
29+ chatState.setCurrentUser(data.user);
30+ });
931 </script>
1032
1133 <div class="windows-xp-bg">
@@ -2,10 +2,32 @@
2 import '../app.css';2 import '../app.css';
3 import DesktopIcons from '$lib/components/desktop-icons.svelte';3 import DesktopIcons from '$lib/components/desktop-icons.svelte';
4 import ChatRoom from '$lib/components/chat-room.svelte';4 import ChatRoom from '$lib/components/chat-room.svelte';
5- let { children } = $props();5+ import { chatState } from '$lib/states/chat.svelte';
6 6
7- let showChatRoom = $state(false);7+ let { children, data } = $props();
8- let showAuth = $state(false);8+
9+ $effect(() => {
10+ console.debug('Layout reactive update - Session state:', {
11+ timestamp: new Date().toISOString(),
12+ hasUser: !!data.user,
13+ userDetails: data.user ? {
14+ nickname: data.user.nickname,
15+ status: data.user.status,
16+ id: data.user.id
17+ } : null,
18+ hasSession: !!data.session,
19+ sessionDetails: data.session ? {
20+ id: data.session.id,
21+ expiresAt: new Date(data.session.expiresAt)
22+ } : null,
23+ cookiePresent: document.cookie.includes('session=')
24+ });
25+ });
26+
27+ $effect(() => {
28+ console.debug('Updating global chatState with data.user:', data.user);
29+ chatState.setCurrentUser(data.user);
30+ });
9 </script>31 </script>
10 32
11 <div class="windows-xp-bg">33 <div class="windows-xp-bg">
A src/routes/+layout.ts +27 -0
@@ -0,0 +1,27 @@
1+import type { LayoutLoad } from './$types';
2+
3+export const load: LayoutLoad = async ({ data, depends }) => {
4+ // Register client-side dependencies for invalidation
5+ depends('app:session');
6+ depends('app:chat');
7+ // Debug log for session data with more details
8+ console.debug('Layout load - Client session data:', {
9+ hasUser: !!data.user,
10+ userDetails: data.user ? {
11+ nickname: data.user.nickname,
12+ status: data.user.status,
13+ id: data.user.id
14+ } : null,
15+ hasSession: !!data.session,
16+ sessionDetails: data.session ? {
17+ id: data.session.id,
18+ expiresAt: new Date(data.session.expiresAt)
19+ } : null
20+ });
21+
22+ // Return the data from the server load function
23+ return {
24+ user: data.user,
25+ session: data.session
26+ };
27+}; ⏎̸
@@ -0,0 +1,27 @@
1+import type { LayoutLoad } from './$types';
2+
3+export const load: LayoutLoad = async ({ data, depends }) => {
4+ // Register client-side dependencies for invalidation
5+ depends('app:session');
6+ depends('app:chat');
7+ // Debug log for session data with more details
8+ console.debug('Layout load - Client session data:', {
9+ hasUser: !!data.user,
10+ userDetails: data.user ? {
11+ nickname: data.user.nickname,
12+ status: data.user.status,
13+ id: data.user.id
14+ } : null,
15+ hasSession: !!data.session,
16+ sessionDetails: data.session ? {
17+ id: data.session.id,
18+ expiresAt: new Date(data.session.expiresAt)
19+ } : null
20+ });
21+
22+ // Return the data from the server load function
23+ return {
24+ user: data.user,
25+ session: data.session
26+ };
27+}; ⏎̸
A src/routes/api/chat/messages/+server.ts +187 -0
@@ -0,0 +1,187 @@
1+import { v4 as uuidv4 } from 'uuid';
2+import db from '$lib/db/db.server';
3+import { messages } from '$lib/db/schema';
4+import type { Message } from '$lib/types/chat';
5+import type {
6+ SendMessageRequest,
7+ SendMessageResponse,
8+ GetMessagesResponse
9+} from '$lib/types/payloads';
10+import { desc } from 'drizzle-orm';
11+import { error } from '@sveltejs/kit';
12+
13+// Global set to store SSE controllers
14+const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
15+
16+// GET endpoint: handle both SSE connections and initial message fetches
17+export async function GET({ request, locals }) {
18+ const acceptHeader = request.headers.get('Accept');
19+ const isPublic = new URL(request.url).searchParams.get('public') === 'true';
20+
21+ // If Accept header is event-stream, handle as SSE connection
22+ if (acceptHeader?.includes('text/event-stream')) {
23+ // SSE connections require authentication
24+ if (!locals.user) {
25+ throw error(401, 'Authentication required for real-time updates');
26+ }
27+
28+ console.debug('Establishing new SSE connection for chat messages');
29+ let thisController: ReadableStreamDefaultController<Uint8Array>;
30+ let heartbeatInterval: NodeJS.Timeout;
31+
32+ const stream = new ReadableStream({
33+ start(controller) {
34+ console.debug('SSE controller started');
35+ thisController = controller;
36+ sseClients.add(controller);
37+ const encoder = new TextEncoder();
38+ // Send initial connection message
39+ controller.enqueue(encoder.encode(`data: Connected\n\n`));
40+
41+ // Heartbeat to keep the connection alive every 15 seconds
42+ heartbeatInterval = setInterval(() => {
43+ controller.enqueue(encoder.encode(`: heartbeat\n\n`));
44+ }, 15000);
45+ },
46+ cancel() {
47+ console.debug('SSE connection cancelled');
48+ sseClients.delete(thisController);
49+ clearInterval(heartbeatInterval);
50+ }
51+ });
52+
53+ return new Response(stream, {
54+ headers: {
55+ 'Content-Type': 'text/event-stream',
56+ 'Cache-Control': 'no-cache',
57+ 'Connection': 'keep-alive'
58+ }
59+ });
60+ }
61+
62+ // For regular GET requests, check authentication unless it's a public request
63+ if (!isPublic && !locals.user) {
64+ throw error(401, 'Authentication required');
65+ }
66+
67+ try {
68+ console.debug('Fetching messages', { isPublic });
69+ const query = db.select()
70+ .from(messages)
71+ .orderBy(desc(messages.timestamp));
72+
73+ // For public requests, limit to 10 most recent messages
74+ if (isPublic) {
75+ query.limit(10);
76+ } else {
77+ query.limit(50);
78+ }
79+
80+ const fetchedMessages = await query;
81+
82+ // Return messages in chronological order with success response
83+ const response: GetMessagesResponse = {
84+ success: true,
85+ messages: fetchedMessages.reverse()
86+ };
87+
88+ return new Response(
89+ JSON.stringify(response),
90+ {
91+ status: 200,
92+ headers: { 'Content-Type': 'application/json' }
93+ }
94+ );
95+ } catch (error) {
96+ console.debug('Error fetching messages:', error);
97+ const errorResponse: GetMessagesResponse = {
98+ success: false,
99+ error: 'Failed to fetch messages'
100+ };
101+ return new Response(
102+ JSON.stringify(errorResponse),
103+ {
104+ status: 500,
105+ headers: { 'Content-Type': 'application/json' }
106+ }
107+ );
108+ }
109+}
110+
111+// POST endpoint: receive a new message, save it in the DB, and broadcast via SSE
112+export async function POST({ request, locals }: { request: Request, locals: App.Locals }) {
113+ // Require authentication for posting messages
114+ if (!locals.user) {
115+ throw error(401, 'Authentication required');
116+ }
117+
118+ console.debug('Received new message POST');
119+ try {
120+ const data = await request.json() as SendMessageRequest;
121+ // Validate input payload
122+ if (!data.content || !data.userId) {
123+ console.debug('Invalid payload', data);
124+ const errorResponse: SendMessageResponse = {
125+ success: false,
126+ error: 'Invalid message payload'
127+ };
128+ return new Response(JSON.stringify(errorResponse), { status: 400 });
129+ }
130+
131+ // Verify the user is posting as themselves
132+ if (data.userId !== locals.user.id) {
133+ throw error(403, 'Cannot post messages as another user');
134+ }
135+
136+ // Create a new message object
137+ const newMessage: Message = {
138+ id: uuidv4(),
139+ chatRoomId: data.chatRoomId || 'default',
140+ senderId: data.userId,
141+ content: data.content,
142+ type: data.type || 'chat',
143+ timestamp: Date.now()
144+ };
145+
146+ // Save the message in the database
147+ await db.insert(messages).values(newMessage);
148+ console.debug('Message saved in DB', newMessage);
149+
150+ // Broadcast the new message to all connected SSE clients
151+ const encoder = new TextEncoder();
152+ const sseData = `data: ${JSON.stringify(newMessage)}\n\n`;
153+ sseClients.forEach(controller => {
154+ try {
155+ controller.enqueue(encoder.encode(sseData));
156+ } catch (error) {
157+ console.debug('Error broadcasting to a client:', error);
158+ }
159+ });
160+
161+ const successResponse: SendMessageResponse = {
162+ success: true,
163+ message: newMessage
164+ };
165+
166+ return new Response(
167+ JSON.stringify(successResponse),
168+ {
169+ status: 201,
170+ headers: { 'Content-Type': 'application/json' }
171+ }
172+ );
173+ } catch (error) {
174+ console.debug('Error processing POST:', error);
175+ const errorResponse: SendMessageResponse = {
176+ success: false,
177+ error: 'Failed to save message'
178+ };
179+ return new Response(
180+ JSON.stringify(errorResponse),
181+ {
182+ status: 500,
183+ headers: { 'Content-Type': 'application/json' }
184+ }
185+ );
186+ }
187+} ⏎̸
@@ -0,0 +1,187 @@
1+import { v4 as uuidv4 } from 'uuid';
2+import db from '$lib/db/db.server';
3+import { messages } from '$lib/db/schema';
4+import type { Message } from '$lib/types/chat';
5+import type {
6+ SendMessageRequest,
7+ SendMessageResponse,
8+ GetMessagesResponse
9+} from '$lib/types/payloads';
10+import { desc } from 'drizzle-orm';
11+import { error } from '@sveltejs/kit';
12+
13+// Global set to store SSE controllers
14+const sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
15+
16+// GET endpoint: handle both SSE connections and initial message fetches
17+export async function GET({ request, locals }) {
18+ const acceptHeader = request.headers.get('Accept');
19+ const isPublic = new URL(request.url).searchParams.get('public') === 'true';
20+
21+ // If Accept header is event-stream, handle as SSE connection
22+ if (acceptHeader?.includes('text/event-stream')) {
23+ // SSE connections require authentication
24+ if (!locals.user) {
25+ throw error(401, 'Authentication required for real-time updates');
26+ }
27+
28+ console.debug('Establishing new SSE connection for chat messages');
29+ let thisController: ReadableStreamDefaultController<Uint8Array>;
30+ let heartbeatInterval: NodeJS.Timeout;
31+
32+ const stream = new ReadableStream({
33+ start(controller) {
34+ console.debug('SSE controller started');
35+ thisController = controller;
36+ sseClients.add(controller);
37+ const encoder = new TextEncoder();
38+ // Send initial connection message
39+ controller.enqueue(encoder.encode(`data: Connected\n\n`));
40+
41+ // Heartbeat to keep the connection alive every 15 seconds
42+ heartbeatInterval = setInterval(() => {
43+ controller.enqueue(encoder.encode(`: heartbeat\n\n`));
44+ }, 15000);
45+ },
46+ cancel() {
47+ console.debug('SSE connection cancelled');
48+ sseClients.delete(thisController);
49+ clearInterval(heartbeatInterval);
50+ }
51+ });
52+
53+ return new Response(stream, {
54+ headers: {
55+ 'Content-Type': 'text/event-stream',
56+ 'Cache-Control': 'no-cache',
57+ 'Connection': 'keep-alive'
58+ }
59+ });
60+ }
61+
62+ // For regular GET requests, check authentication unless it's a public request
63+ if (!isPublic && !locals.user) {
64+ throw error(401, 'Authentication required');
65+ }
66+
67+ try {
68+ console.debug('Fetching messages', { isPublic });
69+ const query = db.select()
70+ .from(messages)
71+ .orderBy(desc(messages.timestamp));
72+
73+ // For public requests, limit to 10 most recent messages
74+ if (isPublic) {
75+ query.limit(10);
76+ } else {
77+ query.limit(50);
78+ }
79+
80+ const fetchedMessages = await query;
81+
82+ // Return messages in chronological order with success response
83+ const response: GetMessagesResponse = {
84+ success: true,
85+ messages: fetchedMessages.reverse()
86+ };
87+
88+ return new Response(
89+ JSON.stringify(response),
90+ {
91+ status: 200,
92+ headers: { 'Content-Type': 'application/json' }
93+ }
94+ );
95+ } catch (error) {
96+ console.debug('Error fetching messages:', error);
97+ const errorResponse: GetMessagesResponse = {
98+ success: false,
99+ error: 'Failed to fetch messages'
100+ };
101+ return new Response(
102+ JSON.stringify(errorResponse),
103+ {
104+ status: 500,
105+ headers: { 'Content-Type': 'application/json' }
106+ }
107+ );
108+ }
109+}
110+
111+// POST endpoint: receive a new message, save it in the DB, and broadcast via SSE
112+export async function POST({ request, locals }: { request: Request, locals: App.Locals }) {
113+ // Require authentication for posting messages
114+ if (!locals.user) {
115+ throw error(401, 'Authentication required');
116+ }
117+
118+ console.debug('Received new message POST');
119+ try {
120+ const data = await request.json() as SendMessageRequest;
121+ // Validate input payload
122+ if (!data.content || !data.userId) {
123+ console.debug('Invalid payload', data);
124+ const errorResponse: SendMessageResponse = {
125+ success: false,
126+ error: 'Invalid message payload'
127+ };
128+ return new Response(JSON.stringify(errorResponse), { status: 400 });
129+ }
130+
131+ // Verify the user is posting as themselves
132+ if (data.userId !== locals.user.id) {
133+ throw error(403, 'Cannot post messages as another user');
134+ }
135+
136+ // Create a new message object
137+ const newMessage: Message = {
138+ id: uuidv4(),
139+ chatRoomId: data.chatRoomId || 'default',
140+ senderId: data.userId,
141+ content: data.content,
142+ type: data.type || 'chat',
143+ timestamp: Date.now()
144+ };
145+
146+ // Save the message in the database
147+ await db.insert(messages).values(newMessage);
148+ console.debug('Message saved in DB', newMessage);
149+
150+ // Broadcast the new message to all connected SSE clients
151+ const encoder = new TextEncoder();
152+ const sseData = `data: ${JSON.stringify(newMessage)}\n\n`;
153+ sseClients.forEach(controller => {
154+ try {
155+ controller.enqueue(encoder.encode(sseData));
156+ } catch (error) {
157+ console.debug('Error broadcasting to a client:', error);
158+ }
159+ });
160+
161+ const successResponse: SendMessageResponse = {
162+ success: true,
163+ message: newMessage
164+ };
165+
166+ return new Response(
167+ JSON.stringify(successResponse),
168+ {
169+ status: 201,
170+ headers: { 'Content-Type': 'application/json' }
171+ }
172+ );
173+ } catch (error) {
174+ console.debug('Error processing POST:', error);
175+ const errorResponse: SendMessageResponse = {
176+ success: false,
177+ error: 'Failed to save message'
178+ };
179+ return new Response(
180+ JSON.stringify(errorResponse),
181+ {
182+ status: 500,
183+ headers: { 'Content-Type': 'application/json' }
184+ }
185+ );
186+ }
187+} ⏎̸
A src/routes/api/register/+server.ts +113 -0
@@ -0,0 +1,113 @@
1+import type { RequestHandler } from '@sveltejs/kit';
2+import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/payloads';
3+import db from '$lib/db/db.server';
4+import { users } from '$lib/db/schema';
5+import { hashPassword } from '$lib/utils/password';
6+
7+// In-memory map to track failed captcha attempts per IP
8+const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>();
9+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
10+
11+export const POST: RequestHandler = async ({ request }) => {
12+ console.debug("POST /api/register called");
13+
14+ // Only allow POST
15+ if (request.method !== 'POST') {
16+ return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 });
17+ }
18+
19+ let body: unknown;
20+ try {
21+ body = await request.json();
22+ } catch (err) {
23+ console.debug("Invalid JSON", err);
24+ return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 });
25+ }
26+
27+ const { suUsername, suPassword, suConfirmPassword, captchaAnswer } = body as { suUsername: string, suPassword: string, suConfirmPassword: string, captchaAnswer: string };
28+
29+ // Destructure and validate required fields from the payload.
30+ if (typeof suUsername !== 'string' || typeof suPassword !== 'string' ||
31+ typeof suConfirmPassword !== 'string' || typeof captchaAnswer !== 'string') {
32+ console.debug("Missing or invalid input fields", { suUsername, suPassword, suConfirmPassword, captchaAnswer });
33+ return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 });
34+ }
35+
36+ // Trim input values.
37+ const username = suUsername.trim();
38+ const password = suPassword.trim();
39+ const confirmPassword = suConfirmPassword.trim();
40+ const captcha = captchaAnswer.trim().toLowerCase();
41+
42+ // Basic validations.
43+ if (!username || !password || !confirmPassword || !captcha) {
44+ return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 });
45+ }
46+
47+ if (username.length > 32) {
48+ return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 });
49+ }
50+ // Only allow letters, numbers, underscores, and dashes in the username.
51+ const usernameRegex = /^[a-zA-Z0-9_-]+$/;
52+ if (!usernameRegex.test(username)) {
53+ return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 });
54+ }
55+
56+ if (password !== confirmPassword) {
57+ return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 });
58+ }
59+
60+ if (password.length < 8 || password.length > 64) {
61+ return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 });
62+ }
63+
64+ // Captcha validation logic.
65+ // Expected captcha answer.
66+ const expectedCaptcha = "point de rencontre";
67+ // Get the IP address for rate limiting. In production, a more reliable method should be used.
68+ const ip = request.headers.get('x-forwarded-for') || 'unknown';
69+ const now = Date.now();
70+ const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
71+ // If the user has failed three or more times, apply aggressive exponential backoff.
72+ if (attemptData.count >= 3) {
73+ const delay = Math.pow(2, attemptData.count - 3 + 1) * 1000; // delay in milliseconds
74+ console.debug(`Applying exponential backoff delay of ${delay}ms for IP ${ip}`);
75+ await sleep(delay);
76+ }
77+ if (captcha !== expectedCaptcha) {
78+ attemptData.count++;
79+ attemptData.lastAttempt = now;
80+ captchaAttempts.set(ip, attemptData);
81+ console.debug(`Captcha failed for IP ${ip}. Attempt count: ${attemptData.count}`);
82+ const remaining = Math.max(0, 3 - attemptData.count);
83+ return new Response(JSON.stringify({ error: `Incorrect captcha answer. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` } as RegisterResponseError), { status: 400 });
84+ }
85+ // Reset captcha attempt data on success.
86+ if (captcha === expectedCaptcha) {
87+ captchaAttempts.delete(ip);
88+ }
89+
90+ // Securely hash the user's password.
91+ let hashedPassword: string;
92+ try {
93+ hashedPassword = await hashPassword(password);
94+ } catch (err) {
95+ console.debug("Error hashing password", err);
96+ return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 });
97+ }
98+ // Insert the new user into the database.
99+ try {
100+ await db.insert(users).values({
101+ password: hashedPassword,
102+ nickname: username,
103+ createdAt: Date.now()
104+ });
105+ console.debug(`User ${username} registered successfully`);
106+ } catch (err) {
107+ console.debug("Database insertion error", err);
108+ // Assume a duplicate user error if the email (or derived unique field) already exists.
109+ return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 });
110+ }
111+
112+ return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), { status: 201 });
113+};
@@ -0,0 +1,113 @@
1+import type { RequestHandler } from '@sveltejs/kit';
2+import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/payloads';
3+import db from '$lib/db/db.server';
4+import { users } from '$lib/db/schema';
5+import { hashPassword } from '$lib/utils/password';
6+
7+// In-memory map to track failed captcha attempts per IP
8+const captchaAttempts = new Map<string, { count: number, lastAttempt: number }>();
9+const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
10+
11+export const POST: RequestHandler = async ({ request }) => {
12+ console.debug("POST /api/register called");
13+
14+ // Only allow POST
15+ if (request.method !== 'POST') {
16+ return new Response(JSON.stringify({ error: 'Method Not Allowed' } as RegisterResponseError), { status: 405 });
17+ }
18+
19+ let body: unknown;
20+ try {
21+ body = await request.json();
22+ } catch (err) {
23+ console.debug("Invalid JSON", err);
24+ return new Response(JSON.stringify({ error: 'Invalid JSON' } as RegisterResponseError), { status: 400 });
25+ }
26+
27+ const { suUsername, suPassword, suConfirmPassword, captchaAnswer } = body as { suUsername: string, suPassword: string, suConfirmPassword: string, captchaAnswer: string };
28+
29+ // Destructure and validate required fields from the payload.
30+ if (typeof suUsername !== 'string' || typeof suPassword !== 'string' ||
31+ typeof suConfirmPassword !== 'string' || typeof captchaAnswer !== 'string') {
32+ console.debug("Missing or invalid input fields", { suUsername, suPassword, suConfirmPassword, captchaAnswer });
33+ return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 });
34+ }
35+
36+ // Trim input values.
37+ const username = suUsername.trim();
38+ const password = suPassword.trim();
39+ const confirmPassword = suConfirmPassword.trim();
40+ const captcha = captchaAnswer.trim().toLowerCase();
41+
42+ // Basic validations.
43+ if (!username || !password || !confirmPassword || !captcha) {
44+ return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 });
45+ }
46+
47+ if (username.length > 32) {
48+ return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 });
49+ }
50+ // Only allow letters, numbers, underscores, and dashes in the username.
51+ const usernameRegex = /^[a-zA-Z0-9_-]+$/;
52+ if (!usernameRegex.test(username)) {
53+ return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 });
54+ }
55+
56+ if (password !== confirmPassword) {
57+ return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 });
58+ }
59+
60+ if (password.length < 8 || password.length > 64) {
61+ return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 });
62+ }
63+
64+ // Captcha validation logic.
65+ // Expected captcha answer.
66+ const expectedCaptcha = "point de rencontre";
67+ // Get the IP address for rate limiting. In production, a more reliable method should be used.
68+ const ip = request.headers.get('x-forwarded-for') || 'unknown';
69+ const now = Date.now();
70+ const attemptData = captchaAttempts.get(ip) || { count: 0, lastAttempt: 0 };
71+ // If the user has failed three or more times, apply aggressive exponential backoff.
72+ if (attemptData.count >= 3) {
73+ const delay = Math.pow(2, attemptData.count - 3 + 1) * 1000; // delay in milliseconds
74+ console.debug(`Applying exponential backoff delay of ${delay}ms for IP ${ip}`);
75+ await sleep(delay);
76+ }
77+ if (captcha !== expectedCaptcha) {
78+ attemptData.count++;
79+ attemptData.lastAttempt = now;
80+ captchaAttempts.set(ip, attemptData);
81+ console.debug(`Captcha failed for IP ${ip}. Attempt count: ${attemptData.count}`);
82+ const remaining = Math.max(0, 3 - attemptData.count);
83+ return new Response(JSON.stringify({ error: `Incorrect captcha answer. ${remaining > 0 ? remaining + " attempt(s) remaining." : "Please wait before trying again."}` } as RegisterResponseError), { status: 400 });
84+ }
85+ // Reset captcha attempt data on success.
86+ if (captcha === expectedCaptcha) {
87+ captchaAttempts.delete(ip);
88+ }
89+
90+ // Securely hash the user's password.
91+ let hashedPassword: string;
92+ try {
93+ hashedPassword = await hashPassword(password);
94+ } catch (err) {
95+ console.debug("Error hashing password", err);
96+ return new Response(JSON.stringify({ error: 'Internal Server Error' } as RegisterResponseError), { status: 500 });
97+ }
98+ // Insert the new user into the database.
99+ try {
100+ await db.insert(users).values({
101+ password: hashedPassword,
102+ nickname: username,
103+ createdAt: Date.now()
104+ });
105+ console.debug(`User ${username} registered successfully`);
106+ } catch (err) {
107+ console.debug("Database insertion error", err);
108+ // Assume a duplicate user error if the email (or derived unique field) already exists.
109+ return new Response(JSON.stringify({ error: 'User registration failed. Possibly user already exists.' } as RegisterResponseError), { status: 409 });
110+ }
111+
112+ return new Response(JSON.stringify({ success: true } as RegisterResponseSuccess), { status: 201 });
113+};
A src/routes/api/rooms/[roomId]/+server.ts +61 -0
@@ -0,0 +1,61 @@
1+import { desc, eq, not } from 'drizzle-orm';
2+import db from '$lib/db/db.server';
3+import { messages, users } from '$lib/db/schema';
4+import type { PublicRoomResponse } from '$lib/types/payloads';
5+import { error } from '@sveltejs/kit';
6+
7+export async function GET({ params, url, locals }): Promise<Response> {
8+ const { roomId } = params;
9+ const isPublic = url.searchParams.get('public') === 'true';
10+
11+ // Check authentication for non-public requests
12+ if (!isPublic && !locals.user) {
13+ throw error(401, 'Authentication required');
14+ }
15+
16+ try {
17+ console.debug(`Fetching ${isPublic ? 'public' : 'private'} room data for room: ${roomId}`);
18+
19+ // Fetch messages for the room (most recent then reversed for chronological order)
20+ const messageQuery = db.select()
21+ .from(messages)
22+ .where(eq(messages.chatRoomId, roomId))
23+ .orderBy(desc(messages.timestamp));
24+
25+ // Limit messages for public requests
26+ if (isPublic) {
27+ messageQuery.limit(10);
28+ } else {
29+ messageQuery.limit(50);
30+ }
31+
32+ // Fetch buddy list: users that are not offline
33+ const fetchedUsers = await db.select()
34+ .from(users)
35+ .where(not(eq(users.status, 'offline')));
36+
37+ // Sanitize user data by removing sensitive fields
38+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
39+ const sanitizedUsers = fetchedUsers.map(({ password, ...userWithoutPassword }) => userWithoutPassword);
40+
41+ const responseData: PublicRoomResponse = {
42+ success: true,
43+ buddyList: sanitizedUsers
44+ };
45+
46+ return new Response(JSON.stringify(responseData), {
47+ status: 200,
48+ headers: { 'Content-Type': 'application/json' }
49+ });
50+ } catch (err) {
51+ console.debug('Error fetching room data:', err);
52+ const errorResponse: PublicRoomResponse = {
53+ success: false,
54+ error: 'Failed to fetch room data'
55+ };
56+ return new Response(JSON.stringify(errorResponse), {
57+ status: 500,
58+ headers: { 'Content-Type': 'application/json' }
59+ });
60+ }
61+} ⏎̸
@@ -0,0 +1,61 @@
1+import { desc, eq, not } from 'drizzle-orm';
2+import db from '$lib/db/db.server';
3+import { messages, users } from '$lib/db/schema';
4+import type { PublicRoomResponse } from '$lib/types/payloads';
5+import { error } from '@sveltejs/kit';
6+
7+export async function GET({ params, url, locals }): Promise<Response> {
8+ const { roomId } = params;
9+ const isPublic = url.searchParams.get('public') === 'true';
10+
11+ // Check authentication for non-public requests
12+ if (!isPublic && !locals.user) {
13+ throw error(401, 'Authentication required');
14+ }
15+
16+ try {
17+ console.debug(`Fetching ${isPublic ? 'public' : 'private'} room data for room: ${roomId}`);
18+
19+ // Fetch messages for the room (most recent then reversed for chronological order)
20+ const messageQuery = db.select()
21+ .from(messages)
22+ .where(eq(messages.chatRoomId, roomId))
23+ .orderBy(desc(messages.timestamp));
24+
25+ // Limit messages for public requests
26+ if (isPublic) {
27+ messageQuery.limit(10);
28+ } else {
29+ messageQuery.limit(50);
30+ }
31+
32+ // Fetch buddy list: users that are not offline
33+ const fetchedUsers = await db.select()
34+ .from(users)
35+ .where(not(eq(users.status, 'offline')));
36+
37+ // Sanitize user data by removing sensitive fields
38+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
39+ const sanitizedUsers = fetchedUsers.map(({ password, ...userWithoutPassword }) => userWithoutPassword);
40+
41+ const responseData: PublicRoomResponse = {
42+ success: true,
43+ buddyList: sanitizedUsers
44+ };
45+
46+ return new Response(JSON.stringify(responseData), {
47+ status: 200,
48+ headers: { 'Content-Type': 'application/json' }
49+ });
50+ } catch (err) {
51+ console.debug('Error fetching room data:', err);
52+ const errorResponse: PublicRoomResponse = {
53+ success: false,
54+ error: 'Failed to fetch room data'
55+ };
56+ return new Response(JSON.stringify(errorResponse), {
57+ status: 500,
58+ headers: { 'Content-Type': 'application/json' }
59+ });
60+ }
61+} ⏎̸
M src/routes/api/session/login/+server.ts +79 -22
@@ -1,24 +1,81 @@
1-import { json } from '@sveltejs/kit';
1+import type { RequestHandler } from '@sveltejs/kit';
2+import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3+import db from '$lib/db/db.server';
4+import { users } from '$lib/db/schema';
5+import { eq } from 'drizzle-orm';
6+import { verifyPassword } from '$lib/utils/password';
27 import { generateSessionToken, createSession } from '$lib/api/session.server';
8+import { setSessionTokenCookie } from '$lib/api/session.cookie';
39
4-export async function POST({ request, cookies }) {
5- // For simplicity, assume the request includes the validated userId.
6- const { userId } = await request.json();
7-
8- // Generate a secure token and create the session record.
9- const token = generateSessionToken();
10- const session = await createSession(token, userId);
11-
12- // Set the token as a cookie (adjust options as needed for production)
13- cookies.set('session', token, {
14- path: '/',
15- httpOnly: true,
16- secure: process.env.NODE_ENV === 'production',
17- sameSite: 'lax',
18- maxAge: 60 * 60 * 24 // 24h expiry
19- });
20-
21- console.debug("Login endpoint created session:", session);
22-
23- return json({ session });
24-} ⏎̸
10+export const POST: RequestHandler = async ({ request, cookies }) => {
11+ console.debug("POST /api/session/login called");
12+
13+ if (request.method !== 'POST') {
14+ return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });
15+ }
16+
17+ let body: unknown;
18+ try {
19+ body = await request.json();
20+ } catch (err) {
21+ console.debug("Invalid JSON", err);
22+ return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 });
23+ }
24+
25+ const { username, password } = body as { username: string, password: string };
26+
27+ if (typeof username !== 'string' || typeof password !== 'string') {
28+ console.debug("Missing or invalid input fields", { username, password });
29+ return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });
30+ }
31+
32+ // Find user by username
33+ const user = await db.query.users.findFirst({
34+ where: eq(users.nickname, username.trim())
35+ });
36+
37+ if (!user) {
38+ console.debug("User not found:", username);
39+ return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });
40+ }
41+
42+ // Verify password
43+ try {
44+ const isValid = await verifyPassword(password.trim(), user.password);
45+ if (!isValid) {
46+ console.debug("Invalid password for user:", username);
47+ return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });
48+ }
49+ } catch (err) {
50+ console.debug("Error verifying password", err);
51+ return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
52+ }
53+
54+ // Generate session token and create session
55+ const token = generateSessionToken();
56+ const session = await createSession(token, user.id);
57+
58+ // Update user status to online
59+ await db.update(users)
60+ .set({
61+ status: 'online',
62+ lastSeen: Date.now()
63+ })
64+ .where(eq(users.id, user.id));
65+
66+ // Set session cookie
67+ setSessionTokenCookie({ cookies }, token, session.expiresAt);
68+
69+ return new Response(JSON.stringify({
70+ success: true,
71+ user: {
72+ id: user.id,
73+ nickname: user.nickname,
74+ status: 'online',
75+ avatarUrl: user.avatarUrl,
76+ lastSeen: Date.now()
77+ }
78+ } as LoginResponseSuccess), {
79+ status: 200
80+ });
81+}; ⏎̸
@@ -1,24 +1,81 @@
1-import { json } from '@sveltejs/kit';1+import type { RequestHandler } from '@sveltejs/kit';
2+import type { LoginResponseSuccess, LoginResponseError } from '$lib/types/payloads';
3+import db from '$lib/db/db.server';
4+import { users } from '$lib/db/schema';
5+import { eq } from 'drizzle-orm';
6+import { verifyPassword } from '$lib/utils/password';
2 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';
3 9
4-export async function POST({ request, cookies }) {10+export const POST: RequestHandler = async ({ request, cookies }) => {
5- // For simplicity, assume the request includes the validated userId.11+ console.debug("POST /api/session/login called");
6- const { userId } = await request.json();12+
7- 13+ if (request.method !== 'POST') {
8- // Generate a secure token and create the session record.14+ return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { status: 405 });
9- const token = generateSessionToken();15+ }
10- const session = await createSession(token, userId);16+
11- 17+ let body: unknown;
12- // Set the token as a cookie (adjust options as needed for production)18+ try {
13- cookies.set('session', token, {19+ body = await request.json();
14- path: '/',20+ } catch (err) {
15- httpOnly: true,21+ console.debug("Invalid JSON", err);
16- secure: process.env.NODE_ENV === 'production',22+ return new Response(JSON.stringify({ error: 'Invalid JSON' } as LoginResponseError), { status: 400 });
17- sameSite: 'lax',23+ }
18- maxAge: 60 * 60 * 24 // 24h expiry24+
19- });25+ const { username, password } = body as { username: string, password: string };
20- 26+
21- console.debug("Login endpoint created session:", session);27+ if (typeof username !== 'string' || typeof password !== 'string') {
22- 28+ console.debug("Missing or invalid input fields", { username, password });
23- return json({ session });29+ return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), { status: 400 });
24-} ⏎̸30+ }
31+
32+ // Find user by username
33+ const user = await db.query.users.findFirst({
34+ where: eq(users.nickname, username.trim())
35+ });
36+
37+ if (!user) {
38+ console.debug("User not found:", username);
39+ return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });
40+ }
41+
42+ // Verify password
43+ try {
44+ const isValid = await verifyPassword(password.trim(), user.password);
45+ if (!isValid) {
46+ console.debug("Invalid password for user:", username);
47+ return new Response(JSON.stringify({ error: 'Invalid username or password' } as LoginResponseError), { status: 401 });
48+ }
49+ } catch (err) {
50+ console.debug("Error verifying password", err);
51+ return new Response(JSON.stringify({ error: 'Internal Server Error' } as LoginResponseError), { status: 500 });
52+ }
53+
54+ // Generate session token and create session
55+ const token = generateSessionToken();
56+ const session = await createSession(token, user.id);
57+
58+ // Update user status to online
59+ await db.update(users)
60+ .set({
61+ status: 'online',
62+ lastSeen: Date.now()
63+ })
64+ .where(eq(users.id, user.id));
65+
66+ // Set session cookie
67+ setSessionTokenCookie({ cookies }, token, session.expiresAt);
68+
69+ return new Response(JSON.stringify({
70+ success: true,
71+ user: {
72+ id: user.id,
73+ nickname: user.nickname,
74+ status: 'online',
75+ avatarUrl: user.avatarUrl,
76+ lastSeen: Date.now()
77+ }
78+ } as LoginResponseSuccess), {
79+ status: 200
80+ });
81+}; ⏎̸
A src/routes/api/users/[userId]/+server.ts +36 -0
@@ -0,0 +1,36 @@
1+import { error, json } from '@sveltejs/kit';
2+import db from '$lib/db/db.server';
3+import { users } from '$lib/db/schema';
4+import { eq } from 'drizzle-orm';
5+import type { RequestHandler } from './$types';
6+
7+export const GET: RequestHandler = async ({ params }) => {
8+ console.debug('GET /api/users/[userId] called with params:', params);
9+
10+ try {
11+ const user = await db.select({
12+ id: users.id,
13+ nickname: users.nickname,
14+ status: users.status,
15+ avatarUrl: users.avatarUrl,
16+ lastSeen: users.lastSeen
17+ })
18+ .from(users)
19+ .where(eq(users.id, params.userId))
20+ .get();
21+
22+ if (!user) {
23+ console.debug('User not found:', params.userId);
24+ throw error(404, 'User not found');
25+ }
26+
27+ console.debug('User found:', user);
28+ return json({
29+ success: true,
30+ user
31+ });
32+ } catch (err) {
33+ console.debug('Error fetching user:', err);
34+ throw error(500, 'Failed to fetch user information');
35+ }
36+}; ⏎̸
@@ -0,0 +1,36 @@
1+import { error, json } from '@sveltejs/kit';
2+import db from '$lib/db/db.server';
3+import { users } from '$lib/db/schema';
4+import { eq } from 'drizzle-orm';
5+import type { RequestHandler } from './$types';
6+
7+export const GET: RequestHandler = async ({ params }) => {
8+ console.debug('GET /api/users/[userId] called with params:', params);
9+
10+ try {
11+ const user = await db.select({
12+ id: users.id,
13+ nickname: users.nickname,
14+ status: users.status,
15+ avatarUrl: users.avatarUrl,
16+ lastSeen: users.lastSeen
17+ })
18+ .from(users)
19+ .where(eq(users.id, params.userId))
20+ .get();
21+
22+ if (!user) {
23+ console.debug('User not found:', params.userId);
24+ throw error(404, 'User not found');
25+ }
26+
27+ console.debug('User found:', user);
28+ return json({
29+ success: true,
30+ user
31+ });
32+ } catch (err) {
33+ console.debug('Error fetching user:', err);
34+ throw error(500, 'Failed to fetch user information');
35+ }
36+}; ⏎̸

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