feat: extend backend APIs and database schema for enhanced chat functionality
- commit
- 03de79c7c01c958c0fdddc18c2cd474666a09033
- author
- Kit · · 2025-07-03 19:55 UTC
- parent
- bf7e125
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
8 files · +129 -108
M src/lib/db/schema.ts +19 -0
| @@ -47,6 +47,23 @@ export const messages = table( | |||
| 47 | 47 | content: t.text("content").notNull(), | |
| 48 | 48 | type: t.text("type").$type<MessageType>().notNull().default("chat"), | |
| 49 | 49 | timestamp: t.integer("timestamp").notNull(), | |
| 50 | + | styleData: t.text("style_data"), // JSON string of TextStyle object | |
| 51 | + | hasFormatting: t.integer("has_formatting", { mode: "boolean" }).default(false), // Boolean flag | |
| 52 | + | } | |
| 53 | + | ); | |
| 54 | + | ||
| 55 | + | export const userTextPreferences = table( | |
| 56 | + | "user_text_preferences", | |
| 57 | + | { | |
| 58 | + | userId: t.text("user_id").references(() => users.id).primaryKey(), | |
| 59 | + | defaultFontFamily: t.text("default_font_family").default("tahoma"), | |
| 60 | + | defaultFontSize: t.integer("default_font_size").default(14), | |
| 61 | + | defaultColor: t.text("default_color").default("black"), | |
| 62 | + | allowFormatting: t.integer("allow_formatting", { mode: "boolean" }).default(true), | |
| 63 | + | maxMessageLength: t.integer("max_message_length").default(500), | |
| 64 | + | stylePresets: t.text("style_presets"), // JSON array of saved styles | |
| 65 | + | createdAt: t.integer("created_at").notNull(), | |
| 66 | + | updatedAt: t.integer("updated_at").notNull(), | |
| 50 | 67 | } | |
| 51 | 68 | ); | |
| 52 | 69 | ||
| @@ -55,6 +72,7 @@ export type Users = typeof users.$inferSelect; | |||
| 55 | 72 | export type ChatRooms = typeof chatRooms.$inferSelect; | |
| 56 | 73 | export type Messages = typeof messages.$inferSelect; | |
| 57 | 74 | export type Sessions = typeof sessions.$inferSelect; | |
| 75 | + | export type UserTextPreferences = typeof userTextPreferences.$inferSelect; | |
| 58 | 76 | // These type assertions will fail if the schema doesn't match the interfaces | |
| 59 | 77 | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | |
| 60 | 78 | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>; | |
| @@ -100,6 +118,7 @@ const schema = { | |||
| 100 | 118 | sessions, | |
| 101 | 119 | chatRooms, | |
| 102 | 120 | messages, | |
| 121 | + | userTextPreferences, | |
| 103 | 122 | userView, | |
| 104 | 123 | chatRoomView, | |
| 105 | 124 | messageView | |
| @@ -47,6 +47,23 @@ export const messages = table( | |||||
| 47 | content: t.text("content").notNull(), | 47 | content: t.text("content").notNull(), | ||
| 48 | type: t.text("type").$type<MessageType>().notNull().default("chat"), | 48 | type: t.text("type").$type<MessageType>().notNull().default("chat"), | ||
| 49 | timestamp: t.integer("timestamp").notNull(), | 49 | timestamp: t.integer("timestamp").notNull(), | ||
| 50 | + | styleData: t.text("style_data"), // JSON string of TextStyle object | |||
| 51 | + | hasFormatting: t.integer("has_formatting", { mode: "boolean" }).default(false), // Boolean flag | |||
| 52 | + | } | |||
| 53 | + | ); | |||
| 54 | + | ||||
| 55 | + | export const userTextPreferences = table( | |||
| 56 | + | "user_text_preferences", | |||
| 57 | + | { | |||
| 58 | + | userId: t.text("user_id").references(() => users.id).primaryKey(), | |||
| 59 | + | defaultFontFamily: t.text("default_font_family").default("tahoma"), | |||
| 60 | + | defaultFontSize: t.integer("default_font_size").default(14), | |||
| 61 | + | defaultColor: t.text("default_color").default("black"), | |||
| 62 | + | allowFormatting: t.integer("allow_formatting", { mode: "boolean" }).default(true), | |||
| 63 | + | maxMessageLength: t.integer("max_message_length").default(500), | |||
| 64 | + | stylePresets: t.text("style_presets"), // JSON array of saved styles | |||
| 65 | + | createdAt: t.integer("created_at").notNull(), | |||
| 66 | + | updatedAt: t.integer("updated_at").notNull(), | |||
| 50 | } | 67 | } | ||
| 51 | ); | 68 | ); | ||
| 52 | 69 | ||||
| @@ -55,6 +72,7 @@ export type Users = typeof users.$inferSelect; | |||||
| 55 | export type ChatRooms = typeof chatRooms.$inferSelect; | 72 | export type ChatRooms = typeof chatRooms.$inferSelect; | ||
| 56 | export type Messages = typeof messages.$inferSelect; | 73 | export type Messages = typeof messages.$inferSelect; | ||
| 57 | export type Sessions = typeof sessions.$inferSelect; | 74 | export type Sessions = typeof sessions.$inferSelect; | ||
| 75 | + | export type UserTextPreferences = typeof userTextPreferences.$inferSelect; | |||
| 58 | // These type assertions will fail if the schema doesn't match the interfaces | 76 | // These type assertions will fail if the schema doesn't match the interfaces | ||
| 59 | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | 77 | export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>; | ||
| 60 | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>; | 78 | export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>; | ||
| @@ -100,6 +118,7 @@ const schema = { | |||||
| 100 | sessions, | 118 | sessions, | ||
| 101 | chatRooms, | 119 | chatRooms, | ||
| 102 | messages, | 120 | messages, | ||
| 121 | + | userTextPreferences, | |||
| 103 | userView, | 122 | userView, | ||
| 104 | chatRoomView, | 123 | chatRoomView, | ||
| 105 | messageView | 124 | messageView | ||
M src/lib/states/chat.svelte.ts +6 -1
| @@ -488,7 +488,7 @@ class ChatState { | |||
| 488 | 488 | } | |
| 489 | 489 | } | |
| 490 | 490 | ||
| 491 | - | async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> { | |
| 491 | + | async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> { | |
| 492 | 492 | const user = this.getCurrentUser(); | |
| 493 | 493 | if (!user) { | |
| 494 | 494 | console.debug('Cannot send message: No current user'); | |
| @@ -505,6 +505,11 @@ class ChatState { | |||
| 505 | 505 | userId: user.id, | |
| 506 | 506 | chatRoomId: DEFAULT_CHAT_ROOM_ID | |
| 507 | 507 | }; | |
| 508 | + | ||
| 509 | + | // Add styleData if textStyle is provided | |
| 510 | + | if (textStyle) { | |
| 511 | + | (payload as any).styleData = JSON.stringify(textStyle); | |
| 512 | + | } | |
| 508 | 513 | ||
| 509 | 514 | console.debug('Sending message with payload:', payload); | |
| 510 | 515 | ||
| @@ -488,7 +488,7 @@ class ChatState { | |||||
| 488 | } | 488 | } | ||
| 489 | } | 489 | } | ||
| 490 | 490 | ||||
| 491 | - | async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> { | 491 | + | async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> { |
| 492 | const user = this.getCurrentUser(); | 492 | const user = this.getCurrentUser(); | ||
| 493 | if (!user) { | 493 | if (!user) { | ||
| 494 | console.debug('Cannot send message: No current user'); | 494 | console.debug('Cannot send message: No current user'); | ||
| @@ -505,6 +505,11 @@ class ChatState { | |||||
| 505 | userId: user.id, | 505 | userId: user.id, | ||
| 506 | chatRoomId: DEFAULT_CHAT_ROOM_ID | 506 | chatRoomId: DEFAULT_CHAT_ROOM_ID | ||
| 507 | }; | 507 | }; | ||
| 508 | + | ||||
| 509 | + | // Add styleData if textStyle is provided | |||
| 510 | + | if (textStyle) { | |||
| 511 | + | (payload as any).styleData = JSON.stringify(textStyle); | |||
| 512 | + | } | |||
| 508 | 513 | ||||
| 509 | console.debug('Sending message with payload:', payload); | 514 | console.debug('Sending message with payload:', payload); | ||
| 510 | 515 | ||||
M src/lib/types/chat.ts +2 -0
| @@ -27,6 +27,8 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||
| 27 | 27 | content: string; | |
| 28 | 28 | type: MessageType; // Default is 'chat' | |
| 29 | 29 | timestamp: number; // Timestamp when the message was sent | |
| 30 | + | styleData?: string; // JSON string of TextStyle object for formatting | |
| 31 | + | hasFormatting?: boolean; // Flag to indicate if message has custom formatting | |
| 30 | 32 | } | |
| 31 | 33 | ||
| 32 | 34 | // Safe user type for API responses - omits sensitive information | |
| @@ -27,6 +27,8 @@ export type UserStatus = 'offline' | 'online' | 'away' | string; | |||||
| 27 | content: string; | 27 | content: string; | ||
| 28 | type: MessageType; // Default is 'chat' | 28 | type: MessageType; // Default is 'chat' | ||
| 29 | timestamp: number; // Timestamp when the message was sent | 29 | timestamp: number; // Timestamp when the message was sent | ||
| 30 | + | styleData?: string; // JSON string of TextStyle object for formatting | |||
| 31 | + | hasFormatting?: boolean; // Flag to indicate if message has custom formatting | |||
| 30 | } | 32 | } | ||
| 31 | 33 | ||||
| 32 | // Safe user type for API responses - omits sensitive information | 34 | // Safe user type for API responses - omits sensitive information | ||
M src/lib/types/payloads.ts +1 -0
| @@ -44,6 +44,7 @@ export interface SendMessageRequest { | |||
| 44 | 44 | type?: Message['type']; | |
| 45 | 45 | userId: string; | |
| 46 | 46 | chatRoomId?: string; | |
| 47 | + | styleData?: string; // JSON stringified TextStyle | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | export interface SendMessageResponseSuccess { | |
| @@ -44,6 +44,7 @@ export interface SendMessageRequest { | |||||
| 44 | type?: Message['type']; | 44 | type?: Message['type']; | ||
| 45 | userId: string; | 45 | userId: string; | ||
| 46 | chatRoomId?: string; | 46 | chatRoomId?: string; | ||
| 47 | + | styleData?: string; // JSON stringified TextStyle | |||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | export interface SendMessageResponseSuccess { | 50 | export interface SendMessageResponseSuccess { | ||
A src/routes/+page.server.ts +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | import type { PageServerLoad } from './$types'; | |
| 2 | + | import { validateSessionToken } from '$lib/api/session.server'; | |
| 3 | + | import db from '$lib/db/db.server'; | |
| 4 | + | import { userTextPreferences, users, messages } from '$lib/db/schema'; | |
| 5 | + | import { eq, desc } from 'drizzle-orm'; | |
| 6 | + | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | |
| 7 | + | ||
| 8 | + | export const load: PageServerLoad = async ({ cookies }) => { | |
| 9 | + | const token = cookies.get('session'); | |
| 10 | + | ||
| 11 | + | if (!token) { | |
| 12 | + | return { | |
| 13 | + | user: null, | |
| 14 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 15 | + | }; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | try { | |
| 19 | + | const { user } = await validateSessionToken(token); | |
| 20 | + | ||
| 21 | + | if (!user) { | |
| 22 | + | return { | |
| 23 | + | user: null, | |
| 24 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 25 | + | }; | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // Get user's last used text styling from their most recent message | |
| 29 | + | const lastMessage = await db.query.messages.findFirst({ | |
| 30 | + | where: eq(messages.senderId, user.id), | |
| 31 | + | orderBy: [desc(messages.timestamp)], | |
| 32 | + | columns: { | |
| 33 | + | styleData: true | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; | |
| 38 | + | ||
| 39 | + | if (lastMessage?.styleData) { | |
| 40 | + | try { | |
| 41 | + | const parsedStyle = JSON.parse(lastMessage.styleData); | |
| 42 | + | // Merge with default style to ensure all required properties | |
| 43 | + | lastTextStyle = { | |
| 44 | + | ...DEFAULT_TEXT_STYLE, | |
| 45 | + | ...parsedStyle | |
| 46 | + | }; | |
| 47 | + | } catch (error) { | |
| 48 | + | console.warn('Failed to parse last text style:', error); | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | return { | |
| 53 | + | user, | |
| 54 | + | lastTextStyle | |
| 55 | + | }; | |
| 56 | + | } catch (error) { | |
| 57 | + | console.error('Error loading page data:', error); | |
| 58 | + | return { | |
| 59 | + | user: null, | |
| 60 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |
| 61 | + | }; | |
| 62 | + | } | |
| 63 | + | };⏎̸ | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | import type { PageServerLoad } from './$types'; | |||
| 2 | + | import { validateSessionToken } from '$lib/api/session.server'; | |||
| 3 | + | import db from '$lib/db/db.server'; | |||
| 4 | + | import { userTextPreferences, users, messages } from '$lib/db/schema'; | |||
| 5 | + | import { eq, desc } from 'drizzle-orm'; | |||
| 6 | + | import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting'; | |||
| 7 | + | ||||
| 8 | + | export const load: PageServerLoad = async ({ cookies }) => { | |||
| 9 | + | const token = cookies.get('session'); | |||
| 10 | + | ||||
| 11 | + | if (!token) { | |||
| 12 | + | return { | |||
| 13 | + | user: null, | |||
| 14 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |||
| 15 | + | }; | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | try { | |||
| 19 | + | const { user } = await validateSessionToken(token); | |||
| 20 | + | ||||
| 21 | + | if (!user) { | |||
| 22 | + | return { | |||
| 23 | + | user: null, | |||
| 24 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |||
| 25 | + | }; | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // Get user's last used text styling from their most recent message | |||
| 29 | + | const lastMessage = await db.query.messages.findFirst({ | |||
| 30 | + | where: eq(messages.senderId, user.id), | |||
| 31 | + | orderBy: [desc(messages.timestamp)], | |||
| 32 | + | columns: { | |||
| 33 | + | styleData: true | |||
| 34 | + | } | |||
| 35 | + | }); | |||
| 36 | + | ||||
| 37 | + | let lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE; | |||
| 38 | + | ||||
| 39 | + | if (lastMessage?.styleData) { | |||
| 40 | + | try { | |||
| 41 | + | const parsedStyle = JSON.parse(lastMessage.styleData); | |||
| 42 | + | // Merge with default style to ensure all required properties | |||
| 43 | + | lastTextStyle = { | |||
| 44 | + | ...DEFAULT_TEXT_STYLE, | |||
| 45 | + | ...parsedStyle | |||
| 46 | + | }; | |||
| 47 | + | } catch (error) { | |||
| 48 | + | console.warn('Failed to parse last text style:', error); | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | return { | |||
| 53 | + | user, | |||
| 54 | + | lastTextStyle | |||
| 55 | + | }; | |||
| 56 | + | } catch (error) { | |||
| 57 | + | console.error('Error loading page data:', error); | |||
| 58 | + | return { | |||
| 59 | + | user: null, | |||
| 60 | + | lastTextStyle: DEFAULT_TEXT_STYLE | |||
| 61 | + | }; | |||
| 62 | + | } | |||
| 63 | + | };⏎̸ | |||
M src/routes/api/chat/messages/+server.ts +12 -2
| @@ -15,6 +15,7 @@ import { chatRooms } from '$lib/db/schema'; | |||
| 15 | 15 | import { users } from '$lib/db/schema'; | |
| 16 | 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | |
| 17 | 17 | import { createLogger } from '$lib/utils/logger.server'; | |
| 18 | + | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | |
| 18 | 19 | ||
| 19 | 20 | const log = createLogger('chat-server'); | |
| 20 | 21 | ||
| @@ -83,7 +84,12 @@ export async function GET({ request, locals }) { | |||
| 83 | 84 | .from(messages) | |
| 84 | 85 | .where(conditions) | |
| 85 | 86 | .orderBy(desc(messages.timestamp)) | |
| 86 | - | .limit(fetchLimit); | |
| 87 | + | .limit(fetchLimit) | |
| 88 | + | .then(rows => rows.map(row => ({ | |
| 89 | + | ...row, | |
| 90 | + | styleData: row.styleData ?? undefined, | |
| 91 | + | hasFormatting: row.hasFormatting ?? false | |
| 92 | + | }))); | |
| 87 | 93 | ||
| 88 | 94 | const response: GetMessagesResponse = { | |
| 89 | 95 | success: true, | |
| @@ -182,13 +188,17 @@ export async function POST({ request, locals }: { request: Request, locals: App. | |||
| 182 | 188 | return new Response(JSON.stringify(errorResponse), { status: 404 }); | |
| 183 | 189 | } | |
| 184 | 190 | ||
| 191 | + | const validatedStyleData = sanitizeStyleData(data.styleData); | |
| 192 | + | ||
| 185 | 193 | const newMessage: Message = { | |
| 186 | 194 | id: uuidv4(), | |
| 187 | 195 | chatRoomId, | |
| 188 | 196 | senderId: data.userId, | |
| 189 | 197 | content: data.content, | |
| 190 | 198 | type: data.type || 'chat', | |
| 191 | - | timestamp: Date.now() | |
| 199 | + | timestamp: Date.now(), | |
| 200 | + | styleData: validatedStyleData, | |
| 201 | + | hasFormatting: Boolean(validatedStyleData) | |
| 192 | 202 | }; | |
| 193 | 203 | ||
| 194 | 204 | // Save to DB | |
| @@ -15,6 +15,7 @@ import { chatRooms } from '$lib/db/schema'; | |||||
| 15 | import { users } from '$lib/db/schema'; | 15 | import { users } from '$lib/db/schema'; | ||
| 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | 16 | import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server'; | ||
| 17 | import { createLogger } from '$lib/utils/logger.server'; | 17 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 18 | + | import { sanitizeStyleData } from '$lib/validation/text-formatting'; | |||
| 18 | 19 | ||||
| 19 | const log = createLogger('chat-server'); | 20 | const log = createLogger('chat-server'); | ||
| 20 | 21 | ||||
| @@ -83,7 +84,12 @@ export async function GET({ request, locals }) { | |||||
| 83 | .from(messages) | 84 | .from(messages) | ||
| 84 | .where(conditions) | 85 | .where(conditions) | ||
| 85 | .orderBy(desc(messages.timestamp)) | 86 | .orderBy(desc(messages.timestamp)) | ||
| 86 | - | .limit(fetchLimit); | 87 | + | .limit(fetchLimit) |
| 88 | + | .then(rows => rows.map(row => ({ | |||
| 89 | + | ...row, | |||
| 90 | + | styleData: row.styleData ?? undefined, | |||
| 91 | + | hasFormatting: row.hasFormatting ?? false | |||
| 92 | + | }))); | |||
| 87 | 93 | ||||
| 88 | const response: GetMessagesResponse = { | 94 | const response: GetMessagesResponse = { | ||
| 89 | success: true, | 95 | success: true, | ||
| @@ -182,13 +188,17 @@ export async function POST({ request, locals }: { request: Request, locals: App. | |||||
| 182 | return new Response(JSON.stringify(errorResponse), { status: 404 }); | 188 | return new Response(JSON.stringify(errorResponse), { status: 404 }); | ||
| 183 | } | 189 | } | ||
| 184 | 190 | ||||
| 191 | + | const validatedStyleData = sanitizeStyleData(data.styleData); | |||
| 192 | + | ||||
| 185 | const newMessage: Message = { | 193 | const newMessage: Message = { | ||
| 186 | id: uuidv4(), | 194 | id: uuidv4(), | ||
| 187 | chatRoomId, | 195 | chatRoomId, | ||
| 188 | senderId: data.userId, | 196 | senderId: data.userId, | ||
| 189 | content: data.content, | 197 | content: data.content, | ||
| 190 | type: data.type || 'chat', | 198 | type: data.type || 'chat', | ||
| 191 | - | timestamp: Date.now() | 199 | + | timestamp: Date.now(), |
| 200 | + | styleData: validatedStyleData, | |||
| 201 | + | hasFormatting: Boolean(validatedStyleData) | |||
| 192 | }; | 202 | }; | ||
| 193 | 203 | ||||
| 194 | // Save to DB | 204 | // Save to DB | ||
M src/routes/api/register/+server.ts +15 -57
| @@ -3,7 +3,7 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/ | |||
| 3 | 3 | import db from '$lib/db/db.server'; | |
| 4 | 4 | import { users } from '$lib/db/schema'; | |
| 5 | 5 | import { hashPassword } from '$lib/utils/password'; | |
| 6 | - | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |
| 6 | + | import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password'; | |
| 7 | 7 | import { createLogger } from '$lib/utils/logger.server'; | |
| 8 | 8 | import { eq } from 'drizzle-orm/sql'; | |
| 9 | 9 | ||
| @@ -54,24 +54,22 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 54 | 54 | turnstileToken?: string | |
| 55 | 55 | }; | |
| 56 | 56 | ||
| 57 | - | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || (!isDev && !turnstileToken)) { | |
| 58 | - | log.warn('Missing required fields', { | |
| 59 | - | hasUsername: Boolean(suUsername), | |
| 60 | - | hasPassword: Boolean(suPassword), | |
| 61 | - | hasConfirmPassword: Boolean(suConfirmPassword), | |
| 62 | - | hasCaptcha: Boolean(captchaAnswer), | |
| 63 | - | hasTurnstileToken: Boolean(turnstileToken), | |
| 64 | - | isDev | |
| 57 | + | // Validate input using Zod schema | |
| 58 | + | try { | |
| 59 | + | const registrationSchema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS); | |
| 60 | + | registrationSchema.parse({ | |
| 61 | + | suUsername, | |
| 62 | + | suPassword, | |
| 63 | + | suConfirmPassword, | |
| 64 | + | captchaAnswer, | |
| 65 | + | turnstileToken | |
| 65 | 66 | }); | |
| 66 | - | return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 }); | |
| 67 | + | } catch (err: any) { | |
| 68 | + | const errorMessage = err.errors?.[0]?.message || 'Invalid input data'; | |
| 69 | + | log.warn('Registration validation failed', { error: errorMessage }); | |
| 70 | + | return new Response(JSON.stringify({ error: errorMessage } as RegisterResponseError), { status: 400 }); | |
| 67 | 71 | } | |
| 68 | 72 | ||
| 69 | - | // Validate Turnstile token (will be automatically bypassed in dev mode) | |
| 70 | - | const isValidTurnstile = await validateTurnstileToken(turnstileToken || '', ip); | |
| 71 | - | if (!isValidTurnstile) { | |
| 72 | - | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 73 | - | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 }); | |
| 74 | - | } | |
| 75 | 73 | ||
| 76 | 74 | // Validate PDR captcha | |
| 77 | 75 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | |
| @@ -86,49 +84,9 @@ export const POST: RequestHandler = async ({ request }) => { | |||
| 86 | 84 | // Reset captcha attempts on success | |
| 87 | 85 | captchaAttempts.delete(ip); | |
| 88 | 86 | ||
| 89 | - | // Destructure and validate required fields from the payload. | |
| 90 | - | if (typeof suUsername !== 'string' || typeof suPassword !== 'string' || | |
| 91 | - | typeof suConfirmPassword !== 'string') { | |
| 92 | - | log.warn('Missing or invalid input fields'); | |
| 93 | - | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 }); | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // Trim input values. | |
| 87 | + | // Trim input values (validation already done by Zod) | |
| 97 | 88 | const username = suUsername.trim(); | |
| 98 | 89 | const password = suPassword.trim(); | |
| 99 | - | const confirmPassword = suConfirmPassword.trim(); | |
| 100 | - | ||
| 101 | - | // Basic validations. | |
| 102 | - | if (!username || !password || !confirmPassword) { | |
| 103 | - | log.warn('All fields must be filled'); | |
| 104 | - | return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 }); | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | if (username.length < 3) { | |
| 108 | - | log.warn('Username must be at least 3 characters'); | |
| 109 | - | return new Response(JSON.stringify({ error: 'Username must be at least 3 characters' } as RegisterResponseError), { status: 400 }); | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | if (username.length > 32) { | |
| 113 | - | log.warn('Username must be at most 32 characters'); | |
| 114 | - | return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 }); | |
| 115 | - | } | |
| 116 | - | // Only allow letters, numbers, underscores, and dashes in the username. | |
| 117 | - | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | |
| 118 | - | if (!usernameRegex.test(username)) { | |
| 119 | - | log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.'); | |
| 120 | - | return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 }); | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | if (password !== confirmPassword) { | |
| 124 | - | log.warn('Passwords do not match'); | |
| 125 | - | return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 }); | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | if (password.length < 8 || password.length > 64) { | |
| 129 | - | log.warn('Password must be between 8 and 64 characters'); | |
| 130 | - | return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 }); | |
| 131 | - | } | |
| 132 | 90 | ||
| 133 | 91 | // Check if nickname already exists | |
| 134 | 92 | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | |
| @@ -3,7 +3,7 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/ | |||||
| 3 | import db from '$lib/db/db.server'; | 3 | import db from '$lib/db/db.server'; | ||
| 4 | import { users } from '$lib/db/schema'; | 4 | import { users } from '$lib/db/schema'; | ||
| 5 | import { hashPassword } from '$lib/utils/password'; | 5 | import { hashPassword } from '$lib/utils/password'; | ||
| 6 | - | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | 6 | + | import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password'; |
| 7 | import { createLogger } from '$lib/utils/logger.server'; | 7 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 8 | import { eq } from 'drizzle-orm/sql'; | 8 | import { eq } from 'drizzle-orm/sql'; | ||
| 9 | 9 | ||||
| @@ -54,24 +54,22 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 54 | turnstileToken?: string | 54 | turnstileToken?: string | ||
| 55 | }; | 55 | }; | ||
| 56 | 56 | ||||
| 57 | - | if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer || (!isDev && !turnstileToken)) { | 57 | + | // Validate input using Zod schema |
| 58 | - | log.warn('Missing required fields', { | 58 | + | try { |
| 59 | - | hasUsername: Boolean(suUsername), | 59 | + | const registrationSchema = createRegistrationSchema(DEFAULT_PASSWORD_CONSTRAINTS); |
| 60 | - | hasPassword: Boolean(suPassword), | 60 | + | registrationSchema.parse({ |
| 61 | - | hasConfirmPassword: Boolean(suConfirmPassword), | 61 | + | suUsername, |
| 62 | - | hasCaptcha: Boolean(captchaAnswer), | 62 | + | suPassword, |
| 63 | - | hasTurnstileToken: Boolean(turnstileToken), | 63 | + | suConfirmPassword, |
| 64 | - | isDev | 64 | + | captchaAnswer, |
| 65 | + | turnstileToken | |||
| 65 | }); | 66 | }); | ||
| 66 | - | return new Response(JSON.stringify({ error: 'All fields are required' } as RegisterResponseError), { status: 400 }); | 67 | + | } catch (err: any) { |
| 68 | + | const errorMessage = err.errors?.[0]?.message || 'Invalid input data'; | |||
| 69 | + | log.warn('Registration validation failed', { error: errorMessage }); | |||
| 70 | + | return new Response(JSON.stringify({ error: errorMessage } as RegisterResponseError), { status: 400 }); | |||
| 67 | } | 71 | } | ||
| 68 | 72 | ||||
| 69 | - | // Validate Turnstile token (will be automatically bypassed in dev mode) | |||
| 70 | - | const isValidTurnstile = await validateTurnstileToken(turnstileToken || '', ip); | |||
| 71 | - | if (!isValidTurnstile) { | |||
| 72 | - | log.warn('Invalid Turnstile token', { maskedIp }); | |||
| 73 | - | return new Response(JSON.stringify({ error: 'Security check failed. Please try again.' } as RegisterResponseError), { status: 400 }); | |||
| 74 | - | } | |||
| 75 | 73 | ||||
| 76 | // Validate PDR captcha | 74 | // Validate PDR captcha | ||
| 77 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | 75 | const normalizedAnswer = captchaAnswer.trim().toLowerCase(); | ||
| @@ -86,49 +84,9 @@ export const POST: RequestHandler = async ({ request }) => { | |||||
| 86 | // Reset captcha attempts on success | 84 | // Reset captcha attempts on success | ||
| 87 | captchaAttempts.delete(ip); | 85 | captchaAttempts.delete(ip); | ||
| 88 | 86 | ||||
| 89 | - | // Destructure and validate required fields from the payload. | 87 | + | // Trim input values (validation already done by Zod) |
| 90 | - | if (typeof suUsername !== 'string' || typeof suPassword !== 'string' || | |||
| 91 | - | typeof suConfirmPassword !== 'string') { | |||
| 92 | - | log.warn('Missing or invalid input fields'); | |||
| 93 | - | return new Response(JSON.stringify({ error: 'Missing or invalid input fields' } as RegisterResponseError), { status: 400 }); | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | // Trim input values. | |||
| 97 | const username = suUsername.trim(); | 88 | const username = suUsername.trim(); | ||
| 98 | const password = suPassword.trim(); | 89 | const password = suPassword.trim(); | ||
| 99 | - | const confirmPassword = suConfirmPassword.trim(); | |||
| 100 | - | ||||
| 101 | - | // Basic validations. | |||
| 102 | - | if (!username || !password || !confirmPassword) { | |||
| 103 | - | log.warn('All fields must be filled'); | |||
| 104 | - | return new Response(JSON.stringify({ error: 'All fields must be filled' } as RegisterResponseError), { status: 400 }); | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | if (username.length < 3) { | |||
| 108 | - | log.warn('Username must be at least 3 characters'); | |||
| 109 | - | return new Response(JSON.stringify({ error: 'Username must be at least 3 characters' } as RegisterResponseError), { status: 400 }); | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | if (username.length > 32) { | |||
| 113 | - | log.warn('Username must be at most 32 characters'); | |||
| 114 | - | return new Response(JSON.stringify({ error: 'Username must be at most 32 characters' } as RegisterResponseError), { status: 400 }); | |||
| 115 | - | } | |||
| 116 | - | // Only allow letters, numbers, underscores, and dashes in the username. | |||
| 117 | - | const usernameRegex = /^[a-zA-Z0-9_-]+$/; | |||
| 118 | - | if (!usernameRegex.test(username)) { | |||
| 119 | - | log.warn('Invalid username. Only letters, numbers, underscores, and dashes are allowed.'); | |||
| 120 | - | return new Response(JSON.stringify({ error: 'Invalid username. Only letters, numbers, underscores, and dashes are allowed.' } as RegisterResponseError), { status: 400 }); | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | if (password !== confirmPassword) { | |||
| 124 | - | log.warn('Passwords do not match'); | |||
| 125 | - | return new Response(JSON.stringify({ error: 'Passwords do not match' } as RegisterResponseError), { status: 400 }); | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | if (password.length < 8 || password.length > 64) { | |||
| 129 | - | log.warn('Password must be between 8 and 64 characters'); | |||
| 130 | - | return new Response(JSON.stringify({ error: 'Password must be between 8 and 64 characters' } as RegisterResponseError), { status: 400 }); | |||
| 131 | - | } | |||
| 132 | 90 | ||||
| 133 | // Check if nickname already exists | 91 | // Check if nickname already exists | ||
| 134 | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | 92 | const existingUser = await db.select().from(users).where(eq(users.nickname, username)); | ||
M src/routes/api/session/login/+server.ts +11 -48
| @@ -8,10 +8,10 @@ import { generateSessionToken, createSession } from '$lib/api/session.server'; | |||
| 8 | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | |
| 9 | 9 | import { createSafeUser } from '$lib/types/chat'; | |
| 10 | 10 | import { createLogger } from '$lib/utils/logger.server'; | |
| 11 | - | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | |
| 11 | + | import { loginSchema } from '$lib/validation/password'; | |
| 12 | + | import { z } from 'zod'; | |
| 12 | 13 | ||
| 13 | 14 | const log = createLogger('login-server'); | |
| 14 | - | const isDev = process.env.NODE_ENV === 'development'; | |
| 15 | 15 | ||
| 16 | 16 | // In-memory map to track failed login attempts per IP | |
| 17 | 17 | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | |
| @@ -29,14 +29,6 @@ setInterval(() => { | |||
| 29 | 29 | } | |
| 30 | 30 | }, 60 * 60 * 1000); | |
| 31 | 31 | ||
| 32 | - | // Helper to parse clearance cookie from headers | |
| 33 | - | function getClearanceCookie(cookieHeader: string | null): string | null { | |
| 34 | - | if (!cookieHeader) return null; | |
| 35 | - | // Use a case-insensitive regex to match either "cf-clearance" or "cf_clearance" | |
| 36 | - | const regex = /cf[-_]clearance=([^;]+)/i; | |
| 37 | - | const match = cookieHeader.match(regex); | |
| 38 | - | return match ? match[1] : null; | |
| 39 | - | } | |
| 40 | 32 | ||
| 41 | 33 | export const POST: RequestHandler = async ({ request, cookies }) => { | |
| 42 | 34 | log.debug('New login attempt received'); | |
| @@ -72,53 +64,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||
| 72 | 64 | ); | |
| 73 | 65 | } | |
| 74 | 66 | ||
| 75 | - | // Extract cookie header and clearance cookie BEFORE validating input. | |
| 76 | - | const cookieHeader = request.headers.get('cookie'); | |
| 77 | - | const clearanceCookie = getClearanceCookie(cookieHeader); | |
| 78 | - | ||
| 79 | - | // Note: Mark turnstileToken as optional if a valid clearance cookie exists or in dev mode. | |
| 80 | - | const { username, password, turnstileToken } = body as { | |
| 67 | + | const { username, password } = body as { | |
| 81 | 68 | username: string; | |
| 82 | 69 | password: string; | |
| 83 | - | turnstileToken?: string; | |
| 84 | 70 | }; | |
| 85 | 71 | ||
| 86 | - | if ( | |
| 87 | - | typeof username !== 'string' || | |
| 88 | - | typeof password !== 'string' || | |
| 89 | - | (!isDev && !clearanceCookie && typeof turnstileToken !== 'string') | |
| 90 | - | ) { | |
| 91 | - | log.warn('Missing or invalid input fields', { | |
| 92 | - | hasUsername: typeof username === 'string', | |
| 93 | - | hasPassword: typeof password === 'string', | |
| 94 | - | hasTurnstileToken: typeof turnstileToken === 'string', | |
| 95 | - | isDev | |
| 96 | - | }); | |
| 72 | + | // Validate input using Zod schema | |
| 73 | + | try { | |
| 74 | + | loginSchema.parse({ username, password }); | |
| 75 | + | } catch (err) { | |
| 76 | + | const zodError = err as z.ZodError; | |
| 77 | + | const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data'; | |
| 78 | + | log.warn('Login validation failed', { error: errorMessage }); | |
| 97 | 79 | return new Response( | |
| 98 | - | JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), | |
| 80 | + | JSON.stringify({ error: errorMessage } as LoginResponseError), | |
| 99 | 81 | { status: 400 } | |
| 100 | 82 | ); | |
| 101 | 83 | } | |
| 102 | 84 | ||
| 103 | - | // Check for dev mode or clearance cookie before validating Turnstile token. | |
| 104 | - | let isValidTurnstile = false; | |
| 105 | - | if (isDev) { | |
| 106 | - | log.debug('Development mode: bypassing token challenge.'); | |
| 107 | - | isValidTurnstile = true; | |
| 108 | - | } else if (clearanceCookie) { | |
| 109 | - | log.debug('Clearance cookie detected; bypassing token challenge.'); | |
| 110 | - | isValidTurnstile = true; | |
| 111 | - | } else { | |
| 112 | - | isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip); | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | if (!isValidTurnstile) { | |
| 116 | - | log.warn('Invalid Turnstile token', { maskedIp }); | |
| 117 | - | return new Response( | |
| 118 | - | JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), | |
| 119 | - | { status: 400 } | |
| 120 | - | ); | |
| 121 | - | } | |
| 122 | 85 | ||
| 123 | 86 | // Find user by username | |
| 124 | 87 | const user = await db.query.users.findFirst({ | |
| @@ -8,10 +8,10 @@ import { generateSessionToken, createSession } from '$lib/api/session.server'; | |||||
| 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | 8 | import { setSessionTokenCookie } from '$lib/api/session.cookie'; | ||
| 9 | import { createSafeUser } from '$lib/types/chat'; | 9 | import { createSafeUser } from '$lib/types/chat'; | ||
| 10 | import { createLogger } from '$lib/utils/logger.server'; | 10 | import { createLogger } from '$lib/utils/logger.server'; | ||
| 11 | - | import { validateTurnstileToken } from '$lib/utils/turnstile.server'; | 11 | + | import { loginSchema } from '$lib/validation/password'; |
| 12 | + | import { z } from 'zod'; | |||
| 12 | 13 | ||||
| 13 | const log = createLogger('login-server'); | 14 | const log = createLogger('login-server'); | ||
| 14 | - | const isDev = process.env.NODE_ENV === 'development'; | |||
| 15 | 15 | ||||
| 16 | // In-memory map to track failed login attempts per IP | 16 | // In-memory map to track failed login attempts per IP | ||
| 17 | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | 17 | const loginAttempts = new Map<string, { count: number, lastAttempt: number }>(); | ||
| @@ -29,14 +29,6 @@ setInterval(() => { | |||||
| 29 | } | 29 | } | ||
| 30 | }, 60 * 60 * 1000); | 30 | }, 60 * 60 * 1000); | ||
| 31 | 31 | ||||
| 32 | - | // Helper to parse clearance cookie from headers | |||
| 33 | - | function getClearanceCookie(cookieHeader: string | null): string | null { | |||
| 34 | - | if (!cookieHeader) return null; | |||
| 35 | - | // Use a case-insensitive regex to match either "cf-clearance" or "cf_clearance" | |||
| 36 | - | const regex = /cf[-_]clearance=([^;]+)/i; | |||
| 37 | - | const match = cookieHeader.match(regex); | |||
| 38 | - | return match ? match[1] : null; | |||
| 39 | - | } | |||
| 40 | 32 | ||||
| 41 | export const POST: RequestHandler = async ({ request, cookies }) => { | 33 | export const POST: RequestHandler = async ({ request, cookies }) => { | ||
| 42 | log.debug('New login attempt received'); | 34 | log.debug('New login attempt received'); | ||
| @@ -72,53 +64,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => { | |||||
| 72 | ); | 64 | ); | ||
| 73 | } | 65 | } | ||
| 74 | 66 | ||||
| 75 | - | // Extract cookie header and clearance cookie BEFORE validating input. | 67 | + | const { username, password } = body as { |
| 76 | - | const cookieHeader = request.headers.get('cookie'); | |||
| 77 | - | const clearanceCookie = getClearanceCookie(cookieHeader); | |||
| 78 | - | ||||
| 79 | - | // Note: Mark turnstileToken as optional if a valid clearance cookie exists or in dev mode. | |||
| 80 | - | const { username, password, turnstileToken } = body as { | |||
| 81 | username: string; | 68 | username: string; | ||
| 82 | password: string; | 69 | password: string; | ||
| 83 | - | turnstileToken?: string; | |||
| 84 | }; | 70 | }; | ||
| 85 | 71 | ||||
| 86 | - | if ( | 72 | + | // Validate input using Zod schema |
| 87 | - | typeof username !== 'string' || | 73 | + | try { |
| 88 | - | typeof password !== 'string' || | 74 | + | loginSchema.parse({ username, password }); |
| 89 | - | (!isDev && !clearanceCookie && typeof turnstileToken !== 'string') | 75 | + | } catch (err) { |
| 90 | - | ) { | 76 | + | const zodError = err as z.ZodError; |
| 91 | - | log.warn('Missing or invalid input fields', { | 77 | + | const errorMessage = zodError.errors?.[0]?.message || 'Invalid input data'; |
| 92 | - | hasUsername: typeof username === 'string', | 78 | + | log.warn('Login validation failed', { error: errorMessage }); |
| 93 | - | hasPassword: typeof password === 'string', | |||
| 94 | - | hasTurnstileToken: typeof turnstileToken === 'string', | |||
| 95 | - | isDev | |||
| 96 | - | }); | |||
| 97 | return new Response( | 79 | return new Response( | ||
| 98 | - | JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError), | 80 | + | JSON.stringify({ error: errorMessage } as LoginResponseError), |
| 99 | { status: 400 } | 81 | { status: 400 } | ||
| 100 | ); | 82 | ); | ||
| 101 | } | 83 | } | ||
| 102 | 84 | ||||
| 103 | - | // Check for dev mode or clearance cookie before validating Turnstile token. | |||
| 104 | - | let isValidTurnstile = false; | |||
| 105 | - | if (isDev) { | |||
| 106 | - | log.debug('Development mode: bypassing token challenge.'); | |||
| 107 | - | isValidTurnstile = true; | |||
| 108 | - | } else if (clearanceCookie) { | |||
| 109 | - | log.debug('Clearance cookie detected; bypassing token challenge.'); | |||
| 110 | - | isValidTurnstile = true; | |||
| 111 | - | } else { | |||
| 112 | - | isValidTurnstile = await validateTurnstileToken(turnstileToken as string, ip); | |||
| 113 | - | } | |||
| 114 | - | ||||
| 115 | - | if (!isValidTurnstile) { | |||
| 116 | - | log.warn('Invalid Turnstile token', { maskedIp }); | |||
| 117 | - | return new Response( | |||
| 118 | - | JSON.stringify({ error: 'Security check failed. Please try again.' } as LoginResponseError), | |||
| 119 | - | { status: 400 } | |||
| 120 | - | ); | |||
| 121 | - | } | |||
| 122 | 85 | ||||
| 123 | // Find user by username | 86 | // Find user by username | ||
| 124 | const user = await db.query.users.findFirst({ | 87 | const user = await db.query.users.findFirst({ | ||