Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
3 changes: 2 additions & 1 deletion server/src/db/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { config } from '../config.js';
import { logger } from '../services/logger.js';

let db: Database.Database | null = null;

Expand All @@ -24,7 +25,7 @@ export function getDb(): Database.Database {

return db;
} catch (error) {
console.error('Failed to initialize database:', error);
logger.errorFromError('db.connection', 'Failed to initialize database', error);
throw new Error('Database initialization failed');
}
}
Expand Down
5 changes: 3 additions & 2 deletions server/src/db/migrations.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type Database from 'better-sqlite3';
import { initializeSchema } from './schema.js';
import { logger } from '../services/logger.js';

const migrations: Record<number, (db: Database.Database) => void> = {
1: (db) => {
Expand Down Expand Up @@ -31,10 +32,10 @@ export function runMigrations(db: Database.Database): void {
for (let v = currentVersion + 1; v <= targetVersion; v++) {
const migration = migrations[v];
if (migration) {
console.log(`Applying migration v${v}...`);
logger.info('db.migration', `Applying migration v${v}...`);
migration(db);
db.prepare('INSERT OR REPLACE INTO schema_version (version) VALUES (?)').run(v);
console.log(`Migration v${v} applied.`);
logger.info('db.migration', `Migration v${v} applied.`);
}
}
});
Expand Down
19 changes: 12 additions & 7 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import morgan from 'morgan';
import { config } from './config.js';
import { authMiddleware } from './middleware/auth.js';
import { errorHandler } from './middleware/errorHandler.js';
import { logger, morganLoggerStream } from './services/logger.js';
import { getDb, closeDb } from './db/connection.js';
import { runMigrations } from './db/migrations.js';
import healthRouter from './routes/health.js';
Expand All @@ -14,14 +15,15 @@ import categoriesRouter from './routes/categories.js';
import configsRouter from './routes/configs.js';
import syncRouter from './routes/sync.js';
import proxyRouter from './routes/proxy.js';
import logsRouter from './routes/logs.js';

export function createApp(): express.Express {
const app = express();

// Middleware
app.use(helmet());
app.use(cors());
app.use(morgan('combined'));
app.use(cors({ exposedHeaders: ['X-Log-Count'] }));
app.use(morgan('combined', { stream: morganLoggerStream }));
app.use(express.json({ limit: '50mb' }));

// Auth middleware for all /api/* except /api/health
Expand All @@ -40,6 +42,9 @@ export function createApp(): express.Express {
// Wave 3: Proxy routes
app.use(proxyRouter);

// Wave 4: Logs route
app.use(logsRouter);

// Global error handler
app.use(errorHandler);

Expand All @@ -50,23 +55,23 @@ function startServer(): void {
// Initialize database
const db = getDb();
runMigrations(db);
console.log('✅ Database initialized');
logger.info('server.init', 'Database initialized');

const app = createApp();

const server = app.listen(config.port, () => {
console.log(`🚀 Server running on port ${config.port}`);
logger.info('server.start', `Server running on port ${config.port}`);
if (!config.apiSecret) {
console.warn('⚠️ Running without API_SECRET — auth is disabled');
logger.warn('server.auth', 'Running without API_SECRET — auth is disabled');
}
});

// Graceful shutdown
const shutdown = () => {
console.log('\n🛑 Shutting down...');
logger.info('server.shutdown', 'Shutting down...');
server.close(() => {
closeDb();
console.log('👋 Server stopped');
logger.info('server.shutdown', 'Server stopped');
process.exit(0);
});
};
Expand Down
3 changes: 2 additions & 1 deletion server/src/middleware/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { Request, Response, NextFunction } from 'express';
import { logger } from '../services/logger.js';

export function errorHandler(
err: Error,
_req: Request,
res: Response,
_next: NextFunction
): void {
console.error('Unhandled error:', err.stack || err.message);
logger.errorFromError('errorHandler.global', 'Unhandled error', err);

if (res.headersSent) {
return _next(err);
Expand Down
38 changes: 18 additions & 20 deletions server/src/routes/configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Router } from 'express';
import { getDb } from '../db/connection.js';
import { encrypt, decrypt } from '../services/crypto.js';
import { config } from '../config.js';
import { logger } from '../services/logger.js';

const router = Router();

Expand All @@ -26,10 +27,7 @@ function getMaskedSecretResult(params: {
status: 'ok',
};
} catch (error) {
const detail = [configId ? `id=${String(configId)}` : '', configName ? `name=${String(configName)}` : '']
.filter(Boolean)
.join(', ');
console.warn(`[configs] Failed to decrypt ${kind}${detail ? ` (${detail})` : ''}:`, error);
logger.warn('configs.decrypt', 'Failed to decrypt stored secret', { kind, configId, configName });
return { decryptedValue: '', status: 'decrypt_failed' };
}
}
Expand Down Expand Up @@ -73,7 +71,7 @@ router.get('/api/configs/ai', (req, res) => {
});
res.json(configs);
} catch (err) {
console.error('GET /api/configs/ai error:', err);
logger.errorFromError('configs.getAI', 'GET /api/configs/ai error', err);
res.status(500).json({ error: 'Failed to fetch AI configs', code: 'FETCH_AI_CONFIGS_FAILED' });
}
});
Expand All @@ -95,7 +93,7 @@ router.post('/api/configs/ai', (req, res) => {

res.status(201).json({ id: result.lastInsertRowid, name, apiType, model, baseUrl, apiKey: maskApiKey(apiKey as string), isActive: !!isActive, reasoningEffort: reasoningEffort ?? null });
} catch (err) {
console.error('POST /api/configs/ai error:', err);
logger.errorFromError('configs.createAI', 'POST /api/configs/ai error', err);
res.status(500).json({ error: 'Failed to create AI config', code: 'CREATE_AI_CONFIG_FAILED' });
}
});
Expand Down Expand Up @@ -147,7 +145,7 @@ router.put('/api/configs/ai/bulk', (req, res) => {
try {
encryptedKey = encrypt(String(c.apiKey), config.encryptionKey);
} catch (encErr) {
console.error(`[configs] Failed to encrypt API key for config "${c.name}" (${c.id}), falling back to existing key:`, encErr);
logger.errorFromError('configs.encryptAIKey', 'Failed to encrypt API key for config', encErr, { configId: c.id, configName: c.name });
encryptedKey = existingKeys.get(String(c.id)) ?? '';
if (!encryptedKey) {
syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: 'encrypt_failed' });
Expand Down Expand Up @@ -178,7 +176,7 @@ router.put('/api/configs/ai/bulk', (req, res) => {
}

if (syncResult.skipped.length > 0) {
console.warn('[configs] Skipped AI configs with missing keys:', syncResult.skipped);
logger.warn('configs.bulkAI', 'Skipped AI configs with missing keys', { skippedCount: syncResult.skipped.length, skipped: syncResult.skipped });
}

// Safety guard: prevent committing an empty database when all configs were skipped
Expand All @@ -191,7 +189,7 @@ router.put('/api/configs/ai/bulk', (req, res) => {
res.json({ synced: syncResult.inserted, skipped: syncResult.skipped.length, errors: syncResult.skipped });
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error('PUT /api/configs/ai/bulk error:', err);
logger.errorFromError('configs.bulkAI', 'PUT /api/configs/ai/bulk error', err);
if (errMsg === 'ALL_CONFIGS_SKIPPED') {
res.status(422).json({
error: 'All AI configs were skipped — check the errors field for per-config reasons',
Expand Down Expand Up @@ -237,7 +235,7 @@ router.put('/api/configs/ai/:id', (req, res) => {

res.json({ id, name, apiType, model, baseUrl, apiKey: maskedKey, isActive: !!isActive, reasoningEffort: reasoningEffort ?? null });
} catch (err) {
console.error('PUT /api/configs/ai error:', err);
logger.errorFromError('configs.updateAI', 'PUT /api/configs/ai error', err);
res.status(500).json({ error: 'Failed to update AI config', code: 'UPDATE_AI_CONFIG_FAILED' });
}
});
Expand All @@ -254,7 +252,7 @@ router.delete('/api/configs/ai/:id', (req, res) => {
}
res.json({ deleted: true });
} catch (err) {
console.error('DELETE /api/configs/ai error:', err);
logger.errorFromError('configs.deleteAI', 'DELETE /api/configs/ai error', err);
res.status(500).json({ error: 'Failed to delete AI config', code: 'DELETE_AI_CONFIG_FAILED' });
}
});
Expand Down Expand Up @@ -294,7 +292,7 @@ router.get('/api/configs/webdav', (req, res) => {
});
res.json(configs);
} catch (err) {
console.error('GET /api/configs/webdav error:', err);
logger.errorFromError('configs.getWebDAV', 'GET /api/configs/webdav error', err);
res.status(500).json({ error: 'Failed to fetch WebDAV configs', code: 'FETCH_WEBDAV_CONFIGS_FAILED' });
}
});
Expand All @@ -316,7 +314,7 @@ router.post('/api/configs/webdav', (req, res) => {

res.status(201).json({ id: result.lastInsertRowid, name, url, username, password: maskPassword(password as string), path, isActive: !!isActive });
} catch (err) {
console.error('POST /api/configs/webdav error:', err);
logger.errorFromError('configs.createWebDAV', 'POST /api/configs/webdav error', err);
res.status(500).json({ error: 'Failed to create WebDAV config', code: 'CREATE_WEBDAV_CONFIG_FAILED' });
}
});
Expand Down Expand Up @@ -365,7 +363,7 @@ router.put('/api/configs/webdav/bulk', (req, res) => {
try {
encryptedPwd = encrypt(String(c.password), config.encryptionKey);
} catch (encErr) {
console.error(`[configs] Failed to encrypt WebDAV password for "${c.name}" (${c.id}), falling back to existing:`, encErr);
logger.errorFromError('configs.encryptWebDAVPwd', 'Failed to encrypt WebDAV password for config', encErr, { configId: c.id, configName: c.name });
encryptedPwd = existingPwds.get(String(c.id)) ?? '';
if (!encryptedPwd) {
syncResult.skipped.push({ id: c.id, name: c.name ?? '', reason: 'encrypt_failed' });
Expand Down Expand Up @@ -395,7 +393,7 @@ router.put('/api/configs/webdav/bulk', (req, res) => {
}

if (syncResult.skipped.length > 0) {
console.warn('[configs] Skipped WebDAV configs with missing passwords:', syncResult.skipped);
logger.warn('configs.bulkWebDAV', 'Skipped WebDAV configs with missing passwords', { skippedCount: syncResult.skipped.length });
}

// Safety guard: prevent committing an empty database when all configs were skipped
Expand All @@ -408,7 +406,7 @@ router.put('/api/configs/webdav/bulk', (req, res) => {
res.json({ synced: syncResult.inserted, skipped: syncResult.skipped.length, errors: syncResult.skipped });
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
console.error('PUT /api/configs/webdav/bulk error:', err);
logger.errorFromError('configs.bulkWebDAV', 'PUT /api/configs/webdav/bulk error', err);
if (errMsg === 'ALL_CONFIGS_SKIPPED') {
res.status(422).json({
error: 'All WebDAV configs were skipped — check the errors field for per-config reasons',
Expand Down Expand Up @@ -453,7 +451,7 @@ router.put('/api/configs/webdav/:id', (req, res) => {

res.json({ id, name, url, username, password: maskedPwd, path, isActive: !!isActive });
} catch (err) {
console.error('PUT /api/configs/webdav error:', err);
logger.errorFromError('configs.updateWebDAV', 'PUT /api/configs/webdav error', err);
res.status(500).json({ error: 'Failed to update WebDAV config', code: 'UPDATE_WEBDAV_CONFIG_FAILED' });
}
});
Expand All @@ -470,7 +468,7 @@ router.delete('/api/configs/webdav/:id', (req, res) => {
}
res.json({ deleted: true });
} catch (err) {
console.error('DELETE /api/configs/webdav error:', err);
logger.errorFromError('configs.deleteWebDAV', 'DELETE /api/configs/webdav error', err);
res.status(500).json({ error: 'Failed to delete WebDAV config', code: 'DELETE_WEBDAV_CONFIG_FAILED' });
}
});
Expand Down Expand Up @@ -503,7 +501,7 @@ router.get('/api/settings', (_req, res) => {

res.json(settings);
} catch (err) {
console.error('GET /api/settings error:', err);
logger.errorFromError('configs.getSettings', 'GET /api/settings error', err);
res.status(500).json({ error: 'Failed to fetch settings', code: 'FETCH_SETTINGS_FAILED' });
}
});
Expand Down Expand Up @@ -543,7 +541,7 @@ router.put('/api/settings', (req, res) => {
upsert();
res.json({ updated: true });
} catch (err) {
console.error('PUT /api/settings error:', err);
logger.errorFromError('configs.updateSettings', 'PUT /api/settings error', err);
res.status(500).json({ error: 'Failed to update settings', code: 'UPDATE_SETTINGS_FAILED' });
}
});
Expand Down
43 changes: 43 additions & 0 deletions server/src/routes/logs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Router } from 'express';
import { logger, LogLevel } from '../services/logger.js';

const router = Router();

const ALLOWED_LEVELS: readonly LogLevel[] = ['debug', 'info', 'warn', 'error'];

// GET /api/logs — returns recent backend log entries
router.get('/api/logs', (req, res) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 1000, 2000);

// Validate level parameter
const rawLevel = typeof req.query.level === 'string' ? req.query.level : undefined;
if (rawLevel && !ALLOWED_LEVELS.includes(rawLevel as LogLevel)) {
res.status(400).json({ error: 'Invalid log level', code: 'INVALID_LOG_LEVEL' });
return;
}
const level = rawLevel as LogLevel | undefined;

// Validate since parameter
const rawSince = typeof req.query.since === 'string' ? req.query.since : undefined;
if (rawSince && Number.isNaN(Date.parse(rawSince))) {
res.status(400).json({ error: 'Invalid since value', code: 'INVALID_SINCE' });
return;
}
const since = rawSince;

// Get all matching entries first for count, then apply limit
const allEntries = logger.getEntries({ level, since });
const total = allEntries.length;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const entries = limit > 0 ? allEntries.slice(-limit) : allEntries;

// Include total count as header for efficient client-side count queries
res.setHeader('X-Log-Count', String(total));
res.json(entries);
} catch (err) {
logger.errorFromError('logs.getLogs', 'Failed to fetch logs', err);
res.status(500).json({ error: 'Failed to fetch logs', code: 'FETCH_LOGS_FAILED' });
}
});

export default router;
Loading
Loading