Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion apps/backend/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -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(),
},
});
21 changes: 21 additions & 0 deletions apps/backend/src/config/cors.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions apps/backend/src/config/cors.ts
Original file line number Diff line number Diff line change
@@ -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));
};
}
23 changes: 22 additions & 1 deletion apps/backend/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
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.
*/
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(),
Expand Down
14 changes: 5 additions & 9 deletions apps/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
});

Expand Down Expand Up @@ -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('');
Expand Down
5 changes: 3 additions & 2 deletions apps/backend/src/modules/audit/audit.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
40 changes: 24 additions & 16 deletions apps/backend/src/modules/issues/issues.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
},
})
Expand All @@ -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 };
}
Expand All @@ -65,16 +73,16 @@ 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 };
}

/**
* Broadcast a new issue to subscribers
*/
broadcastNewIssue(issue: any) {
broadcastNewIssue(issue: IssueBroadcast): void {
const payload: IssueUpdatePayload = {
type: 'new',
issueId: issue.id,
Expand All @@ -83,62 +91,62 @@ 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);
}
}

/**
* Broadcast an issue update to subscribers
*/
broadcastIssueUpdate(issue: any) {
broadcastIssueUpdate(issue: IssueBroadcast): void {
const payload: IssueUpdatePayload = {
type: 'updated',
issueId: issue.id,
data: issue,
};

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);
}
}

/**
* Broadcast when an issue is resolved
*/
broadcastIssueResolved(issue: any) {
broadcastIssueResolved(issue: IssueBroadcast): void {
const payload: IssueUpdatePayload = {
type: 'resolved',
issueId: issue.id,
data: issue,
};

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);
}
}

/**
* 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);
}
}

/**
* 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);
}
}
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/modules/logs/logs.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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,
},
})
Expand Down
4 changes: 3 additions & 1 deletion apps/backend/src/modules/sessions/sessions.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';

import { createCorsOriginValidator } from '../../config/cors';

interface SessionSubscription {
serverId?: string;
}
Expand All @@ -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,
},
})
Expand Down
Loading
Loading