логирование

This commit is contained in:
2026-03-20 15:29:46 +07:00
parent 2c73a25a6a
commit 1ee888e929
2 changed files with 162 additions and 15 deletions

View File

@@ -187,6 +187,10 @@
<i data-lucide="users" class="w-4 h-4"></i> <i data-lucide="users" class="w-4 h-4"></i>
Users Users
</button> </button>
<button id="auditButton" onclick="app.showAuditModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
<i data-lucide="history" class="w-4 h-4"></i>
Audit
</button>
<button id="logsButton" onclick="app.showLogsPanel()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium"> <button id="logsButton" onclick="app.showLogsPanel()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
<i data-lucide="scroll-text" class="w-4 h-4"></i> <i data-lucide="scroll-text" class="w-4 h-4"></i>
Logs Logs
@@ -571,7 +575,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
<div class="p-6 space-y-4"> <div class="p-6 space-y-4">
<div> <div>
<label class="block text-sm font-medium text-slate-700 mb-1">Folder</label> <label class="block text-sm font-medium text-slate-700 mb-1">Folder</label>
<input type="text" id="moveTableFolder" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend"> <select id="moveTableFolder" class="w-full px-4 py-2 border border-slate-300 rounded-lg"></select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700 mb-1">Table name</label> <label class="block text-sm font-medium text-slate-700 mb-1">Table name</label>
@@ -627,15 +631,24 @@ SELECT * FROM users LIMIT 10;"></textarea>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700 mb-1">Readable folders</label> <label class="block text-sm font-medium text-slate-700 mb-1">Readable folders</label>
<input type="text" id="userViewFolders" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend, backend"> <details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
<div id="userViewFoldersList" class="mt-3 grid gap-2"></div>
</details>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label> <label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label>
<input type="text" id="userEditTables" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="users, frontend__users"> <details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose tables</summary>
<div id="userEditTablesList" class="mt-3 grid gap-2 max-h-48 overflow-auto"></div>
</details>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label> <label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label>
<input type="text" id="userDeleteFolders" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="frontend"> <details class="border border-slate-300 rounded-lg p-3">
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
<div id="userDeleteFoldersList" class="mt-3 grid gap-2"></div>
</details>
</div> </div>
<label class="flex items-center gap-2 text-sm text-slate-700"> <label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" id="userDisabled" class="w-4 h-4"> <input type="checkbox" id="userDisabled" class="w-4 h-4">
@@ -650,6 +663,23 @@ SELECT * FROM users LIMIT 10;"></textarea>
</div> </div>
</div> </div>
<div id="auditModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
<h3 class="text-xl font-bold text-slate-800">Audit log</h3>
<button onclick="app.closeModal('auditModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
</button>
</div>
<div class="p-6 overflow-auto">
<div class="flex justify-end mb-4">
<button onclick="app.loadAuditLog()" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Refresh</button>
</div>
<div id="auditList" class="space-y-3"></div>
</div>
</div>
</div>
<!-- Toast Notifications --> <!-- Toast Notifications -->
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div> <div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
@@ -807,6 +837,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer'; document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer';
document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs); document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs);
document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers); document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
document.getElementById('auditButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none'; document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none';
document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none'; document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none';
@@ -1677,7 +1708,34 @@ SELECT * FROM users LIMIT 10;"></textarea>
return value.split(',').map(item => item.trim()).filter(Boolean); return value.split(',').map(item => item.trim()).filter(Boolean);
} }
getAvailableFolders() {
return Array.from(new Set(this.tables.map(table => {
const parts = table.name.split('__');
return parts.length > 1 ? parts[0] : 'default';
}))).sort();
}
renderOptionChecklist(containerId, values, selected = []) {
const container = document.getElementById(containerId);
const selectedSet = new Set(selected || []);
container.innerHTML = values.length
? values.map(value => `
<label class="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" value="${value}" ${selectedSet.has(value) ? 'checked' : ''} class="w-4 h-4">
<span>${value}</span>
</label>
`).join('')
: '<div class="text-sm text-slate-500">No options available</div>';
}
getCheckedValues(containerId) {
return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`)).map(input => input.value);
}
async showUsersModal() { async showUsersModal() {
this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name));
await this.loadUsers(); await this.loadUsers();
this.resetUserForm(); this.resetUserForm();
document.getElementById('usersModal').classList.remove('hidden'); document.getElementById('usersModal').classList.remove('hidden');
@@ -1714,9 +1772,9 @@ SELECT * FROM users LIMIT 10;"></textarea>
document.getElementById('userUsername').disabled = true; document.getElementById('userUsername').disabled = true;
document.getElementById('userPassword').value = ''; document.getElementById('userPassword').value = '';
document.getElementById('userRole').value = user.role; document.getElementById('userRole').value = user.role;
document.getElementById('userViewFolders').value = (user.access?.view?.folders || []).join(', '); this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []);
document.getElementById('userEditTables').value = (user.access?.edit?.tables || []).join(', '); this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []);
document.getElementById('userDeleteFolders').value = (user.access?.delete?.folders || []).join(', '); this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []);
document.getElementById('userDisabled').checked = Boolean(user.disabled); document.getElementById('userDisabled').checked = Boolean(user.disabled);
} }
@@ -1726,9 +1784,9 @@ SELECT * FROM users LIMIT 10;"></textarea>
document.getElementById('userUsername').disabled = false; document.getElementById('userUsername').disabled = false;
document.getElementById('userPassword').value = ''; document.getElementById('userPassword').value = '';
document.getElementById('userRole').value = 'viewer'; document.getElementById('userRole').value = 'viewer';
document.getElementById('userViewFolders').value = ''; this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
document.getElementById('userEditTables').value = ''; this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
document.getElementById('userDeleteFolders').value = ''; this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name));
document.getElementById('userDisabled').checked = false; document.getElementById('userDisabled').checked = false;
} }
@@ -1742,10 +1800,10 @@ SELECT * FROM users LIMIT 10;"></textarea>
role: document.getElementById('userRole').value, role: document.getElementById('userRole').value,
disabled: document.getElementById('userDisabled').checked, disabled: document.getElementById('userDisabled').checked,
access: { access: {
view: { folders: this.parseList(document.getElementById('userViewFolders').value), tables: [] }, view: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] },
create: { folders: [], tables: [] }, create: { folders: [], tables: [] },
edit: { folders: [], tables: this.parseList(document.getElementById('userEditTables').value) }, edit: { folders: [], tables: this.getCheckedValues('userEditTablesList') },
delete: { folders: this.parseList(document.getElementById('userDeleteFolders').value), tables: [] }, delete: { folders: this.getCheckedValues('userDeleteFoldersList'), tables: [] },
}, },
}; };
@@ -1784,7 +1842,12 @@ SELECT * FROM users LIMIT 10;"></textarea>
showMoveTableModal() { showMoveTableModal() {
const parts = (this.currentTable || '').split('__'); const parts = (this.currentTable || '').split('__');
document.getElementById('moveTableFolder').value = parts.length > 1 ? parts[0] : ''; const currentFolder = parts.length > 1 ? parts[0] : 'default';
const folders = this.getAvailableFolders();
document.getElementById('moveTableFolder').innerHTML = ['<option value="default">Common folder</option>']
.concat(folders.filter(folder => folder !== 'default').map(folder => `<option value="${folder}">${folder}</option>`))
.join('');
document.getElementById('moveTableFolder').value = currentFolder;
document.getElementById('moveTableName').value = parts.length > 1 ? parts.slice(1).join('__') : this.currentTable || ''; document.getElementById('moveTableName').value = parts.length > 1 ? parts.slice(1).join('__') : this.currentTable || '';
document.getElementById('moveTableModal').classList.remove('hidden'); document.getElementById('moveTableModal').classList.remove('hidden');
} }
@@ -1801,7 +1864,7 @@ SELECT * FROM users LIMIT 10;"></textarea>
const response = await fetch(`/api/tables/${encodeURIComponent(this.currentTable)}/move`, { const response = await fetch(`/api/tables/${encodeURIComponent(this.currentTable)}/move`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder, name }), body: JSON.stringify({ folder: folder === 'default' ? '' : folder, name }),
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
@@ -1817,6 +1880,36 @@ SELECT * FROM users LIMIT 10;"></textarea>
} }
} }
async showAuditModal() {
await this.loadAuditLog();
document.getElementById('auditModal').classList.remove('hidden');
}
async loadAuditLog() {
try {
const response = await fetch('/api/audit');
const entries = await response.json();
if (!response.ok) {
throw new Error(entries.error || 'Failed to load audit log');
}
document.getElementById('auditList').innerHTML = entries.length
? entries.map(entry => `
<div class="border border-slate-200 rounded-xl p-4">
<div class="flex items-center justify-between gap-4">
<div class="font-medium text-slate-800">${entry.event}</div>
<div class="text-xs text-slate-500">${entry.timestamp}</div>
</div>
<div class="text-sm text-slate-600 mt-1">Actor: ${entry.actor}</div>
<pre class="mt-3 text-xs bg-slate-50 rounded-lg p-3 overflow-auto">${JSON.stringify(entry.details, null, 2)}</pre>
</div>
`).join('')
: '<div class="text-sm text-slate-500">Audit log is empty.</div>';
} catch (err) {
this.showToast(err.message, 'error');
}
}
showIndexesModal() { showIndexesModal() {
document.getElementById('indexesTableName').textContent = this.currentTable; document.getElementById('indexesTableName').textContent = this.currentTable;
this.loadIndexes(); this.loadIndexes();

View File

@@ -47,6 +47,7 @@ function canAccessTable(role, tableName) {
} }
const USERS_FILE = path.join(__dirname, 'users.json'); const USERS_FILE = path.join(__dirname, 'users.json');
const AUDIT_LOG_FILE = path.join(__dirname, 'audit.log');
const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock'; const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock';
const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41'; const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41';
const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
@@ -346,6 +347,36 @@ function writeUsersConfig(users) {
fs.writeFileSync(USERS_FILE, serialized, 'utf8'); fs.writeFileSync(USERS_FILE, serialized, 'utf8');
} }
function appendAudit(event, actor, details = {}) {
const entry = {
timestamp: new Date().toISOString(),
event,
actor: actor || 'system',
details,
};
fs.appendFileSync(AUDIT_LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8');
}
function readAuditLog(limit = 200) {
if (!fs.existsSync(AUDIT_LOG_FILE)) {
return [];
}
return fs.readFileSync(AUDIT_LOG_FILE, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line) => {
try {
return JSON.parse(line);
} catch (error) {
return null;
}
})
.filter(Boolean)
.slice(-limit)
.reverse();
}
function dockerRequest(requestPath, { stream = false } = {}) { function dockerRequest(requestPath, { stream = false } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = http.request({ const req = http.request({
@@ -535,6 +566,7 @@ app.post('/api/login', async (req, res) => {
const sessionUser = createSessionUser(user); const sessionUser = createSessionUser(user);
req.session.user = sessionUser; req.session.user = sessionUser;
appendAudit('login.success', sessionUser.username, { role: sessionUser.role });
return res.json({ return res.json({
success: true, success: true,
username: sessionUser.username, username: sessionUser.username,
@@ -563,6 +595,7 @@ app.post('/api/login', async (req, res) => {
const sessionUser = createSessionUser({ username, role: 'superadmin', folders: null }); const sessionUser = createSessionUser({ username, role: 'superadmin', folders: null });
req.session.user = sessionUser; req.session.user = sessionUser;
appendAudit('login.success', sessionUser.username, { role: sessionUser.role, source: 'env' });
return res.json({ return res.json({
success: true, success: true,
username: sessionUser.username, username: sessionUser.username,
@@ -585,6 +618,7 @@ app.post('/api/login', async (req, res) => {
} }
} }
appendAudit('login.failed', username, {});
return res.status(401).json({ return res.status(401).json({
success: false, success: false,
error: 'Invalid credentials' error: 'Invalid credentials'
@@ -593,7 +627,9 @@ app.post('/api/login', async (req, res) => {
// Logout // Logout
app.post('/api/logout', (req, res) => { app.post('/api/logout', (req, res) => {
const actor = req.session?.user?.username;
req.session.destroy(() => { req.session.destroy(() => {
appendAudit('logout', actor, {});
res.json({ success: true }); res.json({ success: true });
}); });
}); });
@@ -646,6 +682,7 @@ app.post('/api/users', requireAuth, requirePermission(
disabled: payload.disabled, disabled: payload.disabled,
}); });
writeUsersConfig(config.users); writeUsersConfig(config.users);
appendAudit('user.created', req.currentUser.username, { username: payload.username, role: payload.role });
res.json({ success: true, user: sanitizeUser(config.users[config.users.length - 1]) }); res.json({ success: true, user: sanitizeUser(config.users[config.users.length - 1]) });
} catch (err) { } catch (err) {
res.status(400).json({ success: false, error: err.message }); res.status(400).json({ success: false, error: err.message });
@@ -674,6 +711,7 @@ app.put('/api/users/:username', requireAuth, requirePermission(
} }
writeUsersConfig(config.users); writeUsersConfig(config.users);
appendAudit('user.updated', req.currentUser.username, { username: user.username, role: user.role });
res.json({ success: true, user: sanitizeUser(user) }); res.json({ success: true, user: sanitizeUser(user) });
} catch (err) { } catch (err) {
res.status(400).json({ success: false, error: err.message }); res.status(400).json({ success: false, error: err.message });
@@ -691,9 +729,18 @@ app.delete('/api/users/:username', requireAuth, requirePermission(
} }
writeUsersConfig(nextUsers); writeUsersConfig(nextUsers);
appendAudit('user.deleted', req.currentUser.username, { username: req.params.username });
res.json({ success: true }); res.json({ success: true });
}); });
app.get('/api/audit', requireAuth, requirePermission(
(permissions) => permissions.canManageUsers,
'Audit access denied'
), (req, res) => {
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 200, 20), 1000);
res.json(readAuditLog(limit));
});
// Get all tables // Get all tables
app.get('/api/tables', requireAuth, async (req, res) => { app.get('/api/tables', requireAuth, async (req, res) => {
try { try {
@@ -848,6 +895,7 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => {
}).join(', '); }).join(', ');
await pool.query(`CREATE TABLE ${quoteIdentifier(name)} (${columnsSQL})`); await pool.query(`CREATE TABLE ${quoteIdentifier(name)} (${columnsSQL})`);
appendAudit('table.created', req.currentUser.username, { table: name });
res.json({ success: true, message: 'Table created' }); res.json({ success: true, message: 'Table created' });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
@@ -860,6 +908,7 @@ app.delete('/api/tables/:tableName', requireAuth, requireTableAccess, requirePer
), async (req, res) => { ), async (req, res) => {
try { try {
await pool.query(`DROP TABLE IF EXISTS ${quoteIdentifier(req.params.tableName)}`); await pool.query(`DROP TABLE IF EXISTS ${quoteIdentifier(req.params.tableName)}`);
appendAudit('table.deleted', req.currentUser.username, { table: req.params.tableName });
res.json({ success: true, message: 'Table deleted' }); res.json({ success: true, message: 'Table deleted' });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
@@ -882,6 +931,7 @@ app.post('/api/tables/:tableName/move', requireAuth, requireTableAccess, require
const nextName = cleanFolder ? `${cleanFolder}__${cleanName}` : cleanName; const nextName = cleanFolder ? `${cleanFolder}__${cleanName}` : cleanName;
try { try {
await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} RENAME TO ${quoteIdentifier(nextName)}`); await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} RENAME TO ${quoteIdentifier(nextName)}`);
appendAudit('table.moved', req.currentUser.username, { from: tableName, to: nextName, folder: cleanFolder || 'default' });
res.json({ success: true, name: nextName }); res.json({ success: true, name: nextName });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
@@ -926,6 +976,7 @@ app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requ
const placeholders = values.map((_, i) => `$${i + 1}`).join(', '); const placeholders = values.map((_, i) => `$${i + 1}`).join(', ');
const sql = `INSERT INTO ${quoteIdentifier(tableName)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${placeholders}) RETURNING *`; const sql = `INSERT INTO ${quoteIdentifier(tableName)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${placeholders}) RETURNING *`;
const result = await pool.query(sql, values); const result = await pool.query(sql, values);
appendAudit('record.created', req.currentUser.username, { table: tableName });
res.json({ success: true, data: result.rows[0] }); res.json({ success: true, data: result.rows[0] });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
@@ -953,6 +1004,7 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r
: 'ctid::text = $' + (values.length + 1); : 'ctid::text = $' + (values.length + 1);
const sql = `UPDATE ${quoteIdentifier(tableName)} SET ${setClause} WHERE ${whereClause} RETURNING *`; const sql = `UPDATE ${quoteIdentifier(tableName)} SET ${setClause} WHERE ${whereClause} RETURNING *`;
const result = await pool.query(sql, [...values, pk]); const result = await pool.query(sql, [...values, pk]);
appendAudit('record.updated', req.currentUser.username, { table: tableName, key: pk });
res.json({ success: true, data: result.rows[0] }); res.json({ success: true, data: result.rows[0] });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
@@ -971,6 +1023,7 @@ app.delete('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess
? `${quoteIdentifier(primaryKey)} = $1` ? `${quoteIdentifier(primaryKey)} = $1`
: 'ctid::text = $1'; : 'ctid::text = $1';
await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${whereClause}`, [pk]); await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${whereClause}`, [pk]);
appendAudit('record.deleted', req.currentUser.username, { table: tableName, key: pk });
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
res.status(500).json({ success: false, error: err.message }); res.status(500).json({ success: false, error: err.message });
@@ -1071,6 +1124,7 @@ app.post('/api/query', requireAuth, requirePermission(
try { try {
const result = await pool.query(sql); const result = await pool.query(sql);
appendAudit('sql.executed', req.currentUser.username, { command: result.command, sql: sql.slice(0, 400) });
res.json({ res.json({
success: true, success: true,
rows: result.rows, rows: result.rows,