detached 8884c00

Merge pull request #60 from kitpdr/fix/security-hardening

fix(security): harden application security and UI improvements
commit
8884c003c80225e8fa4c0ea84eb92494ddcc6eca
author
KiT · · 2026-01-18 16:42 UTC
committer
GitHub · · 2026-01-18 16:42 UTC
merge of
4c4546a + 5be4eff · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
10 files · +286 -151
M src/hooks.server.ts +20 -4
@@ -9,6 +9,21 @@ import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/sessio
99
1010 const log = createLogger('hooks-server');
1111
12+// Add security headers to response
13+function addSecurityHeaders(response: Response): Response {
14+ const headers = new Headers(response.headers);
15+ headers.set('X-Frame-Options', 'DENY');
16+ headers.set('X-Content-Type-Options', 'nosniff');
17+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
18+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
19+
20+ return new Response(response.body, {
21+ status: response.status,
22+ statusText: response.statusText,
23+ headers
24+ });
25+}
26+
1227 // Set all users to offline on server start
1328 async function setAllUsersOffline() {
1429 try {
@@ -63,7 +78,7 @@ export const handle: Handle = async ({ event, resolve }) => {
6378 if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {
6479 log.debug('Public request accessed', { path: event.url.pathname });
6580 const response = await resolve(event);
66- return response;
81+ return addSecurityHeaders(response);
6782 }
6883
6984 // Public routes that don't require authentication
@@ -80,12 +95,13 @@ export const handle: Handle = async ({ event, resolve }) => {
8095 if (publicRoutes.includes(event.url.pathname)) {
8196 log.debug('Public route accessed', { path: event.url.pathname });
8297 const response = await resolve(event);
83- return response;
98+ return addSecurityHeaders(response);
8499 }
85100
86101 // Skip rate limiting for SSE endpoint when authenticated
87102 if (event.url.pathname.startsWith('/api/sse')) {
88- return await resolve(event);
103+ const response = await resolve(event);
104+ return addSecurityHeaders(response);
89105 }
90106
91107 // Apply rate limiting for non-public endpoints
@@ -102,5 +118,5 @@ export const handle: Handle = async ({ event, resolve }) => {
102118 }
103119
104120 const response = await resolve(event);
105- return response;
121+ return addSecurityHeaders(response);
106122 };
@@ -9,6 +9,21 @@ import { setSessionTokenCookie, deleteSessionTokenCookie } from '$lib/api/sessio
9 9
10 const log = createLogger('hooks-server');10 const log = createLogger('hooks-server');
11 11
12+// Add security headers to response
13+function addSecurityHeaders(response: Response): Response {
14+ const headers = new Headers(response.headers);
15+ headers.set('X-Frame-Options', 'DENY');
16+ headers.set('X-Content-Type-Options', 'nosniff');
17+ headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
18+ headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
19+
20+ return new Response(response.body, {
21+ status: response.status,
22+ statusText: response.statusText,
23+ headers
24+ });
25+}
26+
12 // Set all users to offline on server start27 // Set all users to offline on server start
13 async function setAllUsersOffline() {28 async function setAllUsersOffline() {
14 try {29 try {
@@ -63,7 +78,7 @@ export const handle: Handle = async ({ event, resolve }) => {
63 if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {78 if ((isPublicChatRequest || isPublicRoomRequest) && !event.locals.session) {
64 log.debug('Public request accessed', { path: event.url.pathname });79 log.debug('Public request accessed', { path: event.url.pathname });
65 const response = await resolve(event);80 const response = await resolve(event);
66- return response;81+ return addSecurityHeaders(response);
67 }82 }
68 83
69 // Public routes that don't require authentication84 // Public routes that don't require authentication
@@ -80,12 +95,13 @@ export const handle: Handle = async ({ event, resolve }) => {
80 if (publicRoutes.includes(event.url.pathname)) {95 if (publicRoutes.includes(event.url.pathname)) {
81 log.debug('Public route accessed', { path: event.url.pathname });96 log.debug('Public route accessed', { path: event.url.pathname });
82 const response = await resolve(event);97 const response = await resolve(event);
83- return response;98+ return addSecurityHeaders(response);
84 }99 }
85 100
86 // Skip rate limiting for SSE endpoint when authenticated101 // Skip rate limiting for SSE endpoint when authenticated
87 if (event.url.pathname.startsWith('/api/sse')) {102 if (event.url.pathname.startsWith('/api/sse')) {
88- return await resolve(event);103+ const response = await resolve(event);
104+ return addSecurityHeaders(response);
89 }105 }
90 106
91 // Apply rate limiting for non-public endpoints107 // Apply rate limiting for non-public endpoints
@@ -102,5 +118,5 @@ export const handle: Handle = async ({ event, resolve }) => {
102 }118 }
103 119
104 const response = await resolve(event);120 const response = await resolve(event);
105- return response;121+ return addSecurityHeaders(response);
106 };122 };
M src/lib/api/rate-limiter.ts +51 -35
@@ -71,6 +71,18 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
7171 return 'protected';
7272 }
7373
74+function getRateLimitPoints(
75+ endpointType: keyof typeof RATE_LIMITS,
76+ isAuthenticated: boolean
77+): number {
78+ if (endpointType === 'sse') {
79+ return isAuthenticated
80+ ? RATE_LIMITS.sse.authenticated.points
81+ : RATE_LIMITS.sse.unauthenticated.points;
82+ }
83+ return RATE_LIMITS[endpointType].points;
84+}
85+
7486 function isRateLimited(
7587 ip: string,
7688 endpointType: keyof typeof RATE_LIMITS,
@@ -149,17 +161,6 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
149161 const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
150162
151163 if (limited) {
152- // Create debug headers
153- const debugHeaders = {
154- 'X-Debug-Is-Authenticated': isAuthenticated.toString(),
155- 'X-Debug-Has-User': Boolean(event.locals.user).toString(),
156- 'X-Debug-Has-Session': Boolean(event.locals.session).toString(),
157- 'X-Debug-Endpoint-Type': endpointType,
158- 'X-Debug-Rate-Limit-Points': isAuthenticated
159- ? RATE_LIMITS.sse.authenticated.points.toString()
160- : RATE_LIMITS.sse.unauthenticated.points.toString()
161- };
162-
163164 // Construct a user-friendly error message
164165 let errorMessage = 'Too many requests. ';
165166 if (endpointType === 'sse') {
@@ -172,31 +173,46 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
172173 errorMessage += 'Please try again later.';
173174 }
174175
175- return new Response(
176- JSON.stringify({
177- error: errorMessage,
178- retryAfter: Math.ceil((retryAfter || 0) / 1000),
179- endpointType,
176+ // Build response body - only include debug info in development
177+ const responseBody: Record<string, unknown> = {
178+ error: errorMessage,
179+ retryAfter: Math.ceil((retryAfter || 0) / 1000)
180+ };
181+
182+ // Only include potentially sensitive info in dev mode
183+ if (isDev) {
184+ responseBody.endpointType = endpointType;
185+ responseBody.isAuthenticated = isAuthenticated;
186+ responseBody.debug = {
180187 isAuthenticated,
181- debug: {
182- isAuthenticated,
183- hasUser: Boolean(event.locals.user),
184- hasSession: Boolean(event.locals.session),
185- endpointType,
186- rateLimitPoints: isAuthenticated
187- ? RATE_LIMITS.sse.authenticated.points
188- : RATE_LIMITS.sse.unauthenticated.points
189- }
190- }),
191- {
192- status: 429,
193- headers: {
194- 'Content-Type': 'application/json',
195- 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(),
196- ...debugHeaders
197- }
198- }
199- );
188+ hasUser: Boolean(event.locals.user),
189+ hasSession: Boolean(event.locals.session),
190+ endpointType,
191+ rateLimitPoints: getRateLimitPoints(endpointType, isAuthenticated)
192+ };
193+ }
194+
195+ // Build headers - only include debug headers in development
196+ const headers: Record<string, string> = {
197+ 'Content-Type': 'application/json',
198+ 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString()
199+ };
200+
201+ if (isDev) {
202+ headers['X-Debug-Is-Authenticated'] = isAuthenticated.toString();
203+ headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();
204+ headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();
205+ headers['X-Debug-Endpoint-Type'] = endpointType;
206+ headers['X-Debug-Rate-Limit-Points'] = getRateLimitPoints(
207+ endpointType,
208+ isAuthenticated
209+ ).toString();
210+ }
211+
212+ return new Response(JSON.stringify(responseBody), {
213+ status: 429,
214+ headers
215+ });
200216 }
201217
202218 return null;
@@ -71,6 +71,18 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE
71 return 'protected';71 return 'protected';
72 }72 }
73 73
74+function getRateLimitPoints(
75+ endpointType: keyof typeof RATE_LIMITS,
76+ isAuthenticated: boolean
77+): number {
78+ if (endpointType === 'sse') {
79+ return isAuthenticated
80+ ? RATE_LIMITS.sse.authenticated.points
81+ : RATE_LIMITS.sse.unauthenticated.points;
82+ }
83+ return RATE_LIMITS[endpointType].points;
84+}
85+
74 function isRateLimited(86 function isRateLimited(
75 ip: string,87 ip: string,
76 endpointType: keyof typeof RATE_LIMITS,88 endpointType: keyof typeof RATE_LIMITS,
@@ -149,17 +161,6 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
149 const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);161 const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated);
150 162
151 if (limited) {163 if (limited) {
152- // Create debug headers
153- const debugHeaders = {
154- 'X-Debug-Is-Authenticated': isAuthenticated.toString(),
155- 'X-Debug-Has-User': Boolean(event.locals.user).toString(),
156- 'X-Debug-Has-Session': Boolean(event.locals.session).toString(),
157- 'X-Debug-Endpoint-Type': endpointType,
158- 'X-Debug-Rate-Limit-Points': isAuthenticated
159- ? RATE_LIMITS.sse.authenticated.points.toString()
160- : RATE_LIMITS.sse.unauthenticated.points.toString()
161- };
162-
163 // Construct a user-friendly error message164 // Construct a user-friendly error message
164 let errorMessage = 'Too many requests. ';165 let errorMessage = 'Too many requests. ';
165 if (endpointType === 'sse') {166 if (endpointType === 'sse') {
@@ -172,31 +173,46 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n
172 errorMessage += 'Please try again later.';173 errorMessage += 'Please try again later.';
173 }174 }
174 175
175- return new Response(176+ // Build response body - only include debug info in development
176- JSON.stringify({177+ const responseBody: Record<string, unknown> = {
177- error: errorMessage,178+ error: errorMessage,
178- retryAfter: Math.ceil((retryAfter || 0) / 1000),179+ retryAfter: Math.ceil((retryAfter || 0) / 1000)
179- endpointType,180+ };
181+
182+ // Only include potentially sensitive info in dev mode
183+ if (isDev) {
184+ responseBody.endpointType = endpointType;
185+ responseBody.isAuthenticated = isAuthenticated;
186+ responseBody.debug = {
180 isAuthenticated,187 isAuthenticated,
181- debug: {188+ hasUser: Boolean(event.locals.user),
182- isAuthenticated,189+ hasSession: Boolean(event.locals.session),
183- hasUser: Boolean(event.locals.user),190+ endpointType,
184- hasSession: Boolean(event.locals.session),191+ rateLimitPoints: getRateLimitPoints(endpointType, isAuthenticated)
185- endpointType,192+ };
186- rateLimitPoints: isAuthenticated193+ }
187- ? RATE_LIMITS.sse.authenticated.points194+
188- : RATE_LIMITS.sse.unauthenticated.points195+ // Build headers - only include debug headers in development
189- }196+ const headers: Record<string, string> = {
190- }),197+ 'Content-Type': 'application/json',
191- {198+ 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString()
192- status: 429,199+ };
193- headers: {200+
194- 'Content-Type': 'application/json',201+ if (isDev) {
195- 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString(),202+ headers['X-Debug-Is-Authenticated'] = isAuthenticated.toString();
196- ...debugHeaders203+ headers['X-Debug-Has-User'] = Boolean(event.locals.user).toString();
197- }204+ headers['X-Debug-Has-Session'] = Boolean(event.locals.session).toString();
198- }205+ headers['X-Debug-Endpoint-Type'] = endpointType;
199- );206+ headers['X-Debug-Rate-Limit-Points'] = getRateLimitPoints(
207+ endpointType,
208+ isAuthenticated
209+ ).toString();
210+ }
211+
212+ return new Response(JSON.stringify(responseBody), {
213+ status: 429,
214+ headers
215+ });
200 }216 }
201 217
202 return null;218 return null;
M src/lib/api/session.cookie.ts +12 -4
@@ -5,6 +5,13 @@ const log = createLogger('session-cookie');
55
66 // Helper function to determine cookie domain
77 function getCookieDomain(): string | undefined {
8+ // Allow explicit override via environment variable
9+ const envDomain = process.env.COOKIE_DOMAIN;
10+ if (envDomain) {
11+ log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain });
12+ return envDomain;
13+ }
14+
815 if (process.env.NODE_ENV === 'production') {
916 // Check for Cloudflare Pages domain
1017 if (process.env.CF_PAGES_URL) {
@@ -14,11 +21,12 @@ function getCookieDomain(): string | undefined {
1421 if (process.env.CF_PAGES_BRANCH !== 'main') {
1522 return undefined;
1623 }
17- // For production, use the main domain
18- return '.pdraim.pages.dev';
24+ // For production on Cloudflare Pages, use host-only cookie (safest default)
25+ // Set COOKIE_DOMAIN explicitly if cross-subdomain cookies are needed
26+ return undefined;
1927 }
20- // Fallback for custom domain
21- return '.pdraim.org';
28+ // Production without explicit domain - use host-only cookie (safest default)
29+ return undefined;
2230 }
2331 return undefined;
2432 }
@@ -5,6 +5,13 @@ const log = createLogger('session-cookie');
5 5
6 // Helper function to determine cookie domain6 // Helper function to determine cookie domain
7 function getCookieDomain(): string | undefined {7 function getCookieDomain(): string | undefined {
8+ // Allow explicit override via environment variable
9+ const envDomain = process.env.COOKIE_DOMAIN;
10+ if (envDomain) {
11+ log.debug('Using COOKIE_DOMAIN from environment:', { domain: envDomain });
12+ return envDomain;
13+ }
14+
8 if (process.env.NODE_ENV === 'production') {15 if (process.env.NODE_ENV === 'production') {
9 // Check for Cloudflare Pages domain16 // Check for Cloudflare Pages domain
10 if (process.env.CF_PAGES_URL) {17 if (process.env.CF_PAGES_URL) {
@@ -14,11 +21,12 @@ function getCookieDomain(): string | undefined {
14 if (process.env.CF_PAGES_BRANCH !== 'main') {21 if (process.env.CF_PAGES_BRANCH !== 'main') {
15 return undefined;22 return undefined;
16 }23 }
17- // For production, use the main domain24+ // For production on Cloudflare Pages, use host-only cookie (safest default)
18- return '.pdraim.pages.dev';25+ // Set COOKIE_DOMAIN explicitly if cross-subdomain cookies are needed
26+ return undefined;
19 }27 }
20- // Fallback for custom domain28+ // Production without explicit domain - use host-only cookie (safest default)
21- return '.pdraim.org';29+ return undefined;
22 }30 }
23 return undefined;31 return undefined;
24 }32 }
M src/lib/components/chat-room.svelte +3 -1
@@ -816,7 +816,9 @@
816816
817817 <!-- Offline users section -->
818818 {#if usersOffline.length > 0}
819- <div class="section-separator"></div>
819+ {#if usersOnline.length > 0}
820+ <div class="section-separator"></div>
821+ {/if}
820822 <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>
821823 {#each usersOffline as user (user.id)}
822824 <div class="user offline">
@@ -816,7 +816,9 @@
816 816
817 <!-- Offline users section -->817 <!-- Offline users section -->
818 {#if usersOffline.length > 0}818 {#if usersOffline.length > 0}
819- <div class="section-separator"></div>819+ {#if usersOnline.length > 0}
820+ <div class="section-separator"></div>
821+ {/if}
820 <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>822 <p class="section-header offline-header">Hors ligne ({usersOffline.length})</p>
821 {#each usersOffline as user (user.id)}823 {#each usersOffline as user (user.id)}
822 <div class="user offline">824 <div class="user offline">
M src/lib/components/formatted-message.svelte +1 -16
@@ -1,7 +1,7 @@
11 <script lang="ts">
22 import type { EnrichedMessage } from '../types/chat';
33 import type { TextStyle } from '../types/text-formatting';
4- import { formatText, createGradientText } from '../utils/text-formatter';
4+ import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
55 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
66
77 // Props
@@ -31,7 +31,6 @@
3131 if (message.styleData) {
3232 try {
3333 style = JSON.parse(message.styleData);
34- console.debug('Parsed message style:', style, 'for message:', message.content);
3534 } catch (parseError) {
3635 console.warn('Failed to parse message style data:', parseError);
3736 }
@@ -40,19 +39,12 @@
4039 // Check if this is a gradient message
4140 if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
4241 // Use character-by-character gradient for better compatibility
43- console.debug(
44- 'Creating gradient text with colors:',
45- style.gradient,
46- 'for text:',
47- message.content
48- );
4942 formattedContent = createGradientText(
5043 message.content,
5144 style.gradient,
5245 'gradient-text-static',
5346 style
5447 );
55- console.debug('Generated gradient HTML:', formattedContent);
5648 return;
5749 }
5850
@@ -73,13 +65,6 @@
7365 processMessage();
7466 });
7567
76- // Utility function to escape HTML
77- function escapeHtml(text: string): string {
78- const div = document.createElement('div');
79- div.textContent = text;
80- return div.innerHTML;
81- }
82-
8368 // Generate inline styles for the message
8469 function getMessageStyles(): string {
8570 if (!message.hasFormatting || !message.styleData) {
@@ -1,7 +1,7 @@
1 <script lang="ts">1 <script lang="ts">
2 import type { EnrichedMessage } from '../types/chat';2 import type { EnrichedMessage } from '../types/chat';
3 import type { TextStyle } from '../types/text-formatting';3 import type { TextStyle } from '../types/text-formatting';
4- import { formatText, createGradientText } from '../utils/text-formatter';4+ import { formatText, createGradientText, escapeHtml } from '../utils/text-formatter';
5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';5 import { DEFAULT_TEXT_STYLE } from '../types/text-formatting';
6 6
7 // Props7 // Props
@@ -31,7 +31,6 @@
31 if (message.styleData) {31 if (message.styleData) {
32 try {32 try {
33 style = JSON.parse(message.styleData);33 style = JSON.parse(message.styleData);
34- console.debug('Parsed message style:', style, 'for message:', message.content);
35 } catch (parseError) {34 } catch (parseError) {
36 console.warn('Failed to parse message style data:', parseError);35 console.warn('Failed to parse message style data:', parseError);
37 }36 }
@@ -40,19 +39,12 @@
40 // Check if this is a gradient message39 // Check if this is a gradient message
41 if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {40 if (style.gradient && Array.isArray(style.gradient) && style.gradient.length > 1) {
42 // Use character-by-character gradient for better compatibility41 // Use character-by-character gradient for better compatibility
43- console.debug(
44- 'Creating gradient text with colors:',
45- style.gradient,
46- 'for text:',
47- message.content
48- );
49 formattedContent = createGradientText(42 formattedContent = createGradientText(
50 message.content,43 message.content,
51 style.gradient,44 style.gradient,
52 'gradient-text-static',45 'gradient-text-static',
53 style46 style
54 );47 );
55- console.debug('Generated gradient HTML:', formattedContent);
56 return;48 return;
57 }49 }
58 50
@@ -73,13 +65,6 @@
73 processMessage();65 processMessage();
74 });66 });
75 67
76- // Utility function to escape HTML
77- function escapeHtml(text: string): string {
78- const div = document.createElement('div');
79- div.textContent = text;
80- return div.innerHTML;
81- }
82-
83 // Generate inline styles for the message68 // Generate inline styles for the message
84 function getMessageStyles(): string {69 function getMessageStyles(): string {
85 if (!message.hasFormatting || !message.styleData) {70 if (!message.hasFormatting || !message.styleData) {
M src/lib/utils/password.ts +0 -5
@@ -32,7 +32,6 @@ function hexToBuffer(hex: string): Uint8Array {
3232 async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {
3333 const enc = new TextEncoder();
3434 const keyMaterial = enc.encode(password);
35- console.debug('$state: Key material for password derivation', keyMaterial);
3635 const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [
3736 'deriveBits'
3837 ]);
@@ -46,7 +45,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
4645 key,
4746 512
4847 );
49- console.debug('$state: Derived bits', derivedBits);
5048 return bufferToHex(derivedBits);
5149 }
5250
@@ -57,7 +55,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
5755 */
5856 export async function hashPassword(password: string): Promise<string> {
5957 const salt = crypto.getRandomValues(new Uint8Array(16));
60- console.debug('$state: Generated salt', salt);
6158 const saltHex = bufferToHex(salt.buffer);
6259 const hash = await derivePasswordHash(password, salt);
6360 return `${saltHex}:${hash}`;
@@ -72,8 +69,6 @@ export async function hashPassword(password: string): Promise<string> {
7269 export async function verifyPassword(password: string, hash: string): Promise<boolean> {
7370 const [saltHex, hashValue] = hash.split(':');
7471 const salt = hexToBuffer(saltHex);
75- console.debug('$state: Salt extracted from stored hash', salt);
7672 const derivedHash = await derivePasswordHash(password, salt);
77- console.debug('$state: Derived hash for verification', derivedHash);
7873 return derivedHash === hashValue;
7974 }
@@ -32,7 +32,6 @@ function hexToBuffer(hex: string): Uint8Array {
32 async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {32 async function derivePasswordHash(password: string, salt: Uint8Array): Promise<string> {
33 const enc = new TextEncoder();33 const enc = new TextEncoder();
34 const keyMaterial = enc.encode(password);34 const keyMaterial = enc.encode(password);
35- console.debug('$state: Key material for password derivation', keyMaterial);
36 const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [35 const key = await crypto.subtle.importKey('raw', keyMaterial, { name: 'PBKDF2' }, false, [
37 'deriveBits'36 'deriveBits'
38 ]);37 ]);
@@ -46,7 +45,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
46 key,45 key,
47 51246 512
48 );47 );
49- console.debug('$state: Derived bits', derivedBits);
50 return bufferToHex(derivedBits);48 return bufferToHex(derivedBits);
51 }49 }
52 50
@@ -57,7 +55,6 @@ async function derivePasswordHash(password: string, salt: Uint8Array): Promise<s
57 */55 */
58 export async function hashPassword(password: string): Promise<string> {56 export async function hashPassword(password: string): Promise<string> {
59 const salt = crypto.getRandomValues(new Uint8Array(16));57 const salt = crypto.getRandomValues(new Uint8Array(16));
60- console.debug('$state: Generated salt', salt);
61 const saltHex = bufferToHex(salt.buffer);58 const saltHex = bufferToHex(salt.buffer);
62 const hash = await derivePasswordHash(password, salt);59 const hash = await derivePasswordHash(password, salt);
63 return `${saltHex}:${hash}`;60 return `${saltHex}:${hash}`;
@@ -72,8 +69,6 @@ export async function hashPassword(password: string): Promise<string> {
72 export async function verifyPassword(password: string, hash: string): Promise<boolean> {69 export async function verifyPassword(password: string, hash: string): Promise<boolean> {
73 const [saltHex, hashValue] = hash.split(':');70 const [saltHex, hashValue] = hash.split(':');
74 const salt = hexToBuffer(saltHex);71 const salt = hexToBuffer(saltHex);
75- console.debug('$state: Salt extracted from stored hash', salt);
76 const derivedHash = await derivePasswordHash(password, salt);72 const derivedHash = await derivePasswordHash(password, salt);
77- console.debug('$state: Derived hash for verification', derivedHash);
78 return derivedHash === hashValue;73 return derivedHash === hashValue;
79 }74 }
M src/lib/utils/text-formatter.ts +46 -30
@@ -5,6 +5,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
55 import rehypeStringify from 'rehype-stringify';
66 import type { TextStyle } from '../types/text-formatting';
77 import { validateTextStyle } from '../types/text-formatting';
8+import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode';
89
910 // Define allowed HTML tags and attributes for security
1011 const ALLOWED_SCHEMA = {
@@ -25,8 +26,8 @@ const ALLOWED_SCHEMA = {
2526 }
2627 };
2728
28-// BBCode-style formatting patterns
29-const BB_CODE_PATTERNS: Array<{
29+// BBCode-style formatting patterns (simple ones with static replacement)
30+const BB_CODE_SIMPLE_PATTERNS: Array<{
3031 pattern: RegExp;
3132 replacement: string;
3233 }> = [
@@ -40,26 +41,39 @@ const BB_CODE_PATTERNS: Array<{
4041 { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
4142
4243 // Strikethrough
43- { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },
44+ { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }
45+];
4446
45- // Color
46- {
47- pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi,
48- replacement: '<span style="color: $1">$2</span>'
49- },
47+// BBCode patterns that require validation (using Zod schemas)
48+function processBBCodeWithValidation(text: string): string {
49+ let result = text;
5050
51- // Size
52- {
53- pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi,
54- replacement: '<span style="font-size: $1px">$2</span>'
55- },
51+ // Color - validate hex or named colors using Zod schema
52+ result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => {
53+ if (isValidBBCodeColor(color)) {
54+ return `<span style="color: ${color}">${content}</span>`;
55+ }
56+ return content; // Strip invalid color, keep content
57+ });
5658
57- // Font
58- {
59- pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi,
60- replacement: '<span style="font-family: $1">$2</span>'
61- }
62-];
59+ // Size - validate range 8-24px using Zod schema
60+ result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => {
61+ if (isValidBBCodeFontSize(size)) {
62+ return `<span style="font-size: ${size}px">${content}</span>`;
63+ }
64+ return content; // Strip invalid size, keep content
65+ });
66+
67+ // Font - validate against allowed fonts using Zod schema
68+ result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => {
69+ if (isValidBBCodeFont(font)) {
70+ return `<span style="font-family: ${font}">${content}</span>`;
71+ }
72+ return content; // Strip invalid font, keep content
73+ });
74+
75+ return result;
76+}
6377
6478 // Markdown-style formatting patterns
6579 const MARKDOWN_PATTERNS: Array<{
@@ -85,11 +99,14 @@ const MARKDOWN_PATTERNS: Array<{
8599 function preprocessFormatting(text: string): string {
86100 let processedText = text;
87101
88- // Apply BBCode patterns first
89- BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => {
102+ // Apply simple BBCode patterns first (bold, italic, underline, strikethrough)
103+ BB_CODE_SIMPLE_PATTERNS.forEach(({ pattern, replacement }) => {
90104 processedText = processedText.replace(pattern, replacement);
91105 });
92106
107+ // Apply BBCode patterns that require validation (color, size, font)
108+ processedText = processBBCodeWithValidation(processedText);
109+
93110 // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
94111 MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
95112 // Only apply if not already inside HTML tags
@@ -176,11 +193,14 @@ export async function formatText(
176193 }
177194 }
178195
179-// Utility function to escape HTML
180-function escapeHtml(text: string): string {
181- const div = document.createElement('div');
182- div.textContent = text;
183- return div.innerHTML;
196+// Utility function to escape HTML (isomorphic - works in SSR and browser)
197+export function escapeHtml(text: string): string {
198+ return text
199+ .replace(/&/g, '&amp;')
200+ .replace(/</g, '&lt;')
201+ .replace(/>/g, '&gt;')
202+ .replace(/"/g, '&quot;')
203+ .replace(/'/g, '&#039;');
184204 }
185205
186206 // Generate CSS style string excluding font-family (which should be handled by classes)
@@ -223,13 +243,10 @@ export function createGradientText(
223243 className: string = 'gradient-text',
224244 style?: TextStyle
225245 ): string {
226- console.debug('createGradientText called with:', { text, colors, className, style });
227-
228246 if (colors.length < 2) {
229247 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
230248 const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';
231249 const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
232- console.debug('Single color result:', result);
233250 return result;
234251 }
235252
@@ -294,7 +311,6 @@ export function createGradientText(
294311 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })
295312 : '';
296313 const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`;
297- console.debug('Final gradient HTML:', result);
298314 return result;
299315 }
300316
@@ -5,6 +5,7 @@ import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
5 import rehypeStringify from 'rehype-stringify';5 import rehypeStringify from 'rehype-stringify';
6 import type { TextStyle } from '../types/text-formatting';6 import type { TextStyle } from '../types/text-formatting';
7 import { validateTextStyle } from '../types/text-formatting';7 import { validateTextStyle } from '../types/text-formatting';
8+import { isValidBBCodeColor, isValidBBCodeFontSize, isValidBBCodeFont } from '../validation/bbcode';
8 9
9 // Define allowed HTML tags and attributes for security10 // Define allowed HTML tags and attributes for security
10 const ALLOWED_SCHEMA = {11 const ALLOWED_SCHEMA = {
@@ -25,8 +26,8 @@ const ALLOWED_SCHEMA = {
25 }26 }
26 };27 };
27 28
28-// BBCode-style formatting patterns29+// BBCode-style formatting patterns (simple ones with static replacement)
29-const BB_CODE_PATTERNS: Array<{30+const BB_CODE_SIMPLE_PATTERNS: Array<{
30 pattern: RegExp;31 pattern: RegExp;
31 replacement: string;32 replacement: string;
32 }> = [33 }> = [
@@ -40,26 +41,39 @@ const BB_CODE_PATTERNS: Array<{
40 { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },41 { pattern: /\[u\](.*?)\[\/u\]/gi, replacement: '<u>$1</u>' },
41 42
42 // Strikethrough43 // Strikethrough
43- { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' },44+ { pattern: /\[s\](.*?)\[\/s\]/gi, replacement: '<s>$1</s>' }
45+];
44 46
45- // Color47+// BBCode patterns that require validation (using Zod schemas)
46- {48+function processBBCodeWithValidation(text: string): string {
47- pattern: /\[color=([#\w]+)\](.*?)\[\/color\]/gi,49+ let result = text;
48- replacement: '<span style="color: $1">$2</span>'
49- },
50 50
51- // Size51+ // Color - validate hex or named colors using Zod schema
52- {52+ result = result.replace(/\[color=([#\w]+)\](.*?)\[\/color\]/gi, (match, color, content) => {
53- pattern: /\[size=(\d+)\](.*?)\[\/size\]/gi,53+ if (isValidBBCodeColor(color)) {
54- replacement: '<span style="font-size: $1px">$2</span>'54+ return `<span style="color: ${color}">${content}</span>`;
55- },55+ }
56+ return content; // Strip invalid color, keep content
57+ });
56 58
57- // Font59+ // Size - validate range 8-24px using Zod schema
58- {60+ result = result.replace(/\[size=(\d+)\](.*?)\[\/size\]/gi, (match, size, content) => {
59- pattern: /\[font=([^[\]]+)\](.*?)\[\/font\]/gi,61+ if (isValidBBCodeFontSize(size)) {
60- replacement: '<span style="font-family: $1">$2</span>'62+ return `<span style="font-size: ${size}px">${content}</span>`;
61- }63+ }
62-];64+ return content; // Strip invalid size, keep content
65+ });
66+
67+ // Font - validate against allowed fonts using Zod schema
68+ result = result.replace(/\[font=([^[\]]+)\](.*?)\[\/font\]/gi, (match, font, content) => {
69+ if (isValidBBCodeFont(font)) {
70+ return `<span style="font-family: ${font}">${content}</span>`;
71+ }
72+ return content; // Strip invalid font, keep content
73+ });
74+
75+ return result;
76+}
63 77
64 // Markdown-style formatting patterns78 // Markdown-style formatting patterns
65 const MARKDOWN_PATTERNS: Array<{79 const MARKDOWN_PATTERNS: Array<{
@@ -85,11 +99,14 @@ const MARKDOWN_PATTERNS: Array<{
85 function preprocessFormatting(text: string): string {99 function preprocessFormatting(text: string): string {
86 let processedText = text;100 let processedText = text;
87 101
88- // Apply BBCode patterns first102+ // Apply simple BBCode patterns first (bold, italic, underline, strikethrough)
89- BB_CODE_PATTERNS.forEach(({ pattern, replacement }) => {103+ BB_CODE_SIMPLE_PATTERNS.forEach(({ pattern, replacement }) => {
90 processedText = processedText.replace(pattern, replacement);104 processedText = processedText.replace(pattern, replacement);
91 });105 });
92 106
107+ // Apply BBCode patterns that require validation (color, size, font)
108+ processedText = processBBCodeWithValidation(processedText);
109+
93 // Apply Markdown patterns (but avoid conflicts with already processed BBCode)110 // Apply Markdown patterns (but avoid conflicts with already processed BBCode)
94 MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {111 MARKDOWN_PATTERNS.forEach(({ pattern, replacement }) => {
95 // Only apply if not already inside HTML tags112 // Only apply if not already inside HTML tags
@@ -176,11 +193,14 @@ export async function formatText(
176 }193 }
177 }194 }
178 195
179-// Utility function to escape HTML196+// Utility function to escape HTML (isomorphic - works in SSR and browser)
180-function escapeHtml(text: string): string {197+export function escapeHtml(text: string): string {
181- const div = document.createElement('div');198+ return text
182- div.textContent = text;199+ .replace(/&/g, '&amp;')
183- return div.innerHTML;200+ .replace(/</g, '&lt;')
201+ .replace(/>/g, '&gt;')
202+ .replace(/"/g, '&quot;')
203+ .replace(/'/g, '&#039;');
184 }204 }
185 205
186 // Generate CSS style string excluding font-family (which should be handled by classes)206 // Generate CSS style string excluding font-family (which should be handled by classes)
@@ -223,13 +243,10 @@ export function createGradientText(
223 className: string = 'gradient-text',243 className: string = 'gradient-text',
224 style?: TextStyle244 style?: TextStyle
225 ): string {245 ): string {
226- console.debug('createGradientText called with:', { text, colors, className, style });
227-
228 if (colors.length < 2) {246 if (colors.length < 2) {
229 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';247 const fontClass = style?.fontFamily ? ` retro-font-${style.fontFamily}` : '';
230 const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';248 const inlineStyle = style ? generateCSSStyleWithoutFont({ ...style, gradient: undefined }) : '';
231 const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;249 const result = `<span class="${className}${fontClass}" style="${inlineStyle}">${escapeHtml(text)}</span>`;
232- console.debug('Single color result:', result);
233 return result;250 return result;
234 }251 }
235 252
@@ -294,7 +311,6 @@ export function createGradientText(
294 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })311 ? generateCSSStyleWithoutFont({ ...style, gradient: undefined, color: undefined })
295 : '';312 : '';
296 const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`;313 const result = `<span class="${className}${fontClass}" style="display: inline; ${containerStyle}">${gradientSpans.join('')}</span>`;
297- console.debug('Final gradient HTML:', result);
298 return result;314 return result;
299 }315 }
300 316
A src/lib/validation/bbcode.ts +85 -0
@@ -0,0 +1,85 @@
1+import { z } from 'zod';
2+import { RETRO_FONTS } from '$lib/types/text-formatting';
3+
4+// Build allowed fonts Set from RETRO_FONTS - includes both keys and display names
5+const ALLOWED_FONTS_SET = new Set<string>();
6+for (const [key, value] of Object.entries(RETRO_FONTS)) {
7+ // Add the key (e.g., 'comicsans')
8+ ALLOWED_FONTS_SET.add(key.toLowerCase());
9+ // Add the display name (e.g., 'comic sans ms')
10+ ALLOWED_FONTS_SET.add(value.name.toLowerCase());
11+}
12+
13+// BBCode constraints
14+export const BBCODE_CONSTRAINTS = {
15+ minFontSize: 8,
16+ maxFontSize: 24,
17+ namedColors: [
18+ 'red',
19+ 'blue',
20+ 'green',
21+ 'yellow',
22+ 'orange',
23+ 'purple',
24+ 'pink',
25+ 'black',
26+ 'white',
27+ 'gray',
28+ 'grey',
29+ 'brown',
30+ 'cyan',
31+ 'magenta',
32+ 'lime',
33+ 'navy',
34+ 'teal',
35+ 'maroon'
36+ ] as const
37+};
38+
39+// Hex color schema - validates 6-digit hex colors
40+const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format');
41+
42+// Named color schema - validates against predefined list
43+const namedColorSchema = z.enum(BBCODE_CONSTRAINTS.namedColors);
44+
45+// Combined color schema - accepts either hex or named color
46+export const bbcodeColorSchema = z.union([hexColorSchema, namedColorSchema]);
47+
48+// Font size schema - validates range 8-24
49+export const bbcodeFontSizeSchema = z.coerce
50+ .number()
51+ .int('Font size must be an integer')
52+ .min(
53+ BBCODE_CONSTRAINTS.minFontSize,
54+ `Font size must be at least ${BBCODE_CONSTRAINTS.minFontSize}px`
55+ )
56+ .max(
57+ BBCODE_CONSTRAINTS.maxFontSize,
58+ `Font size cannot exceed ${BBCODE_CONSTRAINTS.maxFontSize}px`
59+ );
60+
61+// Font family schema - validates against RETRO_FONTS (both keys and names)
62+export const bbcodeFontFamilySchema = z
63+ .string()
64+ .transform((val) => val.toLowerCase().trim())
65+ .refine((val) => ALLOWED_FONTS_SET.has(val), {
66+ message: 'Invalid font family'
67+ });
68+
69+// Validation helper functions that return boolean (for use in text-formatter)
70+export function isValidBBCodeColor(color: string): boolean {
71+ return bbcodeColorSchema.safeParse(color.toLowerCase()).success;
72+}
73+
74+export function isValidBBCodeFontSize(size: string): boolean {
75+ return bbcodeFontSizeSchema.safeParse(size).success;
76+}
77+
78+export function isValidBBCodeFont(font: string): boolean {
79+ return ALLOWED_FONTS_SET.has(font.toLowerCase().trim());
80+}
81+
82+// Type exports for use elsewhere
83+export type BBCodeColor = z.infer<typeof bbcodeColorSchema>;
84+export type BBCodeFontSize = z.infer<typeof bbcodeFontSizeSchema>;
85+export type BBCodeFontFamily = z.infer<typeof bbcodeFontFamilySchema>;
@@ -0,0 +1,85 @@
1+import { z } from 'zod';
2+import { RETRO_FONTS } from '$lib/types/text-formatting';
3+
4+// Build allowed fonts Set from RETRO_FONTS - includes both keys and display names
5+const ALLOWED_FONTS_SET = new Set<string>();
6+for (const [key, value] of Object.entries(RETRO_FONTS)) {
7+ // Add the key (e.g., 'comicsans')
8+ ALLOWED_FONTS_SET.add(key.toLowerCase());
9+ // Add the display name (e.g., 'comic sans ms')
10+ ALLOWED_FONTS_SET.add(value.name.toLowerCase());
11+}
12+
13+// BBCode constraints
14+export const BBCODE_CONSTRAINTS = {
15+ minFontSize: 8,
16+ maxFontSize: 24,
17+ namedColors: [
18+ 'red',
19+ 'blue',
20+ 'green',
21+ 'yellow',
22+ 'orange',
23+ 'purple',
24+ 'pink',
25+ 'black',
26+ 'white',
27+ 'gray',
28+ 'grey',
29+ 'brown',
30+ 'cyan',
31+ 'magenta',
32+ 'lime',
33+ 'navy',
34+ 'teal',
35+ 'maroon'
36+ ] as const
37+};
38+
39+// Hex color schema - validates 6-digit hex colors
40+const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format');
41+
42+// Named color schema - validates against predefined list
43+const namedColorSchema = z.enum(BBCODE_CONSTRAINTS.namedColors);
44+
45+// Combined color schema - accepts either hex or named color
46+export const bbcodeColorSchema = z.union([hexColorSchema, namedColorSchema]);
47+
48+// Font size schema - validates range 8-24
49+export const bbcodeFontSizeSchema = z.coerce
50+ .number()
51+ .int('Font size must be an integer')
52+ .min(
53+ BBCODE_CONSTRAINTS.minFontSize,
54+ `Font size must be at least ${BBCODE_CONSTRAINTS.minFontSize}px`
55+ )
56+ .max(
57+ BBCODE_CONSTRAINTS.maxFontSize,
58+ `Font size cannot exceed ${BBCODE_CONSTRAINTS.maxFontSize}px`
59+ );
60+
61+// Font family schema - validates against RETRO_FONTS (both keys and names)
62+export const bbcodeFontFamilySchema = z
63+ .string()
64+ .transform((val) => val.toLowerCase().trim())
65+ .refine((val) => ALLOWED_FONTS_SET.has(val), {
66+ message: 'Invalid font family'
67+ });
68+
69+// Validation helper functions that return boolean (for use in text-formatter)
70+export function isValidBBCodeColor(color: string): boolean {
71+ return bbcodeColorSchema.safeParse(color.toLowerCase()).success;
72+}
73+
74+export function isValidBBCodeFontSize(size: string): boolean {
75+ return bbcodeFontSizeSchema.safeParse(size).success;
76+}
77+
78+export function isValidBBCodeFont(font: string): boolean {
79+ return ALLOWED_FONTS_SET.has(font.toLowerCase().trim());
80+}
81+
82+// Type exports for use elsewhere
83+export type BBCodeColor = z.infer<typeof bbcodeColorSchema>;
84+export type BBCodeFontSize = z.infer<typeof bbcodeFontSizeSchema>;
85+export type BBCodeFontFamily = z.infer<typeof bbcodeFontFamilySchema>;
M src/lib/validation/text-formatting.ts +52 -55
@@ -1,76 +1,74 @@
1+import { z } from 'zod';
12 import type { TextStyle } from '$lib/types/text-formatting';
23 import { RETRO_FONTS } from '$lib/types/text-formatting';
34
4-const MIN_FONT_SIZE = 8;
5-const MAX_FONT_SIZE = 18;
6-const MAX_GRADIENT_COLORS = 3;
7-const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;
5+// Text style constraints (different from BBCode - style picker has smaller max)
6+export const TEXT_STYLE_CONSTRAINTS = {
7+ minFontSize: 8,
8+ maxFontSize: 18,
9+ maxGradientColors: 3
10+};
11+
12+// Hex color schema
13+const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format');
14+
15+// Font family schema - validates against RETRO_FONTS keys
16+const fontFamilyKeys = Object.keys(RETRO_FONTS) as [string, ...string[]];
17+const fontFamilySchema = z.enum(fontFamilyKeys);
18+
19+// Font size schema with clamping
20+const fontSizeSchema = z.coerce
21+ .number()
22+ .int()
23+ .transform((val) =>
24+ Math.max(TEXT_STYLE_CONSTRAINTS.minFontSize, Math.min(TEXT_STYLE_CONSTRAINTS.maxFontSize, val))
25+ );
826
27+// Gradient schema - array of 1-3 hex colors
28+const gradientSchema = z
29+ .array(hexColorSchema)
30+ .min(1)
31+ .max(TEXT_STYLE_CONSTRAINTS.maxGradientColors)
32+ .optional();
33+
34+// Full TextStyle schema
35+const textStyleSchema = z.object({
36+ fontFamily: fontFamilySchema.default('pixelated'),
37+ fontSize: fontSizeSchema.default(14),
38+ color: hexColorSchema.optional(),
39+ gradient: gradientSchema,
40+ bold: z.boolean().default(false),
41+ italic: z.boolean().default(false),
42+ underline: z.boolean().default(false),
43+ strikethrough: z.boolean().default(false)
44+});
45+
46+// Legacy helper functions (for backwards compatibility)
947 export function validateHexColor(color: string): boolean {
10- return HEX_COLOR_REGEX.test(color);
48+ return hexColorSchema.safeParse(color).success;
1149 }
1250
1351 export function validateFontFamily(fontFamily: string): boolean {
14- return fontFamily in RETRO_FONTS;
52+ return fontFamilySchema.safeParse(fontFamily).success;
1553 }
1654
1755 export function validateFontSize(size: number): number {
18- return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));
56+ const result = fontSizeSchema.safeParse(size);
57+ return result.success ? result.data : 14;
1958 }
2059
2160 export function validateGradient(gradient: unknown): string[] | undefined {
22- if (!Array.isArray(gradient)) return undefined;
23- if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined;
24-
25- const validColors = gradient.filter(
26- (color) => typeof color === 'string' && validateHexColor(color)
27- );
28-
29- return validColors.length === gradient.length ? validColors : undefined;
61+ const result = gradientSchema.safeParse(gradient);
62+ return result.success ? result.data : undefined;
3063 }
3164
3265 export function validateTextStyle(style: unknown): TextStyle | null {
3366 if (!style || typeof style !== 'object') return null;
3467
35- const rawStyle = style as Record<string, unknown>;
36-
37- const validated: TextStyle = {
38- fontFamily: 'pixelated',
39- fontSize: 14,
40- bold: false,
41- italic: false,
42- underline: false,
43- strikethrough: false
44- };
45-
46- // Validate font family
47- if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) {
48- validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS;
49- }
50-
51- // Validate font size
52- if (typeof rawStyle.fontSize === 'number') {
53- validated.fontSize = validateFontSize(rawStyle.fontSize);
54- }
55-
56- // Validate color
57- if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) {
58- validated.color = rawStyle.color;
59- }
60-
61- // Validate gradient
62- const validatedGradient = validateGradient(rawStyle.gradient);
63- if (validatedGradient) {
64- validated.gradient = validatedGradient;
65- }
66-
67- // Validate boolean properties
68- validated.bold = Boolean(rawStyle.bold);
69- validated.italic = Boolean(rawStyle.italic);
70- validated.underline = Boolean(rawStyle.underline);
71- validated.strikethrough = Boolean(rawStyle.strikethrough);
68+ const result = textStyleSchema.safeParse(style);
69+ if (!result.success) return null;
7270
73- return validated;
71+ return result.data as TextStyle;
7472 }
7573
7674 export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
@@ -81,8 +79,7 @@ export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
8179 if (typeof styleData === 'string') {
8280 try {
8381 parsedData = JSON.parse(styleData);
84- } catch (error) {
85- console.warn('Failed to parse styleData JSON:', error);
82+ } catch {
8683 return undefined;
8784 }
8885 }
@@ -1,76 +1,74 @@
1+import { z } from 'zod';
1 import type { TextStyle } from '$lib/types/text-formatting';2 import type { TextStyle } from '$lib/types/text-formatting';
2 import { RETRO_FONTS } from '$lib/types/text-formatting';3 import { RETRO_FONTS } from '$lib/types/text-formatting';
3 4
4-const MIN_FONT_SIZE = 8;5+// Text style constraints (different from BBCode - style picker has smaller max)
5-const MAX_FONT_SIZE = 18;6+export const TEXT_STYLE_CONSTRAINTS = {
6-const MAX_GRADIENT_COLORS = 3;7+ minFontSize: 8,
7-const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/;8+ maxFontSize: 18,
9+ maxGradientColors: 3
10+};
11+
12+// Hex color schema
13+const hexColorSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, 'Invalid hex color format');
14+
15+// Font family schema - validates against RETRO_FONTS keys
16+const fontFamilyKeys = Object.keys(RETRO_FONTS) as [string, ...string[]];
17+const fontFamilySchema = z.enum(fontFamilyKeys);
18+
19+// Font size schema with clamping
20+const fontSizeSchema = z.coerce
21+ .number()
22+ .int()
23+ .transform((val) =>
24+ Math.max(TEXT_STYLE_CONSTRAINTS.minFontSize, Math.min(TEXT_STYLE_CONSTRAINTS.maxFontSize, val))
25+ );
8 26
27+// Gradient schema - array of 1-3 hex colors
28+const gradientSchema = z
29+ .array(hexColorSchema)
30+ .min(1)
31+ .max(TEXT_STYLE_CONSTRAINTS.maxGradientColors)
32+ .optional();
33+
34+// Full TextStyle schema
35+const textStyleSchema = z.object({
36+ fontFamily: fontFamilySchema.default('pixelated'),
37+ fontSize: fontSizeSchema.default(14),
38+ color: hexColorSchema.optional(),
39+ gradient: gradientSchema,
40+ bold: z.boolean().default(false),
41+ italic: z.boolean().default(false),
42+ underline: z.boolean().default(false),
43+ strikethrough: z.boolean().default(false)
44+});
45+
46+// Legacy helper functions (for backwards compatibility)
9 export function validateHexColor(color: string): boolean {47 export function validateHexColor(color: string): boolean {
10- return HEX_COLOR_REGEX.test(color);48+ return hexColorSchema.safeParse(color).success;
11 }49 }
12 50
13 export function validateFontFamily(fontFamily: string): boolean {51 export function validateFontFamily(fontFamily: string): boolean {
14- return fontFamily in RETRO_FONTS;52+ return fontFamilySchema.safeParse(fontFamily).success;
15 }53 }
16 54
17 export function validateFontSize(size: number): number {55 export function validateFontSize(size: number): number {
18- return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, size));56+ const result = fontSizeSchema.safeParse(size);
57+ return result.success ? result.data : 14;
19 }58 }
20 59
21 export function validateGradient(gradient: unknown): string[] | undefined {60 export function validateGradient(gradient: unknown): string[] | undefined {
22- if (!Array.isArray(gradient)) return undefined;61+ const result = gradientSchema.safeParse(gradient);
23- if (gradient.length === 0 || gradient.length > MAX_GRADIENT_COLORS) return undefined;62+ return result.success ? result.data : undefined;
24-
25- const validColors = gradient.filter(
26- (color) => typeof color === 'string' && validateHexColor(color)
27- );
28-
29- return validColors.length === gradient.length ? validColors : undefined;
30 }63 }
31 64
32 export function validateTextStyle(style: unknown): TextStyle | null {65 export function validateTextStyle(style: unknown): TextStyle | null {
33 if (!style || typeof style !== 'object') return null;66 if (!style || typeof style !== 'object') return null;
34 67
35- const rawStyle = style as Record<string, unknown>;68+ const result = textStyleSchema.safeParse(style);
36-69+ if (!result.success) return null;
37- const validated: TextStyle = {
38- fontFamily: 'pixelated',
39- fontSize: 14,
40- bold: false,
41- italic: false,
42- underline: false,
43- strikethrough: false
44- };
45-
46- // Validate font family
47- if (typeof rawStyle.fontFamily === 'string' && validateFontFamily(rawStyle.fontFamily)) {
48- validated.fontFamily = rawStyle.fontFamily as keyof typeof RETRO_FONTS;
49- }
50-
51- // Validate font size
52- if (typeof rawStyle.fontSize === 'number') {
53- validated.fontSize = validateFontSize(rawStyle.fontSize);
54- }
55-
56- // Validate color
57- if (typeof rawStyle.color === 'string' && validateHexColor(rawStyle.color)) {
58- validated.color = rawStyle.color;
59- }
60-
61- // Validate gradient
62- const validatedGradient = validateGradient(rawStyle.gradient);
63- if (validatedGradient) {
64- validated.gradient = validatedGradient;
65- }
66-
67- // Validate boolean properties
68- validated.bold = Boolean(rawStyle.bold);
69- validated.italic = Boolean(rawStyle.italic);
70- validated.underline = Boolean(rawStyle.underline);
71- validated.strikethrough = Boolean(rawStyle.strikethrough);
72 70
73- return validated;71+ return result.data as TextStyle;
74 }72 }
75 73
76 export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {74 export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
@@ -81,8 +79,7 @@ export function sanitizeStyleData(styleData: unknown): TextStyle | undefined {
81 if (typeof styleData === 'string') {79 if (typeof styleData === 'string') {
82 try {80 try {
83 parsedData = JSON.parse(styleData);81 parsedData = JSON.parse(styleData);
84- } catch (error) {82+ } catch {
85- console.warn('Failed to parse styleData JSON:', error);
86 return undefined;83 return undefined;
87 }84 }
88 }85 }
M svelte.config.js +16 -1
@@ -11,7 +11,22 @@ const config = {
1111 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
1212 // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
1313 // See https://svelte.dev/docs/kit/adapters for more information about adapters.
14- adapter: adapter()
14+ adapter: adapter(),
15+ csp: {
16+ mode: 'auto',
17+ directives: {
18+ 'default-src': ['self'],
19+ 'script-src': ['self', 'https://challenges.cloudflare.com'],
20+ 'style-src': ['self', 'unsafe-inline'],
21+ 'img-src': ['self', 'data:', 'blob:'],
22+ 'connect-src': ['self', 'https://challenges.cloudflare.com'],
23+ 'font-src': ['self'],
24+ 'frame-src': ['https://challenges.cloudflare.com'],
25+ 'frame-ancestors': ['none'],
26+ 'base-uri': ['self'],
27+ 'form-action': ['self']
28+ }
29+ }
1530 }
1631 };
1732
@@ -11,7 +11,22 @@ const config = {
11 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.11 // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
12 // If your environment is not supported, or you settled on a specific environment, switch out the adapter.12 // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
13 // See https://svelte.dev/docs/kit/adapters for more information about adapters.13 // See https://svelte.dev/docs/kit/adapters for more information about adapters.
14- adapter: adapter()14+ adapter: adapter(),
15+ csp: {
16+ mode: 'auto',
17+ directives: {
18+ 'default-src': ['self'],
19+ 'script-src': ['self', 'https://challenges.cloudflare.com'],
20+ 'style-src': ['self', 'unsafe-inline'],
21+ 'img-src': ['self', 'data:', 'blob:'],
22+ 'connect-src': ['self', 'https://challenges.cloudflare.com'],
23+ 'font-src': ['self'],
24+ 'frame-src': ['https://challenges.cloudflare.com'],
25+ 'frame-ancestors': ['none'],
26+ 'base-uri': ['self'],
27+ 'form-action': ['self']
28+ }
29+ }
15 }30 }
16 };31 };
17 32

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