- Add authMiddleware to all AI/transcribe routes (were unauthenticated) - Add full admin panel: user management, registration toggle, stats - Fix XSS in email verification (escape user.name in HTML) - Fix missing APP_URL fallback in password reset email - Add per-tab model selector (respects OpenRouter/Bedrock/Azure lists) - Fix transcribeAudio to send Authorization header - Fix labs input: textarea instead of single-line input - Add structured logging: audit_log, api_log, access_log tables - Add admin CLI (admin-cli.js) for Docker exec management - Fix duplicate var duration declaration in ai.js catch block - Fix RETURNING check case-sensitivity in database.js
173 lines
5.3 KiB
JavaScript
173 lines
5.3 KiB
JavaScript
const { Pool } = require('pg');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://pedscribe:pedscribe_secret_change_me@localhost:5432/pedscribe';
|
|
|
|
const pool = new Pool({
|
|
connectionString: DATABASE_URL,
|
|
max: 20,
|
|
idleTimeoutMillis: 30000,
|
|
connectionTimeoutMillis: 5000
|
|
});
|
|
|
|
pool.query('SELECT NOW()')
|
|
.then(function() { console.log('✅ PostgreSQL: connected'); })
|
|
.catch(function(err) { console.error('❌ PostgreSQL: connection failed:', err.message); });
|
|
|
|
async function initDatabase() {
|
|
var client = await pool.connect();
|
|
try {
|
|
await client.query(`
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id SERIAL PRIMARY KEY,
|
|
email TEXT UNIQUE NOT NULL,
|
|
password TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
role TEXT DEFAULT 'user',
|
|
email_verified BOOLEAN DEFAULT false,
|
|
verify_token TEXT,
|
|
verify_expires BIGINT,
|
|
totp_secret TEXT,
|
|
totp_enabled BOOLEAN DEFAULT false,
|
|
passkey_credentials TEXT,
|
|
nextcloud_url TEXT,
|
|
nextcloud_user TEXT,
|
|
nextcloud_token TEXT,
|
|
nextcloud_folder TEXT DEFAULT '/PediatricScribe',
|
|
reset_token TEXT,
|
|
reset_expires BIGINT,
|
|
disabled BOOLEAN DEFAULT false,
|
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS app_settings (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
action TEXT NOT NULL,
|
|
category TEXT DEFAULT 'general',
|
|
details TEXT,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
model_used TEXT,
|
|
tokens_used INTEGER,
|
|
duration_ms INTEGER,
|
|
status TEXT DEFAULT 'success',
|
|
timestamp TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS api_log (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
endpoint TEXT NOT NULL,
|
|
method TEXT,
|
|
status_code INTEGER,
|
|
request_size INTEGER,
|
|
response_size INTEGER,
|
|
model_used TEXT,
|
|
tokens_input INTEGER,
|
|
tokens_output INTEGER,
|
|
cost_estimate REAL,
|
|
duration_ms INTEGER,
|
|
ip_address TEXT,
|
|
error TEXT,
|
|
timestamp TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS access_log (
|
|
id SERIAL PRIMARY KEY,
|
|
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
action TEXT NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
success BOOLEAN DEFAULT true,
|
|
timestamp TIMESTAMPTZ DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_api_user ON api_log(user_id);
|
|
CREATE INDEX IF NOT EXISTS idx_api_ts ON api_log(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_access_ts ON access_log(timestamp);
|
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
`);
|
|
|
|
// Add columns if upgrading
|
|
var migrations = [
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'user'",
|
|
"ALTER TABLE users ADD COLUMN IF NOT EXISTS disabled BOOLEAN DEFAULT false"
|
|
];
|
|
for (var i = 0; i < migrations.length; i++) {
|
|
try { await client.query(migrations[i]); } catch(e) {}
|
|
}
|
|
|
|
// Initialize default settings
|
|
await client.query(`
|
|
INSERT INTO app_settings (key, value) VALUES ('registration_enabled', 'true')
|
|
ON CONFLICT (key) DO NOTHING
|
|
`);
|
|
|
|
console.log('✅ Database tables: ready');
|
|
} catch (err) {
|
|
console.error('❌ Database init error:', err.message);
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
initDatabase();
|
|
|
|
// Query helpers
|
|
var db = {
|
|
get: async function(sql, params) {
|
|
var result = await pool.query(convertPlaceholders(sql), params);
|
|
return result.rows[0] || null;
|
|
},
|
|
all: async function(sql, params) {
|
|
var result = await pool.query(convertPlaceholders(sql), params);
|
|
return result.rows;
|
|
},
|
|
run: async function(sql, params) {
|
|
var pgSql = convertPlaceholders(sql);
|
|
if (sql.trim().toUpperCase().startsWith('INSERT') && pgSql.toUpperCase().indexOf('RETURNING') === -1) {
|
|
pgSql += ' RETURNING id';
|
|
}
|
|
var result = await pool.query(pgSql, params);
|
|
return {
|
|
lastInsertRowid: result.rows[0] ? result.rows[0].id : null,
|
|
changes: result.rowCount
|
|
};
|
|
},
|
|
query: async function(sql, params) {
|
|
return pool.query(convertPlaceholders(sql), params);
|
|
},
|
|
// Settings helpers
|
|
getSetting: async function(key) {
|
|
var row = await pool.query('SELECT value FROM app_settings WHERE key = $1', [key]);
|
|
return row.rows[0] ? row.rows[0].value : null;
|
|
},
|
|
setSetting: async function(key, value) {
|
|
await pool.query(
|
|
'INSERT INTO app_settings (key, value, updated_at) VALUES ($1, $2, NOW()) ON CONFLICT (key) DO UPDATE SET value = $2, updated_at = NOW()',
|
|
[key, value]
|
|
);
|
|
},
|
|
pool: pool
|
|
};
|
|
|
|
function convertPlaceholders(sql) {
|
|
var index = 0;
|
|
return sql.replace(/\?/g, function() {
|
|
index++;
|
|
return '$' + index;
|
|
});
|
|
}
|
|
|
|
module.exports = db;
|