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
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