From e5f7167b8d80a98f62d6eb68cff8f080821145bd Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 19 Apr 2026 21:26:33 +0200 Subject: [PATCH] fix: auth/API logging to Loki, TTS voice auto-detection, STT ElevenLabs support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add logger.audit/access calls to auth route (login, login_failed, login_blocked, register, password_changed, 2fa_backup_code_used, 2fa_backup_codes_regenerated) — these previously only wrote to DB via raw SQL, bypassing Loki shipper - Replace logger.info with logger.apiCall in callAI() so every AI call ships to Loki with model, tokens, cost, duration - Add device identifier (parsed user agent) to audit and access logs - Fix TTS voice/model provider mismatch: auto-detect Vertex voices (Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model to match provider regardless of what model was previously set - Fix TTS discovery: model IDs saved to tts.voice are detected and redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts) - Fix STT transcription route: add scribe/elevenlabs/transcri to the isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions endpoint instead of chat completions - Remove OpenObserve/SigNoz code from logger (reverted to Loki-only) --- src/routes/adminConfig.js | 4 +-- src/routes/auth.js | 8 ++++++ src/routes/transcribe.js | 2 +- src/routes/tts.js | 7 ++--- src/utils/ai.js | 11 ++++---- src/utils/logger.js | 59 ++++++++++++++++++++------------------- 6 files changed, 51 insertions(+), 40 deletions(-) diff --git a/src/routes/adminConfig.js b/src/routes/adminConfig.js index 5796434..35c3611 100644 --- a/src/routes/adminConfig.js +++ b/src/routes/adminConfig.js @@ -566,7 +566,7 @@ router.post('/config/tts/test', async function(req, res) { if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; var resolvedVoice = voice || adminVoice || ''; - if (!adminModel && resolvedVoice) { + if (resolvedVoice) { if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts'; else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; } @@ -770,7 +770,7 @@ router.post('/config/stt/test', async function(req, res) { var sttModel = adminSttModel || process.env.LITELLM_STT_MODEL || 'gemini-2.0-flash'; // base URL handled by gatewayUrl() helper - var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel); + var isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/i.test(sttModel); if (isTranscriptionModel) { var ext = mime.split('/')[1] || 'webm'; diff --git a/src/routes/auth.js b/src/routes/auth.js index f339003..4f38107 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -11,6 +11,7 @@ const db = require('../db/database'); const { JWT_SECRET, authMiddleware } = require('../middleware/auth'); const { hashToken, parseUserAgent, generateSessionId } = require('../utils/sessions'); const { notifyNewLogin, notifyPasswordChanged, notifyNewRegistration } = require('../utils/notify'); +var logger = require('../utils/logger'); // Check password against Have I Been Pwned (k-anonymity — only first 5 chars of SHA-1 sent) async function checkPwnedPassword(password) { @@ -229,6 +230,7 @@ router.post('/register', async (req, res) => { await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', [userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']); + logger.audit(userId, 'register', role === 'admin' ? 'First user — auto admin' : 'standard user', req, { category: 'auth' }); notifyNewRegistration(email, name); var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST; @@ -332,6 +334,7 @@ router.post('/login', async (req, res) => { } if (!valid) { await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login_failed', req.ip]).catch(function(){}); + logger.access(user.id, 'login_failed', req, false); return res.status(401).json({ error: 'Invalid credentials' }); } @@ -343,6 +346,7 @@ router.post('/login', async (req, res) => { if (user.disabled) { await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', [user.id, 'login_blocked', req.ip, 'Account disabled']).catch(function(){}); + logger.access(user.id, 'login_blocked', req, false); return res.status(401).json({ error: 'Invalid credentials' }); } @@ -361,11 +365,13 @@ router.post('/login', async (req, res) => { if (!consumed) return res.status(401).json({ error: 'Invalid 2FA code' }); await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)', [user.id, '2fa_backup_code_used', req.ip, 'Backup code consumed at login']).catch(function(){}); + logger.audit(user.id, '2fa_backup_code_used', 'Backup code consumed at login', req, { category: 'auth' }); } } var token = signAuthToken(user.id, req); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [user.id, 'login', req.ip]); + logger.access(user.id, 'login', req, true); // Create session record var sessionId = generateSessionId(); @@ -468,6 +474,7 @@ router.post('/2fa/backup-codes', authMiddleware, async (req, res) => { var hashes = await hashBackupCodes(codes); await db.run('UPDATE users SET totp_backup_codes = ? WHERE id = ?', [JSON.stringify(hashes), req.user.id]); await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, '2fa_backup_codes_regenerated', req.ip]).catch(function(){}); + logger.audit(req.user.id, '2fa_backup_codes_regenerated', 'Backup codes regenerated', req, { category: 'auth' }); // Return plaintext codes — this is the ONLY time they are visible. res.json({ success: true, codes: codes, message: 'Save these somewhere safe — they cannot be shown again.' }); } catch (err) { console.error('[Auth] 2FA backup codes error:', err.message); res.status(500).json({ error: 'Request failed' }); } @@ -626,6 +633,7 @@ router.post('/change-password', authMiddleware, async (req, res) => { } await db.run('INSERT INTO audit_log (user_id, action, ip_address) VALUES (?, ?, ?)', [req.user.id, 'password_changed', req.ip]); + logger.audit(req.user.id, 'password_changed', 'Password changed', req, { category: 'auth' }); notifyPasswordChanged(req.user.id); var resp = { success: true, message: 'Password changed. All other sessions have been logged out.' }; diff --git a/src/routes/transcribe.js b/src/routes/transcribe.js index 5cff735..dbe2351 100644 --- a/src/routes/transcribe.js +++ b/src/routes/transcribe.js @@ -96,7 +96,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r var mimeType = req.file.mimetype || 'audio/webm'; // Models that use /audio/transcriptions (Whisper, Deepgram, Groq Whisper, etc.) - var isTranscriptionModel = /whisper|deepgram|nova|groq/i.test(sttModel); + var isTranscriptionModel = /whisper|deepgram|nova|groq|scribe|elevenlabs|transcri/i.test(sttModel); if (isTranscriptionModel) { // Use /v1/audio/transcriptions endpoint (OpenAI-compatible) diff --git a/src/routes/tts.js b/src/routes/tts.js index a98368a..6f26524 100644 --- a/src/routes/tts.js +++ b/src/routes/tts.js @@ -42,13 +42,12 @@ router.post('/text-to-speech', authMiddleware, async (req, res) => { // tts.voice may contain a model ID (from discovery "Set" button) — detect and use as model if (!adminModel && adminVoice && (adminVoice.indexOf('/') !== -1 || /^(openai-tts|tts-1|elevenlabs|vertex-gemini.*tts|openai-gpt.*tts)/i.test(adminVoice))) { adminModel = adminVoice; adminVoice = ''; } - // Auto-detect model from voice name when no explicit model is set + // Auto-detect model from voice — ensures model matches the voice's provider var VERTEX_VOICES = ['Puck','Charon','Kore','Fenrir','Aoede','Leda','Orus','Zephyr']; - var OPENAI_VOICES = ['alloy','echo','fable','onyx','nova','shimmer','ash','sage','coral']; var resolvedVoice = userVoice || adminVoice || ''; - if (!adminModel && resolvedVoice) { + if (resolvedVoice) { if (VERTEX_VOICES.indexOf(resolvedVoice) !== -1) adminModel = 'vertex-gemini-2.5-flash-tts'; - else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; // ElevenLabs voice IDs are long alphanumeric + else if (resolvedVoice.length > 15 && /^[a-zA-Z0-9]+$/.test(resolvedVoice)) adminModel = 'elevenlabs-v3'; } if (ttsProvider === 'google') { diff --git a/src/utils/ai.js b/src/utils/ai.js index b6c884d..1eda32a 100644 --- a/src/utils/ai.js +++ b/src/utils/ai.js @@ -450,12 +450,13 @@ async function callAI(messages, options) { var duration = Date.now() - startTime; - logger.info('AI call success', { - provider: activeProvider, - model: result.model, + var usage = result.usage || {}; + logger.apiCall(null, activeProvider + '/' + (result.model || model), { + model: result.model || model, + tokensInput: usage.prompt_tokens || 0, + tokensOutput: usage.completion_tokens || 0, duration: duration, - outputLength: result.content.length, - tokens: result.usage + statusCode: 200 }); result.duration = duration; diff --git a/src/utils/logger.js b/src/utils/logger.js index 53ad1c4..e02815b 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -3,6 +3,8 @@ const path = require('path'); const db = require('../db/database'); const { redact } = require('./redact'); const queues = require('./auditQueue'); +var parseUA; +try { parseUA = require('./sessions').parseUserAgent; } catch(e) { parseUA = function(ua) { return ua || 'unknown'; }; } var LOG_DIR = path.join(__dirname, '../../data/logs'); if (!fs.existsSync(LOG_DIR)) { @@ -26,6 +28,7 @@ function pushToLoki(labels, message) { }).catch(function() {}); // fire-and-forget } + function estimateCost(model, inputTokens, outputTokens) { if (!model || !inputTokens) return 0; var rates = { @@ -61,16 +64,17 @@ var logger = { duration_ms: e.duration || null, status: e.status || 'success' }); - pushToLoki( - { type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' }, - JSON.stringify({ - userId: userId, action: action, details: safeDetails, - ip: req ? req.ip : null, - userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, - sessionId: req ? (req.sessionId || null) : null, - status: e.status || 'success' - }) - ); + var auditLabels = { type: 'audit', category: e.category || 'general', action: action, status: e.status || 'success' }; + var auditMsg = JSON.stringify({ + userId: userId, action: action, details: safeDetails, + ip: req ? req.ip : null, + userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, + device: req ? parseUA(req.headers['user-agent']) : null, + sessionId: req ? (req.sessionId || null) : null, + model: e.model || null, + status: e.status || 'success' + }); + pushToLoki(auditLabels, auditMsg); }, apiCall: function(userId, endpoint, data) { @@ -91,14 +95,13 @@ var logger = { ip_address: d.ip || null, error: d.error || null }); - pushToLoki( - { type: 'api_call', endpoint: endpoint, model: d.model || '' }, - JSON.stringify({ - userId: userId, endpoint: endpoint, model: d.model, - tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration, - ip: d.ip || null, status: d.statusCode || 200 - }) - ); + var apiLabels = { type: 'api_call', endpoint: endpoint, model: d.model || '' }; + var apiMsg = JSON.stringify({ + userId: userId, endpoint: endpoint, model: d.model, + tokens: (d.tokensInput||0)+(d.tokensOutput||0), cost: cost, duration: d.duration, + ip: d.ip || null, status: d.statusCode || 200 + }); + pushToLoki(apiLabels, apiMsg); }, access: function(userId, action, req, success) { @@ -109,16 +112,16 @@ var logger = { user_agent: req ? (req.headers['user-agent'] || '').substring(0, 255) : null, success: success ? true : false }); - pushToLoki( - { type: 'access', action: action }, - JSON.stringify({ - userId: userId, action: action, - ip: req ? req.ip : null, - userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, - sessionId: req ? (req.sessionId || null) : null, - success: success - }) - ); + var accessLabels = { type: 'access', action: action }; + var accessMsg = JSON.stringify({ + userId: userId, action: action, + ip: req ? req.ip : null, + userAgent: req ? (req.headers['user-agent'] || '').substring(0, 200) : null, + device: req ? parseUA(req.headers['user-agent']) : null, + sessionId: req ? (req.sessionId || null) : null, + success: success + }); + pushToLoki(accessLabels, accessMsg); }, file: function(level, message, data) {