detached 03de79c

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 UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
8 files · +129 -108
M src/lib/db/schema.ts +19 -0
@@ -47,6 +47,23 @@ export const messages = table(
4747 content: t.text("content").notNull(),
4848 type: t.text("type").$type<MessageType>().notNull().default("chat"),
4949 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(),
5067 }
5168 );
5269
@@ -55,6 +72,7 @@ export type Users = typeof users.$inferSelect;
5572 export type ChatRooms = typeof chatRooms.$inferSelect;
5673 export type Messages = typeof messages.$inferSelect;
5774 export type Sessions = typeof sessions.$inferSelect;
75+export type UserTextPreferences = typeof userTextPreferences.$inferSelect;
5876 // These type assertions will fail if the schema doesn't match the interfaces
5977 export type _UsersValidation = Omit<User, keyof Users> & Omit<Users, keyof User>;
6078 export type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRooms> & Omit<ChatRooms, keyof ChatRoom>;
@@ -100,6 +118,7 @@ const schema = {
100118 sessions,
101119 chatRooms,
102120 messages,
121+ userTextPreferences,
103122 userView,
104123 chatRoomView,
105124 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 interfaces76 // 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 messageView124 messageView
M src/lib/states/chat.svelte.ts +6 -1
@@ -488,7 +488,7 @@ class ChatState {
488488 }
489489 }
490490
491- async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> {
491+ async sendMessage(content: string, type: Message['type'] = 'chat', textStyle?: any): Promise<SendMessageResponse> {
492492 const user = this.getCurrentUser();
493493 if (!user) {
494494 console.debug('Cannot send message: No current user');
@@ -505,6 +505,11 @@ class ChatState {
505505 userId: user.id,
506506 chatRoomId: DEFAULT_CHAT_ROOM_ID
507507 };
508+
509+ // Add styleData if textStyle is provided
510+ if (textStyle) {
511+ (payload as any).styleData = JSON.stringify(textStyle);
512+ }
508513
509514 console.debug('Sending message with payload:', payload);
510515
@@ -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_ID506 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;
2727 content: string;
2828 type: MessageType; // Default is 'chat'
2929 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
3032 }
3133
3234 // 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 sent29 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 information34 // Safe user type for API responses - omits sensitive information
M src/lib/types/payloads.ts +1 -0
@@ -44,6 +44,7 @@ export interface SendMessageRequest {
4444 type?: Message['type'];
4545 userId: string;
4646 chatRoomId?: string;
47+ styleData?: string; // JSON stringified TextStyle
4748 }
4849
4950 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';
1515 import { users } from '$lib/db/schema';
1616 import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
1717 import { createLogger } from '$lib/utils/logger.server';
18+import { sanitizeStyleData } from '$lib/validation/text-formatting';
1819
1920 const log = createLogger('chat-server');
2021
@@ -83,7 +84,12 @@ export async function GET({ request, locals }) {
8384 .from(messages)
8485 .where(conditions)
8586 .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+ })));
8793
8894 const response: GetMessagesResponse = {
8995 success: true,
@@ -182,13 +188,17 @@ export async function POST({ request, locals }: { request: Request, locals: App.
182188 return new Response(JSON.stringify(errorResponse), { status: 404 });
183189 }
184190
191+ const validatedStyleData = sanitizeStyleData(data.styleData);
192+
185193 const newMessage: Message = {
186194 id: uuidv4(),
187195 chatRoomId,
188196 senderId: data.userId,
189197 content: data.content,
190198 type: data.type || 'chat',
191- timestamp: Date.now()
199+ timestamp: Date.now(),
200+ styleData: validatedStyleData,
201+ hasFormatting: Boolean(validatedStyleData)
192202 };
193203
194204 // 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 DB204 // Save to DB
M src/routes/api/register/+server.ts +15 -57
@@ -3,7 +3,7 @@ import type { RegisterResponseSuccess, RegisterResponseError } from '$lib/types/
33 import db from '$lib/db/db.server';
44 import { users } from '$lib/db/schema';
55 import { hashPassword } from '$lib/utils/password';
6-import { validateTurnstileToken } from '$lib/utils/turnstile.server';
6+import { createRegistrationSchema, DEFAULT_PASSWORD_CONSTRAINTS } from '$lib/validation/password';
77 import { createLogger } from '$lib/utils/logger.server';
88 import { eq } from 'drizzle-orm/sql';
99
@@ -54,24 +54,22 @@ export const POST: RequestHandler = async ({ request }) => {
5454 turnstileToken?: string
5555 };
5656
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
6566 });
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 });
6771 }
6872
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- }
7573
7674 // Validate PDR captcha
7775 const normalizedAnswer = captchaAnswer.trim().toLowerCase();
@@ -86,49 +84,9 @@ export const POST: RequestHandler = async ({ request }) => {
8684 // Reset captcha attempts on success
8785 captchaAttempts.delete(ip);
8886
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)
9788 const username = suUsername.trim();
9889 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- }
13290
13391 // Check if nickname already exists
13492 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- isDev64+ 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 captcha74 // 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 success84 // 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 exists91 // 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';
88 import { setSessionTokenCookie } from '$lib/api/session.cookie';
99 import { createSafeUser } from '$lib/types/chat';
1010 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';
1213
1314 const log = createLogger('login-server');
14-const isDev = process.env.NODE_ENV === 'development';
1515
1616 // In-memory map to track failed login attempts per IP
1717 const loginAttempts = new Map<string, { count: number, lastAttempt: number }>();
@@ -29,14 +29,6 @@ setInterval(() => {
2929 }
3030 }, 60 * 60 * 1000);
3131
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-}
4032
4133 export const POST: RequestHandler = async ({ request, cookies }) => {
4234 log.debug('New login attempt received');
@@ -72,53 +64,24 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
7264 );
7365 }
7466
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 {
8168 username: string;
8269 password: string;
83- turnstileToken?: string;
8470 };
8571
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 });
9779 return new Response(
98- JSON.stringify({ error: 'Missing or invalid input fields' } as LoginResponseError),
80+ JSON.stringify({ error: errorMessage } as LoginResponseError),
9981 { status: 400 }
10082 );
10183 }
10284
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- }
12285
12386 // Find user by username
12487 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 IP16 // 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 username86 // Find user by username
124 const user = await db.query.users.findFirst({87 const user = await db.query.users.findFirst({

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close