diff --git a/apps/backend/drizzle.config.ts b/apps/backend/drizzle.config.ts index d45a1f5..a32f3f6 100644 --- a/apps/backend/drizzle.config.ts +++ b/apps/backend/drizzle.config.ts @@ -1,10 +1,39 @@ import { defineConfig } from 'drizzle-kit'; +function getDatabaseUrl(): string { + const databaseUrl = process.env.DATABASE_URL; + + if (!databaseUrl) { + throw new Error('DATABASE_URL is required for database migrations'); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(databaseUrl); + } catch { + throw new Error('DATABASE_URL must be a valid PostgreSQL connection URL'); + } + + if ( + !['postgres:', 'postgresql:'].includes(parsedUrl.protocol) || + !parsedUrl.username || + !parsedUrl.password || + !parsedUrl.hostname || + parsedUrl.pathname.length <= 1 + ) { + throw new Error( + 'DATABASE_URL must include postgres username, password, host, and database name' + ); + } + + return databaseUrl; +} + export default defineConfig({ schema: './src/database/schema.ts', out: './drizzle', dialect: 'postgresql', dbCredentials: { - url: process.env.DATABASE_URL!, + url: getDatabaseUrl(), }, }); diff --git a/apps/backend/src/config/cors.test.ts b/apps/backend/src/config/cors.test.ts new file mode 100644 index 0000000..4be780b --- /dev/null +++ b/apps/backend/src/config/cors.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import { isOriginAllowed } from './cors'; + +describe('isOriginAllowed', () => { + it('allows exact origin matches', () => { + expect(isOriginAllowed('http://localhost:3001', 'http://localhost:3001')).toBe(true); + }); + + it('allows wildcard origins', () => { + expect(isOriginAllowed('https://logarr.example.com', '*')).toBe(true); + }); + + it('allows LAN hosts when configured origin is localhost on the same port', () => { + expect(isOriginAllowed('http://192.168.1.120:3001', 'http://localhost:3001')).toBe(true); + }); + + it('rejects different ports for loopback alias matching', () => { + expect(isOriginAllowed('http://192.168.1.120:1337', 'http://localhost:3001')).toBe(false); + }); +}); diff --git a/apps/backend/src/config/cors.ts b/apps/backend/src/config/cors.ts new file mode 100644 index 0000000..de7413e --- /dev/null +++ b/apps/backend/src/config/cors.ts @@ -0,0 +1,63 @@ +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + +function parseConfiguredOrigins(configuredOrigins: string): string[] { + return configuredOrigins + .split(',') + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +} + +function defaultPort(protocol: string): string { + return protocol === 'https:' ? '443' : '80'; +} + +function parseOrigin(origin: string): URL | null { + try { + return new URL(origin); + } catch { + return null; + } +} + +function isLoopbackAlias(configuredOrigin: string, requestOrigin: string): boolean { + const configuredUrl = parseOrigin(configuredOrigin); + const requestUrl = parseOrigin(requestOrigin); + + if (!configuredUrl || !requestUrl) { + return false; + } + + if (!LOOPBACK_HOSTS.has(configuredUrl.hostname)) { + return false; + } + + return ( + configuredUrl.protocol === requestUrl.protocol && + (configuredUrl.port.length > 0 ? configuredUrl.port : defaultPort(configuredUrl.protocol)) === + (requestUrl.port.length > 0 ? requestUrl.port : defaultPort(requestUrl.protocol)) + ); +} + +export function isOriginAllowed(origin: string | undefined, configuredOrigins: string): boolean { + if (origin === undefined || origin.length === 0) { + return true; + } + + const allowedOrigins = parseConfiguredOrigins(configuredOrigins); + if (allowedOrigins.includes('*')) { + return true; + } + + return allowedOrigins.some( + (configuredOrigin) => configuredOrigin === origin || isLoopbackAlias(configuredOrigin, origin) + ); +} + +export function createCorsOriginValidator(configuredOrigins: string) { + return ( + origin: string | undefined, + callback: (error: Error | null, allow?: boolean) => void + ): void => { + callback(null, isOriginAllowed(origin, configuredOrigins)); + }; +} diff --git a/apps/backend/src/config/env.ts b/apps/backend/src/config/env.ts index 8f9f91f..d154e69 100644 --- a/apps/backend/src/config/env.ts +++ b/apps/backend/src/config/env.ts @@ -1,5 +1,20 @@ import { z } from 'zod'; +function isValidDatabaseUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + (url.protocol === 'postgres:' || url.protocol === 'postgresql:') && + url.username.length > 0 && + url.password.length > 0 && + url.hostname.length > 0 && + url.pathname.length > 1 + ); + } catch { + return false; + } +} + /** * Environment configuration schema. * All required values must be set - no defaults that hide misconfiguration. @@ -7,7 +22,13 @@ import { z } from 'zod'; const envSchema = z.object({ NODE_ENV: z.enum(['development', 'production', 'test']), BACKEND_PORT: z.coerce.number().min(1).max(65535), - DATABASE_URL: z.string().url(), + DATABASE_URL: z + .string() + .url() + .refine( + isValidDatabaseUrl, + 'DATABASE_URL must include postgres username, password, host, and database name' + ), REDIS_URL: z.string(), CORS_ORIGIN: z.string().min(1), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).optional(), diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index c5fedc3..58d4fe6 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -3,6 +3,7 @@ import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { Logger } from 'nestjs-pino'; import { AppModule } from './app.module'; +import { createCorsOriginValidator } from './config/cors'; import { validateEnv, type Env } from './config/env'; import { AuthService } from './modules/auth/auth.service'; import { SettingsService } from './modules/settings/settings.service'; @@ -23,13 +24,8 @@ async function bootstrap() { // Use Pino logger for all NestJS logging app.useLogger(app.get(Logger)); - // Enable CORS - support multiple origins via comma-separated string - const origins = env.CORS_ORIGIN.includes(',') - ? env.CORS_ORIGIN.split(',').map((o) => o.trim()) - : env.CORS_ORIGIN; - app.enableCors({ - origin: origins, + origin: createCorsOriginValidator(env.CORS_ORIGIN), credentials: true, }); @@ -79,9 +75,9 @@ async function bootstrap() { const setupStatus = await authService.getSetupStatus(); if (setupStatus.setupRequired && setupStatus.setupToken) { // Log the setup token prominently for first-time setup - const frontendUrl = env.CORS_ORIGIN.includes(',') - ? (env.CORS_ORIGIN.split(',')[0] ?? env.CORS_ORIGIN).trim() - : env.CORS_ORIGIN; + const frontendUrl = env.CORS_ORIGIN.includes('*') + ? 'your Logarr frontend' + : (env.CORS_ORIGIN.split(',')[0] ?? env.CORS_ORIGIN).trim() || 'your Logarr frontend'; const token = setupStatus.setupToken; console.log(''); diff --git a/apps/backend/src/modules/audit/audit.gateway.ts b/apps/backend/src/modules/audit/audit.gateway.ts index 6ebf2e9..bf68774 100644 --- a/apps/backend/src/modules/audit/audit.gateway.ts +++ b/apps/backend/src/modules/audit/audit.gateway.ts @@ -8,14 +8,15 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { createCorsOriginValidator } from '../../config/cors'; + /** * WebSocket Gateway for real-time audit log updates * Clients can connect to receive live audit logs as they're created */ @WebSocketGateway({ cors: { - origin: - (process.env as { CORS_ORIGIN?: string }).CORS_ORIGIN?.split(',') || 'http://localhost:3000', + origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] || 'http://localhost:3000'), credentials: true, }, namespace: '/audit', diff --git a/apps/backend/src/modules/issues/issues.gateway.ts b/apps/backend/src/modules/issues/issues.gateway.ts index 25060b6..2e1ec61 100644 --- a/apps/backend/src/modules/issues/issues.gateway.ts +++ b/apps/backend/src/modules/issues/issues.gateway.ts @@ -8,12 +8,20 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { createCorsOriginValidator } from '../../config/cors'; + import { IssuesService } from './issues.service'; interface IssueUpdatePayload { type: 'new' | 'updated' | 'resolved' | 'merged'; issueId: string; - data?: any; + data?: unknown; +} + +interface IssueBroadcast { + id: string; + serverId?: string; + [key: string]: unknown; } interface BackfillProgressPayload { @@ -32,7 +40,7 @@ interface BackfillProgressPayload { @WebSocketGateway({ namespace: 'issues', cors: { - origin: process.env['CORS_ORIGIN']!, + origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] ?? 'http://localhost:3000'), credentials: true, }, }) @@ -51,9 +59,9 @@ export class IssuesGateway { async handleSubscribe( @MessageBody() data: { serverId?: string }, @ConnectedSocket() client: Socket - ) { + ): Promise<{ subscribed: true; room: string }> { // Join a room for the specific server or 'all' for all issues - const room = data.serverId || 'all'; + const room = data.serverId ?? 'all'; await client.join(`issues:${room}`); return { subscribed: true, room }; } @@ -65,8 +73,8 @@ export class IssuesGateway { async handleUnsubscribe( @MessageBody() data: { serverId?: string }, @ConnectedSocket() client: Socket - ) { - const room = data.serverId || 'all'; + ): Promise<{ unsubscribed: true; room: string }> { + const room = data.serverId ?? 'all'; await client.leave(`issues:${room}`); return { unsubscribed: true, room }; } @@ -74,7 +82,7 @@ export class IssuesGateway { /** * Broadcast a new issue to subscribers */ - broadcastNewIssue(issue: any) { + broadcastNewIssue(issue: IssueBroadcast): void { const payload: IssueUpdatePayload = { type: 'new', issueId: issue.id, @@ -83,7 +91,7 @@ export class IssuesGateway { // Broadcast to both server-specific room and 'all' room this.server.to('issues:all').emit('issue:new', payload); - if (issue.serverId) { + if (issue.serverId !== undefined && issue.serverId.length > 0) { this.server.to(`issues:${issue.serverId}`).emit('issue:new', payload); } } @@ -91,7 +99,7 @@ export class IssuesGateway { /** * Broadcast an issue update to subscribers */ - broadcastIssueUpdate(issue: any) { + broadcastIssueUpdate(issue: IssueBroadcast): void { const payload: IssueUpdatePayload = { type: 'updated', issueId: issue.id, @@ -99,7 +107,7 @@ export class IssuesGateway { }; this.server.to('issues:all').emit('issue:updated', payload); - if (issue.serverId) { + if (issue.serverId !== undefined && issue.serverId.length > 0) { this.server.to(`issues:${issue.serverId}`).emit('issue:updated', payload); } } @@ -107,7 +115,7 @@ export class IssuesGateway { /** * Broadcast when an issue is resolved */ - broadcastIssueResolved(issue: any) { + broadcastIssueResolved(issue: IssueBroadcast): void { const payload: IssueUpdatePayload = { type: 'resolved', issueId: issue.id, @@ -115,7 +123,7 @@ export class IssuesGateway { }; this.server.to('issues:all').emit('issue:resolved', payload); - if (issue.serverId) { + if (issue.serverId !== undefined && issue.serverId.length > 0) { this.server.to(`issues:${issue.serverId}`).emit('issue:resolved', payload); } } @@ -123,9 +131,9 @@ export class IssuesGateway { /** * Broadcast stats update */ - broadcastStatsUpdate(stats: any, serverId?: string) { + broadcastStatsUpdate(stats: unknown, serverId?: string): void { this.server.to('issues:all').emit('stats:updated', stats); - if (serverId) { + if (serverId !== undefined && serverId.length > 0) { this.server.to(`issues:${serverId}`).emit('stats:updated', stats); } } @@ -133,12 +141,12 @@ export class IssuesGateway { /** * Broadcast backfill progress */ - broadcastBackfillProgress(progress: BackfillProgressPayload, serverId?: string) { + broadcastBackfillProgress(progress: BackfillProgressPayload, serverId?: string): void { this.logger.log( `Broadcasting backfill progress: ${progress.status} - ${progress.processedLogs}/${progress.totalLogs}` ); this.server.to('issues:all').emit('backfill:progress', progress); - if (serverId) { + if (serverId !== undefined && serverId.length > 0) { this.server.to(`issues:${serverId}`).emit('backfill:progress', progress); } } diff --git a/apps/backend/src/modules/logs/logs.gateway.ts b/apps/backend/src/modules/logs/logs.gateway.ts index 6167000..ae8f11a 100644 --- a/apps/backend/src/modules/logs/logs.gateway.ts +++ b/apps/backend/src/modules/logs/logs.gateway.ts @@ -7,6 +7,8 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { createCorsOriginValidator } from '../../config/cors'; + interface LogSubscription { serverId?: string; levels?: string[]; @@ -43,7 +45,7 @@ interface FileIngestionProgress { @WebSocketGateway({ namespace: 'logs', cors: { - origin: process.env['CORS_ORIGIN']!, + origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] || 'http://localhost:3000'), credentials: true, }, }) diff --git a/apps/backend/src/modules/sessions/sessions.gateway.ts b/apps/backend/src/modules/sessions/sessions.gateway.ts index 242d0f4..3ba5a71 100644 --- a/apps/backend/src/modules/sessions/sessions.gateway.ts +++ b/apps/backend/src/modules/sessions/sessions.gateway.ts @@ -7,6 +7,8 @@ import { } from '@nestjs/websockets'; import { Server, Socket } from 'socket.io'; +import { createCorsOriginValidator } from '../../config/cors'; + interface SessionSubscription { serverId?: string; } @@ -22,7 +24,7 @@ export interface SessionUpdatePayload { @WebSocketGateway({ namespace: 'sessions', cors: { - origin: process.env['CORS_ORIGIN']!, + origin: createCorsOriginValidator(process.env['CORS_ORIGIN'] || 'http://localhost:3000'), credentials: true, }, }) diff --git a/apps/frontend/docker-entrypoint.sh b/apps/frontend/docker-entrypoint.sh index 78534ec..39a1504 100644 --- a/apps/frontend/docker-entrypoint.sh +++ b/apps/frontend/docker-entrypoint.sh @@ -12,12 +12,39 @@ if [ -z "$NEXT_PUBLIC_WS_URL" ]; then exit 1 fi -# Generate runtime config that will be injected into the page +# Generate runtime config that will be injected into the page. +# When Docker defaults point at localhost, remote browsers need those URLs rewritten +# to the host they actually used to open Logarr. cat > /app/apps/frontend/public/__config.js << EOF -window.__LOGARR_CONFIG__ = { - apiUrl: "${NEXT_PUBLIC_API_URL}", - wsUrl: "${NEXT_PUBLIC_WS_URL}" -}; +(function () { + var loopbackHosts = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); + + function normalizeUrl(rawUrl, type) { + try { + var url = new URL(rawUrl, window.location.origin); + if (!loopbackHosts.has(url.hostname)) { + return url.toString(); + } + + url.hostname = window.location.hostname; + url.protocol = + type === 'ws' + ? window.location.protocol === 'https:' + ? 'wss:' + : 'ws:' + : window.location.protocol; + + return url.toString(); + } catch { + return rawUrl; + } + } + + window.__LOGARR_CONFIG__ = { + apiUrl: normalizeUrl("${NEXT_PUBLIC_API_URL}", 'http'), + wsUrl: normalizeUrl("${NEXT_PUBLIC_WS_URL}", 'ws') + }; +})(); EOF echo "Runtime config generated:" diff --git a/apps/frontend/src/components/app-sidebar.test.tsx b/apps/frontend/src/components/app-sidebar.test.tsx index d1a231e..98238d9 100644 --- a/apps/frontend/src/components/app-sidebar.test.tsx +++ b/apps/frontend/src/components/app-sidebar.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect, vi, beforeEach, beforeAll } from 'vitest'; import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; // Set the version environment variable before any imports const TEST_VERSION = '0.4.3'; @@ -131,7 +131,7 @@ describe('AppSidebar', () => { }); }); - it('should display version without v prefix in collapsed state', async () => { + it('should display version without v prefix in collapsed state', () => { // This test validates the conditional rendering logic // In collapsed state, version should show as "0.4.3" without the "v" prefix // The actual behavior depends on useSidebar().state @@ -196,5 +196,13 @@ describe('AppSidebar', () => { render(); expect(screen.getByText('Settings')).toBeInTheDocument(); }); + + it('should link Settings directly to AI providers', () => { + render(); + expect(screen.getByRole('link', { name: /settings/i })).toHaveAttribute( + 'href', + '/settings/ai-providers' + ); + }); }); }); diff --git a/apps/frontend/src/components/app-sidebar.tsx b/apps/frontend/src/components/app-sidebar.tsx index b888ac2..1778785 100644 --- a/apps/frontend/src/components/app-sidebar.tsx +++ b/apps/frontend/src/components/app-sidebar.tsx @@ -58,7 +58,7 @@ function ServiceStatusIndicator({ icon: Icon, status, isCollapsed, -}: ServiceStatusIndicatorProps) { +}: ServiceStatusIndicatorProps): React.JSX.Element { const isOk = status?.status === 'ok'; const latency = status?.latency; const error = status?.error; @@ -101,7 +101,7 @@ function ServiceStatusIndicator({ )} - {error && ( + {error !== undefined && error.length > 0 && (
{error}
)} @@ -111,7 +111,7 @@ function ServiceStatusIndicator({ ); } -export function AppSidebar() { +export function AppSidebar(): React.JSX.Element { const pathname = usePathname(); const { state } = useSidebar(); const isCollapsed = state === 'collapsed'; @@ -190,7 +190,8 @@ export function AppSidebar() { const settingsNavItems = [ { title: 'Settings', - href: '/settings', + href: '/settings/ai-providers', + activePrefix: '/settings', icon: Settings, iconColor: 'text-zinc-400', badge: null as string | number | null, @@ -223,13 +224,13 @@ export function AppSidebar() { isActive={pathname === item.href} size="lg" className="gap-3" - tooltip={item.badge ? `${item.title} (${item.badge})` : item.title} + tooltip={item.badge !== null ? `${item.title} (${item.badge})` : item.title} > {item.title} {/* Badge indicator dot for collapsed state */} - {isCollapsed && item.badge && ( + {isCollapsed && item.badge !== null && ( - {!isCollapsed && item.badge && ( + {!isCollapsed && item.badge !== null && ( @@ -274,11 +275,13 @@ export function AppSidebar() { {item.title} {/* AI setup indicator for collapsed state */} - {isCollapsed && !hasAiConfigured && item.href === '/settings' && ( + {isCollapsed && !hasAiConfigured && item.activePrefix === '/settings' && ( )} - {!isCollapsed && !hasAiConfigured && item.href === '/settings' && ( + {!isCollapsed && !hasAiConfigured && item.activePrefix === '/settings' && ( diff --git a/docker-compose.yml b/docker-compose.yml index 0ab2ea3..d813880 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: DATABASE_URL: postgresql://postgres:postgres@db:5432/logarr REDIS_URL: redis://redis:6379 BACKEND_PORT: 4000 - CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3001} + CORS_ORIGIN: ${CORS_ORIGIN:-*} # Health check startup grace period (seconds) - allows file ingestion to initialize # Volume mounts may not be immediately available after docker compose restart HEALTH_CHECK_STARTUP_GRACE_SECONDS: ${HEALTH_CHECK_STARTUP_GRACE_SECONDS:-60} diff --git a/package.json b/package.json index c1ab172..f12ec75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "logarr", - "version": "0.6.2", + "version": "0.6.3", "private": true, "description": "Unified logging for your media stack", "scripts": {