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 Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown - refs
- tag: v0.1
20 files · +1439 -154
M src/app.d.ts +7 -1
| @@ -1,13 +1,19 @@ | |||
| 1 | 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | |
| 2 | 2 | // for information about these interfaces | |
| 3 | + | import type { User, Session } from "$lib/types/chat"; | |
| 4 | + | ||
| 3 | 5 | declare global { | |
| 4 | 6 | namespace App { | |
| 5 | 7 | // interface Error {} | |
| 6 | - | // interface Locals {} | |
| 8 | + | interface Locals { | |
| 9 | + | user: User | null; | |
| 10 | + | session: Session | null; | |
| 11 | + | } | |
| 7 | 12 | // interface PageData {} | |
| 8 | 13 | // interface PageState {} | |
| 9 | 14 | // interface Platform {} | |
| 10 | 15 | } | |
| 16 | + | type Window = object | |
| 11 | 17 | } | |
| 12 | 18 | ||
| 13 | 19 | export {}; | |
| @@ -1,13 +1,19 @@ | |||||
| 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | 1 | // See https://svelte.dev/docs/kit/types#app.d.ts | ||
| 2 | // for information about these interfaces | 2 | // 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'; | |||
| 4 | 4 | import type { Session, User } from '../types/chat'; | |
| 5 | 5 | ||
| 6 | 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 | 8 | const encoder = new TextEncoder(); | |
| 9 | 9 | const data = encoder.encode(message); | |
| 10 | 10 | 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 @@ | |||
| 1 | 1 | <!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs --> | |
| 2 | 2 | <script lang="ts"> | |
| 3 | 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 | 7 | import { createEventDispatcher } from 'svelte'; | |
| 5 | 8 | ||
| 9 | + | let { onLoginSuccess }: { onLoginSuccess?: (user: User) => void } = $props(); | |
| 6 | 10 | const dispatch = createEventDispatcher(); | |
| 7 | - | ||
| 11 | + | ||
| 8 | 12 | let activeTab = $state('signin'); | |
| 9 | 13 | ||
| 10 | 14 | // Sign In state | |
| @@ -13,6 +17,7 @@ | |||
| 13 | 17 | let savePassword = $state(true); | |
| 14 | 18 | let autoLogin = $state(false); | |
| 15 | 19 | let error = $state(''); | |
| 20 | + | let loginStatus = $state('idle'); | |
| 16 | 21 | ||
| 17 | 22 | // Sign Up state | |
| 18 | 23 | let suUsername = $state(''); | |
| @@ -20,33 +25,135 @@ | |||
| 20 | 25 | let suConfirmPassword = $state(''); | |
| 21 | 26 | let captchaAnswer = $state(''); | |
| 22 | 27 | let signupError = $state(''); | |
| 28 | + | let signupStatus = $state('idle'); | |
| 23 | 29 | ||
| 24 | - | function handleSigninSubmit() { | |
| 30 | + | async function handleSigninSubmit() { | |
| 25 | 31 | error = ''; | |
| 32 | + | loginStatus = 'loading'; | |
| 26 | 33 | if (!siUsername || !siPassword) { | |
| 27 | 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 | 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 | 86 | signupError = ''; | |
| 87 | + | signupStatus = 'loading'; | |
| 36 | 88 | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) { | |
| 37 | 89 | signupError = 'Please fill all fields'; | |
| 90 | + | signupStatus = 'error'; | |
| 38 | 91 | return; | |
| 39 | 92 | } | |
| 40 | 93 | if (suPassword !== suConfirmPassword) { | |
| 41 | 94 | signupError = 'Passwords do not match'; | |
| 95 | + | signupStatus = 'error'; | |
| 42 | 96 | return; | |
| 43 | 97 | } | |
| 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(); | |
| 46 | 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 | 159 | function switchTab(tab: string) { | |
| @@ -56,9 +163,17 @@ | |||
| 56 | 163 | } | |
| 57 | 164 | ||
| 58 | 165 | function handleClose() { | |
| 166 | + | console.debug("Closing AIM Login component."); | |
| 59 | 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 | 177 | // Added dragging state and handler similar to chat-room | |
| 63 | 178 | let windowX = $state(0); | |
| 64 | 179 | let windowY = $state(0); | |
| @@ -69,6 +184,8 @@ | |||
| 69 | 184 | } | |
| 70 | 185 | </script> | |
| 71 | 186 | ||
| 187 | + | <svelte:window onkeydown={handleKeydown} /> | |
| 188 | + | ||
| 72 | 189 | <!-- Updated outer div to handle dragging with inline style and drag event --> | |
| 73 | 190 | <div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;"> | |
| 74 | 191 | <div class="title-bar"> | |
| @@ -111,10 +228,29 @@ | |||
| 111 | 228 | <div class="error">{error}</div> | |
| 112 | 229 | {/if} | |
| 113 | 230 | <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} | |
| 118 | 254 | Sign In | |
| 119 | 255 | </button> | |
| 120 | 256 | </div> | |
| @@ -141,10 +277,29 @@ | |||
| 141 | 277 | <div class="error">{signupError}</div> | |
| 142 | 278 | {/if} | |
| 143 | 279 | <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} | |
| 148 | 303 | Sign Up | |
| 149 | 304 | </button> | |
| 150 | 305 | </div> | |
| @@ -207,8 +362,7 @@ | |||
| 207 | 362 | margin-bottom: 4px; | |
| 208 | 363 | font-size: 1rem; | |
| 209 | 364 | } | |
| 210 | - | .form-group input, | |
| 211 | - | .form-group select { | |
| 365 | + | .form-group input { | |
| 212 | 366 | width: 100%; | |
| 213 | 367 | padding: 4px; | |
| 214 | 368 | font-size: 1rem; | |
| @@ -245,4 +399,10 @@ | |||
| 245 | 399 | font-size: 1rem; | |
| 246 | 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 | 408 | </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 state | 14 | // 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 state | 22 | // 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-room | 177 | // 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 In | 254 | 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 Up | 303 | 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 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 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 | 4 | import { onMount } from 'svelte'; | |
| 5 | 5 | import { browser } from '$app/environment'; | |
| 6 | 6 | import { draggable } from '$lib/actions/draggable'; | |
| @@ -17,8 +17,6 @@ let currentMessage = $state(''); | |||
| 17 | 17 | let showUserList = $state(false); | |
| 18 | 18 | let isMaximized = $state(false); | |
| 19 | 19 | ||
| 20 | - | ||
| 21 | - | // let { value = $bindable(), ...props } = $props(); | |
| 22 | 20 | let { showChatRoom = $bindable() } = $props(); | |
| 23 | 21 | ||
| 24 | 22 | function handleClose() { | |
| @@ -26,9 +24,85 @@ function handleClose() { | |||
| 26 | 24 | } | |
| 27 | 25 | ||
| 28 | 26 | // 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 | + | }); | |
| 32 | 106 | ||
| 33 | 107 | onMount(() => { | |
| 34 | 108 | if (!browser) return; | |
| @@ -53,13 +127,20 @@ onMount(() => { | |||
| 53 | 127 | ||
| 54 | 128 | // Add resize listener | |
| 55 | 129 | window.addEventListener('resize', handleResize); | |
| 130 | + | ||
| 56 | 131 | return () => window.removeEventListener('resize', handleResize); | |
| 57 | 132 | }); | |
| 58 | 133 | ||
| 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); | |
| 62 | 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 | 239 | class="sunken-panel chat-area" | |
| 159 | 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 | 243 | <div class="message {message.type} text"> | |
| 163 | 244 | {#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> | |
| 166 | 247 | {: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> | |
| 169 | 250 | <span class="content">{message.content}</span> | |
| 170 | 251 | {/if} | |
| 171 | 252 | </div> | |
| @@ -179,8 +260,9 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 179 | 260 | style="flex: 1;" | |
| 180 | 261 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 181 | 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 | 266 | </div> | |
| 185 | 267 | </div> | |
| 186 | 268 | ||
| @@ -306,6 +388,13 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 306 | 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 | 398 | .hidden { | |
| 310 | 399 | display: none !important; | |
| 311 | 400 | } | |
| @@ -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 values | 26 | // 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 listener | 128 | // 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 @@ | |||
| 1 | 1 | import { drizzle } from 'drizzle-orm/libsql'; | |
| 2 | 2 | import { createClient } from '@libsql/client'; | |
| 3 | 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 | 6 | const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN }); | |
| 7 | 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 | 26 | 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); | |
| 12 | 29 | }; | |
| 13 | 30 | ||
| 14 | 31 | 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"; | |||
| 3 | 3 | import type { User, ChatRoom, Message, UserStatus, MessageType, Session } from "../types/chat"; | |
| 4 | 4 | import { v4 as uuidv4 } from "uuid"; | |
| 5 | 5 | import { eq } from "drizzle-orm"; | |
| 6 | + | import type { LibSQLDatabase } from "drizzle-orm/libsql"; | |
| 6 | 7 | ||
| 7 | 8 | export const users = table( | |
| 8 | 9 | "users", | |
| 9 | 10 | { | |
| 10 | 11 | id: t.text("id").primaryKey().$default(() => uuidv4()), | |
| 11 | - | email: t.text("email").notNull().unique(), | |
| 12 | 12 | password: t.text("password").notNull(), | |
| 13 | 13 | nickname: t.text("nickname").notNull(), | |
| 14 | 14 | status: t.text("status").notNull().$type<UserStatus>().default("offline"), | |
| @@ -94,3 +94,44 @@ export const messageView = t.sqliteView("message_view").as((qb) => | |||
| 94 | 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 | + | ||
| @@ -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 | + | ||
| 65 | 9 | ||
| 66 | 10 | 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>>({}); | |
| 70 | 21 | ||
| 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 | + | } | |
| 73 | 38 | } | |
| 74 | 39 | ||
| 75 | 40 | // User methods | |
| @@ -77,42 +42,312 @@ class ChatState { | |||
| 77 | 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 | 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 | 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) { | |
| 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 | + | }); | |
| 94 | 132 | } | |
| 95 | 133 | ||
| 96 | 134 | // Message methods | |
| 97 | 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') { | |
| 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 | + | }); | |
| 103 | 226 | ||
| 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 | + | } | |
| 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 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 | + | } | |
| 116 | 351 | } | |
| 117 | 352 | ||
| 118 | 353 | // 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 users | 3 | + | 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 cleanup | 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 | + | } | |||
| 73 | } | 38 | } | ||
| 74 | 39 | ||||
| 75 | // User methods | 40 | // 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 | - | : user | 126 | + | // Update cache |
| 93 | - | ); | 127 | + | this.userCache[userId] = updatedUser; |
| 128 | + | return updatedUser; | |||
| 129 | + | } | |||
| 130 | + | return user; | |||
| 131 | + | }); | |||
| 94 | } | 132 | } | ||
| 95 | 133 | ||||
| 96 | // Message methods | 134 | // 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 | - | type | 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 | + | } | |||
| 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 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 | + | } | |||
| 116 | } | 351 | } | ||
| 117 | 352 | ||||
| 118 | // Export a singleton instance | 353 | // Export a singleton instance | ||
M src/lib/types/chat.ts +4 -1
| @@ -6,7 +6,6 @@ | |||
| 6 | 6 | // Users table: holds authentication and profile details | |
| 7 | 7 | export interface User { | |
| 8 | 8 | id: string; | |
| 9 | - | email: string; | |
| 10 | 9 | password: string; // hashed password | |
| 11 | 10 | nickname: string; | |
| 12 | 11 | status: UserStatus; // default is 'offline' | |
| @@ -35,6 +34,10 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||
| 35 | 34 | timestamp: number; // Timestamp when the message was sent | |
| 36 | 35 | } | |
| 37 | 36 | ||
| 37 | + | export interface EnrichedMessage extends Message { | |
| 38 | + | user: User; | |
| 39 | + | } | |
| 40 | + | ||
| 38 | 41 | export type MessageType = 'chat' | 'emote' | 'system' | string; | |
| 39 | 42 | ||
| 40 | 43 | export interface Session { | |
| @@ -6,7 +6,6 @@ | |||||
| 6 | // Users table: holds authentication and profile details | 6 | // 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 password | 9 | 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 sent | 34 | 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 @@ | |||
| 2 | 2 | import '../app.css'; | |
| 3 | 3 | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 4 | 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); | |
| 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 | + | }); | |
| 9 | 31 | </script> | |
| 10 | 32 | ||
| 11 | 33 | <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'; | |
| 2 | 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 }) { | |
| 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 expiry | 24 | + | |
| 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 | + | }; ⏎̸ | |||