- App-layer AES-256-GCM crypto helper (src/utils/crypto.js) - Nextcloud tokens encrypted at rest; transparent migration on next use - Audio backups encrypted at rest (version byte 0x01 envelope); legacy rows still decrypt as-is until overwritten - argon2id password hashing via src/utils/passwords.js with bcrypt fallback; bcrypt hashes rehashed to argon2id on next successful login. argon2 package is optional — server keeps running with bcrypt only until npm install adds the native dep - PHI redactor for audit log details (src/utils/redact.js) — strips SSN, phone, email, DoB, long IDs; caps at 500 chars; detects note bodies - DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in Learning Hub content rendering - SRI integrity hashes added for Font Awesome CSS and Chart.js - Magic-byte file-type verification on document uploads (src/utils/fileType.js) - Generic 500 error responses via src/utils/errors.js applied to nextcloud and audioBackups; full detail still logged server-side - DATA_ENCRYPTION_KEY env documented in .env.example Deploy: requires rebuild of the container image to pick up the new files and `npm install` (adds argon2). Existing users keep working because bcrypt stays available and crypto helpers pass through plaintext when the key is not yet set in dev.
153 lines
6.4 KiB
JavaScript
153 lines
6.4 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const db = require('../db/database');
|
|
const { redact } = require('./redact');
|
|
|
|
var LOG_DIR = path.join(__dirname, '../../data/logs');
|
|
if (!fs.existsSync(LOG_DIR)) {
|
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
}
|
|
|
|
// Loki log shipping (optional — set LOKI_URL env var)
|
|
var LOKI_URL = process.env.LOKI_URL;
|
|
function pushToLoki(labels, message) {
|
|
if (!LOKI_URL) return;
|
|
var payload = {
|
|
streams: [{
|
|
stream: Object.assign({ app: 'pedscribe' }, labels),
|
|
values: [[String(Date.now() * 1000000), typeof message === 'string' ? message : JSON.stringify(message)]]
|
|
}]
|
|
};
|
|
fetch(LOKI_URL + '/loki/api/v1/push', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}).catch(function() {}); // fire-and-forget
|
|
}
|
|
|
|
function estimateCost(model, inputTokens, outputTokens) {
|
|
if (!model || !inputTokens) return 0;
|
|
var rates = {
|
|
'google/gemini-2.5-flash': { input: 0.075, output: 0.30 },
|
|
'google/gemini-2.5-pro': { input: 1.25, output: 5.00 },
|
|
'deepseek/deepseek-chat-v3-0324': { input: 0.14, output: 0.28 },
|
|
'deepseek/deepseek-r1': { input: 0.55, output: 2.19 },
|
|
'deepseek/deepseek-r1:free': { input: 0, output: 0 },
|
|
'qwen/qwen3-30b-a3b:free': { input: 0, output: 0 },
|
|
'anthropic/vendor-model-sonnet-4': { input: 3.00, output: 15.00 },
|
|
'anthropic/vendor-model-3-haiku': { input: 0.25, output: 1.25 },
|
|
'openai/gpt-4.1-mini': { input: 0.40, output: 1.60 },
|
|
'meta-llama/llama-3.3-70b-instruct': { input: 0.10, output: 0.16 }
|
|
};
|
|
var rate = rates[model] || { input: 0.50, output: 1.00 };
|
|
return ((inputTokens / 1000000) * rate.input) + (((outputTokens || 0) / 1000000) * rate.output);
|
|
}
|
|
|
|
var logger = {
|
|
|
|
audit: function(userId, action, details, req, extra) {
|
|
var e = extra || {};
|
|
var safeDetails = redact(typeof details === 'string' ? details : JSON.stringify(details));
|
|
db.run(
|
|
'INSERT INTO audit_log (user_id, action, category, details, ip_address, user_agent, model_used, tokens_used, duration_ms, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
[
|
|
userId || null, action, e.category || 'general',
|
|
safeDetails,
|
|
req ? (req.ip || null) : null,
|
|
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
|
|
e.model || null, e.tokens || null, e.duration || null, e.status || 'success'
|
|
]
|
|
).catch(function(err) { console.error('[Logger] Audit failed:', err.message); });
|
|
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'
|
|
})
|
|
);
|
|
},
|
|
|
|
apiCall: function(userId, endpoint, data) {
|
|
var d = data || {};
|
|
var cost = estimateCost(d.model, d.tokensInput, d.tokensOutput);
|
|
db.run(
|
|
'INSERT INTO api_log (user_id, endpoint, method, status_code, request_size, response_size, model_used, tokens_input, tokens_output, cost_estimate, duration_ms, ip_address, error) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
|
[
|
|
userId || null, endpoint, d.method || 'POST', d.statusCode || 200,
|
|
d.requestSize || 0, d.responseSize || 0, d.model || null,
|
|
d.tokensInput || 0, d.tokensOutput || 0, cost, d.duration || 0,
|
|
d.ip || null, d.error || null
|
|
]
|
|
).catch(function(err) { console.error('[Logger] API log failed:', err.message); });
|
|
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
|
|
})
|
|
);
|
|
},
|
|
|
|
access: function(userId, action, req, success) {
|
|
db.run(
|
|
'INSERT INTO access_log (user_id, action, ip_address, user_agent, success) VALUES (?, ?, ?, ?, ?)',
|
|
[
|
|
userId || null, action,
|
|
req ? (req.ip || null) : null,
|
|
req ? (req.headers['user-agent'] || '').substring(0, 255) : null,
|
|
success ? true : false
|
|
]
|
|
).catch(function(err) { console.error('[Logger] Access log failed:', err.message); });
|
|
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
|
|
})
|
|
);
|
|
},
|
|
|
|
file: function(level, message, data) {
|
|
try {
|
|
var now = new Date();
|
|
var date = now.toISOString().split('T')[0];
|
|
var logFile = path.join(LOG_DIR, date + '.log');
|
|
var line = '[' + now.toISOString() + '] [' + level.toUpperCase() + '] ' + message;
|
|
if (data) line += ' | ' + (typeof data === 'string' ? data : JSON.stringify(data));
|
|
line += '\n';
|
|
fs.appendFileSync(logFile, line);
|
|
if (level === 'error') console.error(line.trim());
|
|
} catch (e) {}
|
|
},
|
|
|
|
info: function(msg, data) { this.file('info', msg, data); },
|
|
warn: function(msg, data) { this.file('warn', msg, data); },
|
|
error: function(msg, data) { this.file('error', msg, data); },
|
|
|
|
getAuditLogs: function(userId, limit) {
|
|
return db.all('SELECT * FROM audit_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
|
},
|
|
getApiLogs: function(userId, limit) {
|
|
return db.all('SELECT * FROM api_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
|
},
|
|
getAccessLogs: function(userId, limit) {
|
|
return db.all('SELECT * FROM access_log WHERE user_id = ? ORDER BY timestamp DESC LIMIT ?', [userId, limit || 100]);
|
|
},
|
|
getUsageSummary: function(userId, days) {
|
|
var since = new Date(Date.now() - (days || 30) * 86400000).toISOString();
|
|
return db.all(
|
|
'SELECT COUNT(*) as total_calls, SUM(tokens_input) as total_input_tokens, SUM(tokens_output) as total_output_tokens, SUM(cost_estimate) as total_estimated_cost, AVG(duration_ms) as avg_duration_ms, model_used, endpoint FROM api_log WHERE user_id = ? AND timestamp > ? GROUP BY model_used, endpoint ORDER BY total_calls DESC',
|
|
[userId, since]
|
|
);
|
|
}
|
|
};
|
|
|
|
console.log('✅ Logger initialized');
|
|
module.exports = logger;
|