diff --git a/server/src/db/connection.ts b/server/src/db/connection.ts index 3b6b1a8e..d22c2aab 100644 --- a/server/src/db/connection.ts +++ b/server/src/db/connection.ts @@ -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; @@ -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'); } } diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 5d65e695..07104db3 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import { initializeSchema } from './schema.js'; +import { logger } from '../services/logger.js'; const migrations: Record void> = { 1: (db) => { @@ -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.`); } } }); diff --git a/server/src/index.ts b/server/src/index.ts index 42ba4200..d7ef9e5c 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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'; @@ -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 @@ -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); @@ -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); }); }; diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts index 43862017..5d51d137 100644 --- a/server/src/middleware/errorHandler.ts +++ b/server/src/middleware/errorHandler.ts @@ -1,4 +1,5 @@ import type { Request, Response, NextFunction } from 'express'; +import { logger } from '../services/logger.js'; export function errorHandler( err: Error, @@ -6,7 +7,7 @@ export function errorHandler( 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); diff --git a/server/src/routes/configs.ts b/server/src/routes/configs.ts index 4e62735d..15271730 100644 --- a/server/src/routes/configs.ts +++ b/server/src/routes/configs.ts @@ -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(); @@ -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' }; } } @@ -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' }); } }); @@ -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' }); } }); @@ -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' }); @@ -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 @@ -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', @@ -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' }); } }); @@ -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' }); } }); @@ -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' }); } }); @@ -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' }); } }); @@ -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' }); @@ -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 @@ -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', @@ -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' }); } }); @@ -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' }); } }); @@ -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' }); } }); @@ -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' }); } }); diff --git a/server/src/routes/logs.ts b/server/src/routes/logs.ts new file mode 100644 index 00000000..0bd0ade1 --- /dev/null +++ b/server/src/routes/logs.ts @@ -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; + 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; \ No newline at end of file diff --git a/server/src/routes/proxy.ts b/server/src/routes/proxy.ts index 31c5c0c7..62c33501 100644 --- a/server/src/routes/proxy.ts +++ b/server/src/routes/proxy.ts @@ -3,6 +3,7 @@ import { getDb } from '../db/connection.js'; import { encrypt, decrypt } from '../services/crypto.js'; import { config } from '../config.js'; import { proxyRequest, ProxyConfig } from '../services/proxyService.js'; +import { logger } from '../services/logger.js'; function getProxyConfig(): ProxyConfig | null { try { @@ -101,7 +102,7 @@ router.post('/api/proxy/github/*', async (req, res) => { const result = await proxyRequest({ url: targetUrl, method, headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { - console.error('GitHub proxy error:', err); + logger.errorFromError('proxy.github', 'GitHub proxy error', err); res.status(500).json({ error: 'GitHub proxy failed', code: 'GITHUB_PROXY_FAILED' }); } }); @@ -143,7 +144,7 @@ router.post('/api/proxy/ai', async (req, res) => { try { const parsed = new URL(baseUrl); if (parsed.protocol !== 'https:') { - console.warn(`[Proxy] ⚠️ AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); + logger.warn('proxy.ai', `AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); } } catch { /* invalid URL, will be caught by validateUrl later */ } } else if (configId) { @@ -161,7 +162,7 @@ router.post('/api/proxy/ai', async (req, res) => { try { const parsed = new URL(baseUrl); if (parsed.protocol !== 'https:') { - console.warn(`[Proxy] ⚠️ AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); + logger.warn('proxy.ai', `AI API key transmitted over ${parsed.protocol} (not HTTPS). Consider using HTTPS for security.`); } } catch { /* invalid URL, will be caught by validateUrl later */ } } else { @@ -220,7 +221,7 @@ router.post('/api/proxy/ai', async (req, res) => { res.status(result.status).json(result.data); } catch (err) { - console.error('AI proxy error:', err); + logger.errorFromError('proxy.ai', 'AI proxy error', err); res.status(500).json({ error: 'AI proxy failed', code: 'AI_PROXY_FAILED' }); } }); @@ -277,7 +278,7 @@ router.post('/api/proxy/webdav', async (req, res) => { res.status(result.status).json(result.data); } catch (err) { - console.error('WebDAV proxy error:', err); + logger.errorFromError('proxy.webdav', 'WebDAV proxy error', err); res.status(500).json({ error: 'WebDAV proxy failed', code: 'WEBDAV_PROXY_FAILED' }); } }); @@ -317,7 +318,7 @@ router.post('/api/proxy/github/search/repositories', async (req, res) => { const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { - console.error('GitHub search repositories proxy error:', err); + logger.errorFromError('proxy.github.search', 'GitHub search repositories proxy error', err); res.status(500).json({ error: 'GitHub search proxy failed', code: 'GITHUB_SEARCH_PROXY_FAILED' }); } }); @@ -357,7 +358,7 @@ router.post('/api/proxy/github/search/users', async (req, res) => { const result = await proxyRequest({ url: targetUrl, method: 'GET', headers, proxyConfig }); res.status(result.status).json(result.data); } catch (err) { - console.error('GitHub search users proxy error:', err); + logger.errorFromError('proxy.github.search', 'GitHub search users proxy error', err); res.status(500).json({ error: 'GitHub search proxy failed', code: 'GITHUB_SEARCH_PROXY_FAILED' }); } }); @@ -416,7 +417,7 @@ router.put('/api/settings/proxy', (req, res) => { res.json({ success: true }); } catch (err) { - console.error('Failed to save proxy config:', err); + logger.errorFromError('proxy.settings', 'Failed to save proxy config', err); res.status(500).json({ error: 'Failed to save proxy config' }); } }); diff --git a/server/src/services/logSanitizer.ts b/server/src/services/logSanitizer.ts new file mode 100644 index 00000000..55635efa --- /dev/null +++ b/server/src/services/logSanitizer.ts @@ -0,0 +1,146 @@ +/** + * Backend log sanitization utility — masks all sensitive data at write time. + * Logic mirrors the frontend sanitizer but lives in the server bundle. + */ + +// Sensitive field names that trigger masking +const SENSITIVE_FIELD_NAMES = new Set([ + 'apiKey', 'api_key', 'api_key_encrypted', 'password', 'password_encrypted', + 'secret', 'token', 'githubToken', 'accessToken', 'authorization', + 'x-api-key', 'credentials', 'passwd', 'pwd', 'backendApiSecret', +]); + +// URL query param keys to redact +const SENSITIVE_URL_PARAMS = ['key', 'api_key', 'apikey', 'token', 'access_token', 'secret', 'client_secret', 'password', 'auth']; + +// Patterns for token/key detection +const GITHUB_TOKEN_RE = /^ghp_[a-zA-Z0-9]{36}$/; +const GENERIC_SECRET_RE = /^[a-zA-Z0-9+/=_-]{20,}$/; + +/** + * Mask a secret string: show only last 4 chars. + */ +export function maskSecret(value: string): string { + if (!value || value.length <= 4) return '****'; + return '***' + value.slice(-4); +} + +/** + * Mask an email: keep domain, mask local part. + */ +export function maskEmail(email: string): string { + const atIndex = email.indexOf('@'); + if (atIndex <= 0) return '***@***'; + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const maskedLocal = local.length <= 2 ? '**' : local[0] + '***'; + return maskedLocal + '@' + domain; +} + +/** + * Redact sensitive query params from a URL string. + */ +export function redactUrl(url: string): string { + try { + const parsed = new URL(url); + for (const [key] of parsed.searchParams) { + if (SENSITIVE_URL_PARAMS.includes(key.toLowerCase())) { + parsed.searchParams.set(key, '***'); + } + } + return parsed.toString(); + } catch { + return url; + } +} + +function isGitHubToken(value: string): boolean { + return GITHUB_TOKEN_RE.test(value); +} + +function looksLikeSecret(value: string): boolean { + return value.length >= 20 && GENERIC_SECRET_RE.test(value); +} + +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +/** + * Recursively sanitize an object for logging. + */ +export function sanitizeForLog(input: unknown, seen: WeakSet = new WeakSet()): unknown { + if (input === null || input === undefined) return input; + if (typeof input === 'string') return sanitizeString(input); + if (typeof input === 'number' || typeof input === 'boolean') return input; + if (typeof input === 'object') { + if (seen.has(input as object)) return '[Circular]'; + seen.add(input as object); + const result = Array.isArray(input) + ? input.map((v) => sanitizeForLog(v, seen)) + : sanitizeObject(input as Record, seen); + seen.delete(input as object); + return result; + } + return sanitizeString(String(input)); +} + +function sanitizeString(value: string): string { + if (isGitHubToken(value)) return maskSecret(value); + if (looksLikeSecret(value)) return maskSecret(value); + if (EMAIL_RE.test(value)) return maskEmail(value); + if (value.startsWith('http://') || value.startsWith('https://')) return redactUrl(value); + if (value.startsWith('Bearer ') || value.startsWith('bearer ')) return value.slice(0, 7) + maskSecret(value.slice(7)); + if (value.startsWith('Basic ') || value.startsWith('basic ')) return value.slice(0, 6) + '***'; + return value; +} + +function sanitizeObject(obj: Record, seen: WeakSet): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + if (SENSITIVE_FIELD_NAMES.has(key) || SENSITIVE_FIELD_NAMES.has(lowerKey)) { + result[key] = typeof value === 'string' ? maskSecret(value) : '****'; + continue; + } + if (lowerKey.includes('password') || lowerKey.includes('passwd') || lowerKey.includes('pwd')) { + result[key] = typeof value === 'string' ? maskSecret(value) : '****'; + continue; + } + if (lowerKey.includes('token') || lowerKey.includes('secret') || lowerKey.includes('apikey')) { + result[key] = typeof value === 'string' ? maskSecret(value) : '****'; + continue; + } + if (typeof value === 'object' && value !== null && (lowerKey === 'headers' || lowerKey === 'header')) { + result[key] = sanitizeHeaders(value as Record, seen); + continue; + } + result[key] = sanitizeForLog(value, seen); + } + return result; +} + +function sanitizeHeaders(headers: Record, seen: WeakSet): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + if (lowerKey === 'authorization' || lowerKey === 'x-api-key') { + result[key] = typeof value === 'string' ? sanitizeString(value) : '****'; + } else { + result[key] = sanitizeForLog(value, seen); + } + } + return result; +} + +/** + * Sanitize an Error object for logging. + */ +export function sanitizeError(err: unknown): { message: string; stack?: string; name?: string } { + if (!(err instanceof Error)) { + return { message: sanitizeString(String(err)) }; + } + return { + name: err.name, + message: sanitizeString(err.message), + stack: err.stack ? sanitizeString(err.stack) : undefined, + }; +} \ No newline at end of file diff --git a/server/src/services/logger.ts b/server/src/services/logger.ts new file mode 100644 index 00000000..fd51f130 --- /dev/null +++ b/server/src/services/logger.ts @@ -0,0 +1,145 @@ +/** + * Backend Logger — ring-buffer in-memory, write-time sanitization, console forwarding, + * Morgan stream integration. + */ + +import { sanitizeForLog, sanitizeError } from './logSanitizer.js'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + id: string; + timestamp: string; + level: LogLevel; + module: string; + message: string; + data?: unknown; + source: 'backend'; +} + +const LEVEL_ORDER: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +class Logger { + private buffer: LogEntry[] = []; + private maxEntries = 2000; + private minLevel: LogLevel = 'info'; + + log(level: LogLevel, module: string, message: string, data?: unknown): void { + if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) return; + + const sanitizedMessage = typeof message === 'string' ? sanitizeForLog(message) as string : String(message); + const sanitizedData = data !== undefined ? sanitizeForLog(data) : undefined; + + const entry: LogEntry = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + level, + module, + message: sanitizedMessage, + data: sanitizedData, + source: 'backend', + }; + + this.buffer.push(entry); + if (this.buffer.length > this.maxEntries) { + this.buffer.shift(); + } + + this.forwardToConsole(entry); + } + + debug(module: string, message: string, data?: unknown): void { + this.log('debug', module, message, data); + } + + info(module: string, message: string, data?: unknown): void { + this.log('info', module, message, data); + } + + warn(module: string, message: string, data?: unknown): void { + this.log('warn', module, message, data); + } + + error(module: string, message: string, data?: unknown): void { + this.log('error', module, message, data); + } + + errorFromError(module: string, message: string, err: unknown, extra?: unknown): void { + const sanitizedExtra = extra !== undefined && typeof extra === 'object' && extra !== null && !Array.isArray(extra) + ? sanitizeForLog(extra) as Record + : extra !== undefined + ? { extra: sanitizeForLog(extra) } + : {}; + this.log('error', module, message, { ...sanitizeError(err), ...sanitizedExtra }); + } + + private forwardToConsole(entry: LogEntry): void { + const prefix = `[${entry.module}]`; + const dataStr = entry.data !== undefined ? entry.data : ''; + switch (entry.level) { + case 'debug': + console.debug(prefix, entry.message, dataStr); + break; + case 'info': + console.info(prefix, entry.message, dataStr); + break; + case 'warn': + console.warn(prefix, entry.message, dataStr); + break; + case 'error': + console.error(prefix, entry.message, dataStr); + break; + } + } + + getEntries(filter?: { level?: LogLevel; since?: string; limit?: number }): LogEntry[] { + let entries = this.buffer; + if (filter?.level) { + const minOrder = LEVEL_ORDER[filter.level]; + entries = entries.filter(e => LEVEL_ORDER[e.level] >= minOrder); + } + if (filter?.since) { + entries = entries.filter(e => e.timestamp >= filter.since!); + } + if (filter?.limit && filter.limit > 0) { + entries = entries.slice(-filter.limit); + } + return entries; + } + + getCounts(): { total: number; debug: number; info: number; warn: number; error: number } { + const counts = { total: this.buffer.length, debug: 0, info: 0, warn: 0, error: 0 }; + for (const entry of this.buffer) { + counts[entry.level]++; + } + return counts; + } + + clear(): void { + this.buffer = []; + } + + setLevel(level: LogLevel): void { + this.minLevel = level; + } +} + +export const logger = new Logger(); + +/** + * Morgan stream that writes HTTP access logs into the Logger ring buffer. + */ +export const morganLoggerStream = { + write(line: string): void { + // Morgan 'combined' format line: strip trailing newline + const trimmed = line.trim(); + if (trimmed) { + logger.info('http.access', trimmed); + } + }, +}; \ No newline at end of file diff --git a/server/src/services/proxyService.ts b/server/src/services/proxyService.ts index 63ac9b03..ec01d970 100644 --- a/server/src/services/proxyService.ts +++ b/server/src/services/proxyService.ts @@ -1,4 +1,6 @@ import axios, { AxiosRequestConfig } from 'axios'; +import { logger } from './logger.js'; +import { redactUrl } from './logSanitizer.js'; export interface ProxyConfig { enabled: boolean; @@ -24,18 +26,6 @@ export interface ProxyResponse { data: unknown; } -function redactUrl(rawUrl: string): string { - try { - const url = new URL(rawUrl); - for (const key of ['key', 'api_key', 'apikey', 'token', 'access_token', 'secret', 'client_secret', 'password', 'auth']) { - if (url.searchParams.has(key)) url.searchParams.set(key, '***'); - } - return url.toString(); - } catch { - return rawUrl; - } -} - const BLOCKED_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '169.254.169.254']); const PRIVATE_IP_PATTERNS = [/^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./]; @@ -73,7 +63,7 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise ${response.status}`); + logger.info('proxy.response', `${method} ${redactUrl(url)} -> ${response.status}`); const responseHeaders: Record = {}; if (response.headers) { @@ -168,7 +158,7 @@ export async function proxyRequest(options: ProxyRequestOptions): Promise { } componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - console.error('[ErrorBoundary] Caught error:', error); - console.error('[ErrorBoundary] Error info:', errorInfo); + logger.errorFromError('ui.errorBoundary', 'Caught error', error, { message: error.message, componentStack: errorInfo.componentStack }); this.setState({ error, errorInfo }); } @@ -73,7 +73,7 @@ export class ErrorBoundary extends Component { try { await navigator.clipboard.writeText(errorText); } catch (e) { - console.error('Failed to copy:', e); + logger.errorFromError('ui.errorBoundary', 'Failed to copy', e); } }; diff --git a/src/components/settings/DataManagementPanel.tsx b/src/components/settings/DataManagementPanel.tsx index cd48e590..2590b1c2 100644 --- a/src/components/settings/DataManagementPanel.tsx +++ b/src/components/settings/DataManagementPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useMemo } from 'react'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; import { Trash2, AlertTriangle, @@ -21,9 +21,15 @@ import { HardDrive, RefreshCw, Rss, + FileText, + ShieldCheck, } from 'lucide-react'; import { useAppStore } from '../../store/useAppStore'; import { indexedDBStorage } from '../../services/indexedDbStorage'; +import { logger, LogLevel } from '../../services/logger'; +import { backend } from '../../services/backendAdapter'; +import { maskUrlDomain } from '../../utils/logSanitizer'; +import { version as appVersion } from '../../../package.json'; import type { Repository, Release, @@ -145,6 +151,7 @@ export const DataManagementPanel: React.FC = ({ t }) = const [, setSearchHistoryVersion] = useState(0); const [showErrorMessage, setShowErrorMessage] = useState(null); const [isExporting, setIsExporting] = useState(false); + const [isExportingLogs, setIsExportingLogs] = useState(false); const [isImporting, setIsImporting] = useState(false); const [importPreview, setImportPreview] = useState<{ data: ExportData | null; @@ -163,6 +170,159 @@ export const DataManagementPanel: React.FC = ({ t }) = setOperationLogs((prev) => [newLog, ...prev].slice(0, 50)); }, []); + // Log export: counts and state + const [frontendLogCount, setFrontendLogCount] = useState(0); + const [backendLogCount, setBackendLogCount] = useState(0); + const [logCounts, setLogCounts] = useState>({ debug: 0, info: 0, warn: 0, error: 0 }); + const backendAvailable = backend.isAvailable; + + useEffect(() => { + const updateCounts = () => { + const counts = logger.getCounts(); + setFrontendLogCount(counts.total); + setLogCounts({ debug: counts.debug, info: counts.info, warn: counts.warn, error: counts.error }); + }; + updateCounts(); + const interval = setInterval(updateCounts, 2000); + return () => clearInterval(interval); + }, []); + + useEffect(() => { + if (!backendAvailable) { + setBackendLogCount(0); + return; + } + const fetchBackendCount = async () => { + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + const res = await fetch('/api/logs?limit=1', { + headers: { Authorization: `Bearer ${secret}` }, + }); + if (res.ok) { + // Use the total count from the response header + const totalHeader = res.headers.get('X-Log-Count'); + if (totalHeader) { + setBackendLogCount(parseInt(totalHeader) || 0); + } else { + // Fallback: fetch all logs to count (heavy but works) + const allRes = await fetch('/api/logs?limit=2000', { + headers: { Authorization: `Bearer ${secret}` }, + }); + if (allRes.ok) { + const logs = await allRes.json(); + setBackendLogCount(Array.isArray(logs) ? logs.length : 0); + } + } + } + } catch { + // Backend not reachable — keep count at 0 + } + }; + fetchBackendCount(); + const interval = setInterval(fetchBackendCount, 10000); + return () => clearInterval(interval); + }, [backendAvailable]); + + const handleExportLogs = async () => { + setIsExportingLogs(true); + try { + // Gather selected scopes + const scopeCheckboxes = document.querySelectorAll('.log-scope-checkbox:checked'); + const selectedScopes = Array.from(scopeCheckboxes).map(cb => (cb as HTMLInputElement).dataset.scope as string); + // Gather selected levels + const levelCheckboxes = document.querySelectorAll('.log-level-checkbox:checked'); + const selectedLevels = Array.from(levelCheckboxes).map(cb => (cb as HTMLInputElement).dataset.level as LogLevel); + + if (selectedLevels.length === 0) { + showError(t('请至少选择一个日志级别', 'Please select at least one log level')); + setIsExportingLogs(false); + return; + } + + if (selectedScopes.length === 0) { + showError(t('请至少选择一个日志范围', 'Please select at least one log scope')); + setIsExportingLogs(false); + return; + } + + // Determine min level for filtering + const levelOrder: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + const minLevel = selectedLevels.reduce((min, lvl) => Math.min(min, levelOrder[lvl]), 3); + const minLevelName = (Object.entries(levelOrder).find(([_, v]) => v === minLevel)?.[0] as LogLevel) || 'debug'; + + // Fetch frontend logs + let frontendLogs = selectedScopes.includes('frontend') + ? logger.getEntries({ level: minLevelName }) + : []; + // Filter by explicit membership to honor exact level selection + frontendLogs = frontendLogs.filter((e) => selectedLevels.includes(e.level)); + + // Fetch backend logs + let backendLogs: Array<{ level: string }> = []; + if (selectedScopes.includes('backend') && backendAvailable) { + try { + const res = await fetch(`/api/logs?limit=2000&level=${minLevelName}`, { + headers: { Authorization: `Bearer ${sessionStorage.getItem('github-stars-manager-backend-secret')}` }, + }); + if (res.ok) { + const raw = await res.json(); + backendLogs = Array.isArray(raw) ? raw.filter((e: { level: string }) => selectedLevels.includes(e.level as LogLevel)) : []; + } + } catch { + // Backend unreachable — skip + } + } + + // Build environment info + const state = useAppStore.getState(); + const isElectron = typeof window !== 'undefined' && window.electronAPI; + const environment = { + platform: isElectron ? 'electron' : 'web', + deployMode: isElectron ? 'electron' : 'web', + electronVersion: isElectron ? navigator.userAgent.match(/Electron\/([\d.]+)/)?.[1] ?? 'unknown' : null, + osPlatform: navigator.platform, + screenResolution: `${screen.width}x${screen.height}`, + backendAvailable: backendAvailable, + backendUrl: backendAvailable ? maskUrlDomain(backend.backendUrl || '') : null, + language: state.language, + repoCount: state.repositories?.length ?? 0, + aiConfigCount: state.aiConfigs?.length ?? 0, + webdavConfigCount: state.webdavConfigs?.length ?? 0, + storeHydrated: true, + lastSyncTime: state.lastSync ?? null, + appVersion, + }; + + const exportData = { + format: 'github-stars-manager-logs-v1', + exportDate: new Date().toISOString(), + appVersion, + environment, + sanitizationNote: t('所有 Token、API Key、密码、邮箱已脱敏为 ***格式', 'All tokens, API keys, passwords, and emails have been masked as ***'), + frontendLogs, + backendLogs, + }; + + // Download + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `github-stars-manager-logs-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + addLog(t('导出日志', 'Export logs'), true); + } catch (err) { + addLog(t('导出日志', 'Export logs'), false, String(err)); + showError(t('导出日志失败', 'Failed to export logs')); + } finally { + setIsExportingLogs(false); + } + }; + const showSuccess = useCallback((message: string) => { setShowSuccessMessage(message); setTimeout(() => setShowSuccessMessage(null), 3000); @@ -1288,6 +1448,104 @@ export const DataManagementPanel: React.FC = ({ t }) = + {/* Log Export */} +
+

+ + {t('日志导出', 'Log Export')} +

+
+
+
+ +
+
+

{t('导出应用日志', 'Export App Logs')}

+

{t('导出日志用于问题排查,所有敏感信息已自动脱敏', 'Export logs for troubleshooting. All sensitive info is automatically sanitized.')}

+
+
+ + {/* Log scope */} +
+

{t('日志范围', 'Log Scope')}

+
+ + +
+
+ + {/* Log level */} +
+

{t('日志级别', 'Log Level')}

+
+ {([ + { level: 'info' as LogLevel, label: t('Info', 'Info'), defaultChecked: true }, + { level: 'warn' as LogLevel, label: t('Warn', 'Warn'), defaultChecked: true }, + { level: 'error' as LogLevel, label: t('Error', 'Error'), defaultChecked: true }, + { level: 'debug' as LogLevel, label: t('Debug', 'Debug'), defaultChecked: false }, + ]).map((item) => ( + + ))} +
+
+ + {/* Sanitization notice */} +
+ + {t('所有敏感信息(Token、API Key、密码、邮箱等)已自动脱敏为 ***格式', 'All sensitive info (Token, API Key, password, email) is automatically masked as ***')} +
+ + {/* Export button */} + +
+
+ {/* Data Cleanup Suggestions */} {cleanupSuggestions.length > 0 && (
diff --git a/src/main.tsx b/src/main.tsx index 5db31d13..440ca218 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,8 +7,9 @@ import App from './App.tsx'; import './index.css'; import { ErrorBoundary } from './components/ErrorBoundary.tsx'; import { DialogProvider } from './hooks/useDialog'; +import { logger } from './services/logger'; -console.log('Main.tsx loading...'); +logger.info('app', 'Main.tsx loading'); try { const rootElement = document.getElementById('root'); @@ -16,7 +17,7 @@ try { throw new Error('Root element not found'); } - console.log('Root element found, creating React root...'); + logger.info('app', 'Root element found, creating React root'); const root = createRoot(rootElement); root.render( @@ -29,9 +30,9 @@ try { ); - console.log('React app rendered'); + logger.info('app', 'React app rendered'); } catch (error) { - console.error('Failed to render React app:', error); + logger.error('app', 'Failed to render React app', error); const strings = (() => { const lang = navigator.language?.startsWith('zh') ? 'zh' : 'en'; return { diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 5b4d3a67..3a76a7c2 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -1,6 +1,7 @@ import { Repository, AIConfig, AIApiType } from '../types'; import { backend } from './backendAdapter'; import { buildApiUrl, buildFinalApiUrl } from '../utils/apiUrlBuilder'; +import { logger } from './logger'; interface OpenAIResponseContentPart { text?: string; @@ -333,7 +334,7 @@ ${options.user}` : options.user; return this.parseAIResponse(content); } catch (error) { - console.error('AI analysis failed:', error); + logger.errorFromError('ai', 'AI analysis failed', error, { configId: this.config.id }); // 抛出错误,让调用方处理失败状态 throw error; } @@ -452,7 +453,7 @@ Focus on practicality and accurate categorization to help users quickly understa platforms: [], }; } catch (error) { - console.error('Failed to parse AI response:', error); + logger.errorFromError('ai', 'Failed to parse AI response', error); return { summary: this.language === 'zh' ? '分析失败' : 'Analysis failed', tags: [], @@ -656,7 +657,7 @@ Focus on practicality and accurate categorization to help users quickly understa return this.performEnhancedSearch(repositories, query, searchTerms); } } catch (error) { - console.warn('AI search failed, falling back to basic search:', error); + logger.warn('ai', 'AI search failed, falling back to basic search', { configId: this.config.id }); } // Fallback to basic search @@ -674,11 +675,11 @@ Focus on practicality and accurate categorization to help users quickly understa * @returns Filtered and ranked repositories matching the query */ async searchRepositoriesWithReranking(repositories: Repository[], query: string): Promise { - console.log('🤖 AI Service: Starting enhanced search for:', query); + logger.info('ai', 'Starting enhanced search', { query }); if (!query.trim()) return repositories; try { - console.log('🚀 AI Service: Calling configured AI service for semantic search'); + logger.info('ai', 'Calling configured AI service for semantic search', { apiType: this.getApiType(), model: this.config.model, configId: this.config.id }); const searchPrompt = this.createSearchPrompt(query); const system = this.language === 'zh' ? '你是一个智能搜索助手。请分析用户的搜索意图,提取关键词并提供多语言翻译。' @@ -694,16 +695,16 @@ Focus on practicality and accurate categorization to help users quickly understa if (content) { const searchTerms = this.parseSearchResponse(content); const results = this.performEnhancedSearch(repositories, query, searchTerms); - console.log('✨ AI Service: AI semantic search completed, results:', results.length); + logger.info('ai', 'AI semantic search completed', { resultCount: results.length, apiType: this.getApiType(), model: this.config.model }); return results; } } catch (error) { - console.warn('❌ AI Service: AI semantic search failed, falling back to enhanced basic search:', error); + logger.warn('ai', 'AI semantic search failed, falling back to enhanced basic search', { apiType: this.getApiType(), model: this.config.model, configId: this.config.id }); } - console.log('🔄 AI Service: Using enhanced basic search with intelligent ranking'); + logger.info('ai', 'Using enhanced basic search with intelligent ranking'); const fallbackResults = this.performEnhancedBasicSearch(repositories, query); - console.log('✨ AI Service: Enhanced search completed, results:', fallbackResults.length); + logger.info('ai', 'Enhanced search completed', { resultCount: fallbackResults.length }); return fallbackResults; } @@ -828,7 +829,7 @@ Reply in JSON format: return allTerms.filter(term => typeof term === 'string' && term.length > 0); } } catch (error) { - console.warn('Failed to parse AI search response:', error); + logger.warn('ai', 'Failed to parse AI search response', { error: String(error) }); } return []; } diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index eacf1145..747497b3 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -1,6 +1,7 @@ import { backend } from './backendAdapter'; import { useAppStore } from '../store/useAppStore'; import { mergeRepositoriesPreservingLocalMetadata } from '../utils/repositoryMerge'; +import { logger } from './logger'; // Prevent sync loops: when we pull data FROM backend and update store, // the store subscription would trigger a push TO backend. This flag blocks that. @@ -158,7 +159,7 @@ export async function syncFromBackend(): Promise { if (bc.apiKeyStatus === 'decrypt_failed' || !bc.apiKey) { const local = localConfigs.find(lc => lc.id === bc.id); if (local && local.apiKey) { - console.warn(`[sync] Backend decrypt_failed for AI config "${bc.name}", preserving local apiKey`); + logger.warn('sync.decryptFailed', `Backend decrypt_failed for AI config "${bc.name}", preserving local apiKey`); return { ...bc, apiKey: local.apiKey, apiKeyStatus: 'ok' as const }; } } @@ -178,7 +179,7 @@ export async function syncFromBackend(): Promise { if (bc.passwordStatus === 'decrypt_failed' || !bc.password) { const local = localConfigs.find(lc => lc.id === bc.id); if (local && local.password) { - console.warn(`[sync] Backend decrypt_failed for WebDAV config "${bc.name}", preserving local password`); + logger.warn('sync.decryptFailed', `Backend decrypt_failed for WebDAV config "${bc.name}", preserving local password`); return { ...bc, password: local.password, passwordStatus: 'ok' as const }; } } @@ -226,9 +227,9 @@ export async function syncFromBackend(): Promise { _lastHash.settings = hashes.settings; } - console.log('✅ Synced from backend (data changed)'); + logger.info('sync.pullFromBackend', 'Synced from backend (data changed)', changed); } catch (err) { - console.error('Failed to sync from backend:', err); + logger.errorFromError('sync.pullFromBackend', 'Failed to sync from backend', err); } finally { setRepositorySyncVisualState(false); _isSyncingFromBackend = false; @@ -280,10 +281,10 @@ export async function syncToBackend(): Promise { const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { - console.warn(`⚠️ Synced to backend with ${failures.length} error(s):`, failures.map(f => (f as PromiseRejectedResult).reason)); + logger.warn('sync.pushToBackend', `Synced to backend with ${failures.length} error(s)`, { failureCount: failures.length }); _hasPendingLocalChanges = true; } else { - console.log('✅ Synced to backend'); + logger.info('sync.pushToBackend', 'Synced to backend'); _hasPendingLocalChanges = false; } @@ -304,7 +305,7 @@ export async function syncToBackend(): Promise { }); } } catch (err) { - console.error('Failed to sync to backend:', err); + logger.errorFromError('sync.pushToBackend', 'Failed to sync to backend', err); } finally { setRepositorySyncVisualState(false); _isPushingToBackend = false; @@ -385,7 +386,7 @@ export function startAutoSync(): () => void { syncFromBackend(); }, POLL_INTERVAL); - console.log('🔄 Auto-sync started (push debounce: 2s, poll: 5s)'); + logger.info('sync.start', 'Auto-sync started (push debounce: 2s, poll: 5s)'); return unsubscribe; } @@ -413,5 +414,5 @@ export function stopAutoSync(unsubscribe: () => void): void { _isSyncingFromBackend = false; _hasPendingPush = false; _hasPendingLocalChanges = false; - console.log('🔄 Auto-sync stopped'); + logger.info('sync.stop', 'Auto-sync stopped'); } diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index b9b9b0d4..23623307 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -1,4 +1,5 @@ import { translateBackendError } from '../utils/backendErrors'; +import { logger } from './logger'; import { Repository, Release, AIConfig, WebDAVConfig } from '../types'; import { useAppStore } from '../store/useAppStore'; @@ -29,7 +30,7 @@ class BackendAdapter { const data = await res.json(); if (data.status === 'ok') { this._backendUrl = baseUrl; - console.log(`✅ Backend connected: ${baseUrl}`); + logger.info('backendAdapter', 'Backend connected', { url: baseUrl }); return; } } @@ -41,10 +42,10 @@ class BackendAdapter { } this._backendUrl = null; - console.log('ℹ️ Backend not available, using local-only mode'); + logger.info('backendAdapter', 'Backend not available, using local-only mode'); } catch { this._backendUrl = null; - console.log('ℹ️ Backend not available, using local-only mode'); + logger.info('backendAdapter', 'Backend not available, using local-only mode'); } } @@ -115,7 +116,7 @@ class BackendAdapter { if (!isRetryable || attempt === maxRetries) throw lastError; // Exponential backoff: 1s, 2s, 4s const delay = Math.min(1000 * Math.pow(2, attempt), 4000); - console.warn(`⚠️ Sync request failed (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms...`, lastError.message); + logger.warn('backendAdapter', 'Sync request failed, retrying', { attempt: attempt + 1, maxRetries: maxRetries + 1, delayMs: delay }); await new Promise(resolve => setTimeout(resolve, delay)); } } @@ -354,7 +355,7 @@ class BackendAdapter { // Pre-sync validation: warn about configs that will likely be skipped for (const c of configs) { if (!c.apiKey) { - console.warn(`[sync] AI config "${c.name}" (${c.id}) has empty apiKey, will be skipped if no existing key on backend`); + logger.warn('backendAdapter', 'AI config has empty apiKey, will be skipped', { name: c.name, id: c.id }); } } @@ -394,7 +395,7 @@ class BackendAdapter { // Pre-sync validation: warn about configs that will likely be skipped for (const c of configs) { if (!c.password) { - console.warn(`[sync] WebDAV config "${c.name}" (${c.id}) has empty password, will be skipped if no existing password on backend`); + logger.warn('backendAdapter', 'WebDAV config has empty password, will be skipped', { name: c.name, id: c.id }); } } diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index 6efd5a40..e540b360 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -16,6 +16,7 @@ import { ForkRepo, WorkflowDefinition, } from '../types'; +import { logger } from './logger'; interface GitHubStarredItem { starred_at?: string; @@ -61,7 +62,7 @@ export class GitHubApiService { if (this.rateLimitRemaining !== null && this.rateLimitRemaining < 100 && this.rateLimitReset !== null) { const waitMs = (this.rateLimitReset * 1000) - Date.now(); if (waitMs > 0) { - console.log(`Rate limit low (${this.rateLimitRemaining}), waiting ${Math.ceil(waitMs / 1000)}s for reset...`); + logger.warn('githubApi', 'Rate limit low, waiting for reset', { remaining: this.rateLimitRemaining, resetTime: this.rateLimitReset }); // Honor abort signal during rate limit wait await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => resolve(), waitMs + 1000); @@ -191,7 +192,7 @@ export class GitHubApiService { } return response.content; } catch (error) { - console.warn(`Failed to fetch README for ${owner}/${repo}:`, error); + logger.warn('githubApi', `Failed to fetch README for ${owner}/${repo}`, error); return ''; } } @@ -220,7 +221,7 @@ export class GitHubApiService { }, })); } catch (error) { - console.warn(`Failed to fetch releases for ${owner}/${repo}:`, error); + logger.warn('githubApi', `Failed to fetch releases for ${owner}/${repo}`, error); throw error; // Re-throw to let caller handle } } @@ -402,7 +403,7 @@ export class GitHubApiService { return mappedReleases; } catch (error) { - console.warn(`Failed to fetch incremental releases for ${owner}/${repo}:`, error); + logger.warn('githubApi', `Failed to fetch incremental releases for ${owner}/${repo}`, error); return []; } } @@ -550,7 +551,7 @@ export class GitHubApiService { r.description = data.description; } } catch (e) { - console.warn(`Failed to fetch repo details for ${r.full_name}:`, e); + logger.warn('githubApi', `Failed to fetch repo details for ${r.full_name}`, e); } // 避免 GitHub API 限流 await new Promise(resolve => setTimeout(resolve, 100)); @@ -559,7 +560,7 @@ export class GitHubApiService { return repos; } catch (error) { - console.error('Failed to fetch trending from RSS:', error); + logger.error('githubApi', 'Failed to fetch trending from RSS', error); return []; } } @@ -770,7 +771,7 @@ export class GitHubApiService { r.description = data.description; } } catch (e) { - console.warn(`Failed to fetch repo details for ${r.full_name}:`, e); + logger.warn('githubApi', `Failed to fetch repo details for ${r.full_name}`, e); } // Avoid GitHub API rate limiting await new Promise(resolve => setTimeout(resolve, 80)); @@ -787,7 +788,7 @@ export class GitHubApiService { totalCount: items.length, }; } catch (error) { - console.error('Failed to fetch trending from RSS:', error); + logger.error('githubApi', 'Failed to fetch trending from RSS', error); return { repos: [], hasMore: false, nextPageIndex: 1, totalCount: 0 }; } } @@ -974,7 +975,7 @@ async getUserForks(): Promise { return allForks; } catch (error) { - console.warn('Failed to fetch user forks:', error); + logger.warn('githubApi', 'Failed to fetch user forks', error); throw error; } } @@ -1034,7 +1035,7 @@ async getUserForks(): Promise { parentHtmlUrl: resultParentHtmlUrl }; } catch (error) { - console.warn(`Failed to check sync status for ${owner}/${repo}:`, error); + logger.warn('githubApi', `Failed to check sync status for ${owner}/${repo}`, error); return { needsSync: false }; } } @@ -1044,7 +1045,7 @@ async getUserForks(): Promise { const branches = await this.makeRequest<{ name: string }[]>(`/repos/${owner}/${repo}/branches?per_page=100`); return branches.map(b => b.name); } catch (error) { - console.warn(`Failed to fetch branches for ${owner}/${repo}:`, error); + logger.warn('githubApi', `Failed to fetch branches for ${owner}/${repo}`, error); return []; } } @@ -1057,7 +1058,7 @@ async getUserForks(): Promise { ); return data.workflows || []; } catch (error) { - console.warn(`Failed to fetch workflows for ${owner}/${repo}:`, error); + logger.warn('githubApi', `Failed to fetch workflows for ${owner}/${repo}`, error); return []; } } diff --git a/src/services/logger.ts b/src/services/logger.ts new file mode 100644 index 00000000..8eabb762 --- /dev/null +++ b/src/services/logger.ts @@ -0,0 +1,144 @@ +/** + * Frontend Logger — ring-buffer in-memory, write-time sanitization, console forwarding. + */ + +import { sanitizeForLog, sanitizeError } from '../utils/logSanitizer'; + +export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; + +export interface LogEntry { + id: string; + timestamp: string; + level: LogLevel; + module: string; + message: string; + data?: unknown; + source: 'frontend'; +} + +const LEVEL_ORDER: Record = { + debug: 0, + info: 1, + warn: 2, + error: 3, +}; + +class Logger { + private buffer: LogEntry[] = []; + private maxEntries = 2000; + private minLevel: LogLevel = 'info'; + + log(level: LogLevel, module: string, message: string, data?: unknown): void { + if (LEVEL_ORDER[level] < LEVEL_ORDER[this.minLevel]) return; + + // Sanitize at write time — buffer never contains secrets + const sanitizedMessage = typeof message === 'string' ? sanitizeForLog(message) as string : String(message); + const sanitizedData = data !== undefined ? sanitizeForLog(data) : undefined; + + const entry: LogEntry = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + level, + module, + message: sanitizedMessage, + data: sanitizedData, + source: 'frontend', + }; + + this.buffer.push(entry); + if (this.buffer.length > this.maxEntries) { + this.buffer.shift(); + } + + // Forward to console for dev experience + this.forwardToConsole(entry); + } + + debug(module: string, message: string, data?: unknown): void { + this.log('debug', module, message, data); + } + + info(module: string, message: string, data?: unknown): void { + this.log('info', module, message, data); + } + + warn(module: string, message: string, data?: unknown): void { + this.log('warn', module, message, data); + } + + error(module: string, message: string, data?: unknown): void { + this.log('error', module, message, data); + } + + /** + * Convenience: log an error with sanitized Error object. + */ + errorFromError(module: string, message: string, err: unknown, extra?: unknown): void { + const sanitizedExtra = extra !== undefined && typeof extra === 'object' && extra !== null && !Array.isArray(extra) + ? sanitizeForLog(extra) as Record + : extra !== undefined + ? { extra: sanitizeForLog(extra) } + : {}; + this.log('error', module, message, { ...sanitizeError(err), ...sanitizedExtra }); + } + + private forwardToConsole(entry: LogEntry): void { + const prefix = `[${entry.module}]`; + const dataStr = entry.data !== undefined ? entry.data : ''; + switch (entry.level) { + case 'debug': + console.debug(prefix, entry.message, dataStr); + break; + case 'info': + console.info(prefix, entry.message, dataStr); + break; + case 'warn': + console.warn(prefix, entry.message, dataStr); + break; + case 'error': + console.error(prefix, entry.message, dataStr); + break; + } + } + + getEntries(filter?: { level?: LogLevel; since?: string; module?: string }): LogEntry[] { + let entries = this.buffer; + if (filter?.level) { + const minOrder = LEVEL_ORDER[filter.level]; + entries = entries.filter(e => LEVEL_ORDER[e.level] >= minOrder); + } + if (filter?.since) { + entries = entries.filter(e => e.timestamp >= filter.since!); + } + if (filter?.module) { + entries = entries.filter(e => e.module.startsWith(filter.module!)); + } + return entries; + } + + getCounts(): { total: number; debug: number; info: number; warn: number; error: number } { + const counts = { total: this.buffer.length, debug: 0, info: 0, warn: 0, error: 0 }; + for (const entry of this.buffer) { + counts[entry.level]++; + } + return counts; + } + + clear(): void { + this.buffer = []; + } + + setLevel(level: LogLevel): void { + this.minLevel = level; + } + + /** + * Build the export JSON structure (frontend logs only). + * The UI component merges this with backend logs. + */ + exportEntries(filter?: { level?: LogLevel; since?: string }): LogEntry[] { + return this.getEntries(filter); + } +} + +export const logger = new Logger(); \ No newline at end of file diff --git a/src/services/updateService.ts b/src/services/updateService.ts index f6eb5adc..c432c9b1 100644 --- a/src/services/updateService.ts +++ b/src/services/updateService.ts @@ -7,6 +7,7 @@ export interface VersionInfo { import { PROJECT_REPO_URL } from '../constants/project'; import { version } from '../../package.json'; +import { logger } from './logger'; const REPO_OWNER = PROJECT_REPO_URL.split('/').slice(-2).join('/'); const VERSION_INFO_URL = `https://raw.githubusercontent.com/${REPO_OWNER}/main/versions/version-info.xml`; @@ -53,7 +54,7 @@ export class UpdateService { latestVersion: hasUpdate ? latestVersion : undefined }; } catch (error) { - console.error('检查更新失败:', error); + logger.error('update', '检查更新失败', error); throw error; } } diff --git a/src/services/webdavService.ts b/src/services/webdavService.ts index 2d9e6318..35c69775 100644 --- a/src/services/webdavService.ts +++ b/src/services/webdavService.ts @@ -1,4 +1,5 @@ import { WebDAVConfig } from '../types'; +import { logger } from './logger'; export class WebDAVService { private config: WebDAVConfig; @@ -13,7 +14,7 @@ export class WebDAVService { const data = JSON.parse(content); return JSON.stringify(data); } catch (e) { - console.warn('JSON压缩失败,使用原始内容:', e); + logger.warn('webdav', 'JSON压缩失败,使用原始内容', e); return content; } } @@ -63,7 +64,7 @@ export class WebDAVService { throw lastError; } - console.warn(`上传失败,第${attempt}次重试 (${delay}ms后):`, errMsg); + logger.warn('webdav', `上传失败,第${attempt}次重试`, { attempt, errMsg, delay }); await new Promise(resolve => setTimeout(resolve, delay)); delay *= 2; // 指数退避 } @@ -83,7 +84,7 @@ export class WebDAVService { } private handleNetworkError(error: unknown, operation: string): never { - console.error(`WebDAV ${operation} failed:`, error); + logger.error('webdav', `WebDAV ${operation} failed`, error); const err = error as Error; const isCorsError = ( @@ -189,10 +190,10 @@ export class WebDAVService { const compressedContent = this.compressData(content); if (fileAnalysis.isLarge) { - console.warn(`大文件备份 (${fileAnalysis.sizeKB}KB):`, fileAnalysis.suggestions.join(', ')); + logger.warn('webdav', '大文件备份', { sizeKB: fileAnalysis.sizeKB, suggestions: fileAnalysis.suggestions }); } - console.log(`文件大小: ${fileAnalysis.sizeKB}KB,压缩后: ${Math.round(compressedContent.length / 1024)}KB`); + logger.info('webdav', '文件大小', { sizeKB: fileAnalysis.sizeKB, compressedKB: Math.round(compressedContent.length / 1024) }); // 确保目录存在 await this.ensureDirectoryExists(); @@ -200,7 +201,7 @@ export class WebDAVService { // 动态计算超时时间:基于压缩后文件大小,最小60秒,最大300秒 const finalSizeKB = Math.round(compressedContent.length / 1024); const dynamicTimeout = Math.max(60000, Math.min(300000, finalSizeKB * 100)); // 每KB 100ms - console.log(`设置超时时间: ${dynamicTimeout}ms`); + logger.info('webdav', '设置超时时间', { dynamicTimeout }); const uploadOperation = async (): Promise => { const controller = new AbortController(); @@ -287,17 +288,17 @@ export class WebDAVService { if (!res.ok && res.status !== 405) { // 某些服务器对已存在目录返回 409 Conflict if (res.status !== 409) { - console.warn(`无法创建目录 ${currentPath},状态码: ${res.status}`); + logger.warn('webdav', '无法创建目录', { currentPath, status: res.status }); break; // 不再继续往下建 } } } catch (e) { - console.warn(`创建目录 ${currentPath} 发生异常:`, e); + logger.warn('webdav', '创建目录发生异常', { currentPath, error: e }); break; } } } catch (error) { - console.warn('目录创建检查失败:', error); + logger.warn('webdav', '目录创建检查失败', error); // 不在这里抛出错误,因为目录可能已经存在 } } @@ -369,7 +370,7 @@ export class WebDAVService { clearTimeout(timeoutId); return response.ok; } catch (error) { - console.error('WebDAV文件检查失败:', error); + logger.error('webdav', 'WebDAV文件检查失败', error); return false; } } @@ -529,7 +530,7 @@ export class WebDAVService { }; } } catch (error) { - console.warn('无法获取服务器信息:', error); + logger.warn('webdav', '无法获取服务器信息', error); } return {}; diff --git a/src/store/useAppStore.ts b/src/store/useAppStore.ts index dbb0925c..43dd30ef 100644 --- a/src/store/useAppStore.ts +++ b/src/store/useAppStore.ts @@ -27,6 +27,7 @@ import { defaultSubscriptionChannels } from '../types'; import { indexedDBStorage } from '../services/indexedDbStorage'; +import { logger } from '../services/logger'; import { PRESET_FILTERS } from '../constants/presetFilters'; const BACKEND_SECRET_SESSION_KEY = 'github-stars-manager-backend-secret'; @@ -54,7 +55,7 @@ const debouncedPersistStorage: PersistStorage = { const str = JSON.stringify(latestValue); indexedDBStorage.setItem(name, str); } catch (e) { - console.error('Failed to stringify state for persistence', e); + logger.errorFromError('store.persist', 'Failed to stringify state for persistence', e); } }, 1000); }; @@ -778,11 +779,11 @@ export const useAppStore = create()( // Auth actions setUser: (user) => { - console.log('Setting user:', user); + logger.info('store.setUser', 'Setting user', { hasUser: !!user }); set({ user, isAuthenticated: !!user }); }, setGitHubToken: (token) => { - console.log('Setting GitHub token:', !!token); + logger.info('store.setGitHubToken', 'Setting GitHub token', { hasToken: !!token }); set({ githubToken: token }); }, logout: () => set({ @@ -1644,7 +1645,7 @@ export const useAppStore = create()( currentState as AppState & AppActions ); - console.log('Store rehydrated:', { + logger.info('store.hydrate', 'Store rehydrated', { isAuthenticated: normalized.isAuthenticated, repositoriesCount: normalized.repositories?.length || 0, lastSync: normalized.lastSync, @@ -1658,13 +1659,17 @@ export const useAppStore = create()( ...normalized, }; }, - onRehydrateStorage: (state) => (_rehydratedState, error) => { - if (error) { - console.error('Store hydration failed', error); - } else { - console.log('Store hydration complete'); - } - state.setHasHydrated(true); + onRehydrateStorage: (state) => { + const hydrationStart = Date.now(); + return (_rehydratedState, error) => { + const elapsedMs = Date.now() - hydrationStart; + if (error) { + logger.errorFromError('store.hydrate', 'Store hydration failed', error, { elapsedMs }); + } else { + logger.info('store.hydrate', 'Store hydration complete', { elapsedMs }); + } + state.setHasHydrated(true); + }; }, } ) diff --git a/src/utils/logSanitizer.ts b/src/utils/logSanitizer.ts new file mode 100644 index 00000000..2e009df1 --- /dev/null +++ b/src/utils/logSanitizer.ts @@ -0,0 +1,209 @@ +/** + * Log sanitization utility — masks all sensitive data at write time. + * The ring buffer never contains raw secrets. + */ + +// Sensitive field names that trigger masking +const SENSITIVE_FIELD_NAMES = new Set([ + 'apiKey', 'api_key', 'api_key_encrypted', 'password', 'password_encrypted', + 'secret', 'token', 'githubToken', 'accessToken', 'authorization', + 'x-api-key', 'credentials', 'passwd', 'pwd', 'backendApiSecret', +]); + +// URL query param keys to redact +const SENSITIVE_URL_PARAMS = ['key', 'api_key', 'apikey', 'token', 'access_token', 'secret', 'client_secret', 'password', 'auth']; + +// Patterns for token/key detection +const GITHUB_TOKEN_RE = /^ghp_[a-zA-Z0-9]{36}$/; +const GENERIC_SECRET_RE = /^[a-zA-Z0-9+/=_-]{20,}$/; // long base64-ish strings + +/** + * Mask a secret string: show only last 4 chars. + */ +export function maskSecret(value: string): string { + if (!value || value.length <= 4) return '****'; + return '***' + value.slice(-4); +} + +/** + * Mask an email: keep domain, mask local part. + */ +export function maskEmail(email: string): string { + const atIndex = email.indexOf('@'); + if (atIndex <= 0) return '***@***'; + const local = email.slice(0, atIndex); + const domain = email.slice(atIndex + 1); + const maskedLocal = local.length <= 2 ? '**' : local[0] + '***'; + return maskedLocal + '@' + domain; +} + +/** + * Redact sensitive query params from a URL string. + */ +export function redactUrl(url: string): string { + try { + const parsed = new URL(url); + for (const [key] of parsed.searchParams) { + if (SENSITIVE_URL_PARAMS.includes(key.toLowerCase())) { + parsed.searchParams.set(key, '***'); + } + } + return parsed.toString(); + } catch { + return url; + } +} + +/** + * Mask a domain in a URL: show only first and last chars of hostname. + */ +export function maskUrlDomain(url: string): string { + try { + const parsed = new URL(url); + const host = parsed.hostname; + if (host.length <= 6) return parsed.toString(); + const maskedHost = host[0] + '***' + host.slice(-2); + parsed.hostname = maskedHost; + // Also redact query params (case-insensitive) + for (const [key] of parsed.searchParams) { + if (SENSITIVE_URL_PARAMS.includes(key.toLowerCase())) { + parsed.searchParams.set(key, '***'); + } + } + return parsed.toString(); + } catch { + return url; + } +} + +/** + * Detect if a string looks like a GitHub token. + */ +function isGitHubToken(value: string): boolean { + return GITHUB_TOKEN_RE.test(value); +} + +/** + * Detect if a string looks like a generic API key/secret. + * Only flags if it's 20+ chars of alphanumeric/special chars. + */ +function looksLikeSecret(value: string): boolean { + return value.length >= 20 && GENERIC_SECRET_RE.test(value); +} + +/** + * Detect if a string looks like an email address. + */ +const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; + +/** + * Recursively sanitize an object for logging. + * Walks objects and arrays, masking sensitive field values, + * token patterns, email addresses, and URLs. + */ +export function sanitizeForLog(input: unknown, seen: WeakSet = new WeakSet()): unknown { + if (input === null || input === undefined) return input; + if (typeof input === 'string') return sanitizeString(input); + if (typeof input === 'number' || typeof input === 'boolean') return input; + if (typeof input === 'object') { + if (seen.has(input as object)) return '[Circular]'; + seen.add(input as object); + const result = Array.isArray(input) + ? input.map((v) => sanitizeForLog(v, seen)) + : sanitizeObject(input as Record, seen); + seen.delete(input as object); + return result; + } + // Functions, Symbols, etc. — convert to string and sanitize + return sanitizeString(String(input)); +} + +function sanitizeString(value: string): string { + // GitHub token pattern + if (isGitHubToken(value)) return maskSecret(value); + + // Plain-string secrets (e.g., sk-..., long base64-like strings) + if (looksLikeSecret(value)) return maskSecret(value); + + // Email pattern + if (EMAIL_RE.test(value)) return maskEmail(value); + + // URL containing sensitive query params + if (value.startsWith('http://') || value.startsWith('https://')) { + return redactUrl(value); + } + + // Bearer token in Authorization header value + if (value.startsWith('Bearer ') || value.startsWith('bearer ')) { + const tokenPart = value.slice(7); + return value.slice(0, 7) + maskSecret(tokenPart); + } + + // Basic auth header + if (value.startsWith('Basic ') || value.startsWith('basic ')) { + return value.slice(0, 6) + '***'; + } + + return value; +} + +function sanitizeObject(obj: Record, seen: WeakSet): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + const lowerKey = key.toLowerCase(); + + // Sensitive field name → always mask + if (SENSITIVE_FIELD_NAMES.has(key) || SENSITIVE_FIELD_NAMES.has(lowerKey)) { + result[key] = typeof value === 'string' ? maskSecret(value) : '****'; + continue; + } + + // Field names containing partial matches + if (lowerKey.includes('password') || lowerKey.includes('passwd') || lowerKey.includes('pwd')) { + result[key] = typeof value === 'string' ? maskSecret(value) : '****'; + continue; + } + if (lowerKey.includes('token') || lowerKey.includes('secret') || lowerKey.includes('apikey')) { + result[key] = typeof value === 'string' ? maskSecret(value) : '****'; + continue; + } + + // Header objects: mask Authorization values + if (typeof value === 'object' && value !== null && (lowerKey === 'headers' || lowerKey === 'header')) { + result[key] = sanitizeHeaders(value as Record, seen); + continue; + } + + // Recurse into nested objects/arrays + result[key] = sanitizeForLog(value, seen); + } + return result; +} + +function sanitizeHeaders(headers: Record, seen: WeakSet): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(headers)) { + const lowerKey = key.toLowerCase(); + if (lowerKey === 'authorization' || lowerKey === 'x-api-key') { + result[key] = typeof value === 'string' ? sanitizeString(value) : '****'; + } else { + result[key] = sanitizeForLog(value, seen); + } + } + return result; +} + +/** + * Sanitize an Error object for logging. + * Extracts message and stack, sanitizes any embedded secrets. + */ +export function sanitizeError(err: unknown): { message: string; stack?: string; name?: string } { + if (!(err instanceof Error)) { + return { message: sanitizeString(String(err)) }; + } + return { + name: err.name, + message: sanitizeString(err.message), + stack: err.stack ? sanitizeString(err.stack) : undefined, + }; +} \ No newline at end of file