diff --git a/.gitignore b/.gitignore index 41bfca2..e6dd91c 100644 --- a/.gitignore +++ b/.gitignore @@ -164,3 +164,5 @@ tmp/ temp/ *.tmp .cache/ +audit.log +backups/ diff --git a/public/assets/app.css b/public/assets/app.css new file mode 100644 index 0000000..8726def --- /dev/null +++ b/public/assets/app.css @@ -0,0 +1,116 @@ +:root { + color-scheme: light; + } + body[data-theme="dark"] { + color-scheme: dark; + } + body { font-family: 'Inter', sans-serif; } + .font-mono { font-family: 'JetBrains Mono', monospace; } + .glass-panel { + background: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.2); + } + .sidebar-item:hover { + background: linear-gradient(90deg, rgba(59, 130, 246, 0.1) 0%, transparent 100%); + } + .sql-keyword { color: #c678dd; } + .sql-string { color: #98c379; } + .sql-function { color: #61afef; } + .sql-comment { color: #5c6370; font-style: italic; } + + /* Custom scrollbar */ + ::-webkit-scrollbar { width: 8px; height: 8px; } + ::-webkit-scrollbar-track { background: #f1f5f9; } + ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: #94a3b8; } + + .fade-in { animation: fadeIn 0.3s ease-in; } + @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } + + .loader { + border: 3px solid #f3f3f3; + border-top: 3px solid #3b82f6; + border-radius: 50%; + width: 24px; + height: 24px; + animation: spin 1s linear infinite; + } + @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } + body { + transition: background-color 0.25s ease, color 0.25s ease; + } + body[data-theme="dark"] .bg-white { background-color: #111827 !important; } + body[data-theme="dark"] .bg-slate-50 { background-color: #0f172a !important; } + body[data-theme="dark"] .bg-slate-100 { background-color: #1e293b !important; } + body[data-theme="dark"] .bg-slate-800 { background-color: #0f172a !important; } + body[data-theme="dark"] .bg-slate-900 { background-color: #020617 !important; } + body[data-theme="dark"] .bg-blue-50 { background-color: rgba(59, 130, 246, 0.16) !important; } + body[data-theme="dark"] .bg-green-50 { background-color: rgba(34, 197, 94, 0.16) !important; } + body[data-theme="dark"] .bg-red-50 { background-color: rgba(239, 68, 68, 0.16) !important; } + body[data-theme="dark"] .text-slate-800 { color: #e2e8f0 !important; } + body[data-theme="dark"] .text-slate-700 { color: #cbd5e1 !important; } + body[data-theme="dark"] .text-slate-600 { color: #94a3b8 !important; } + body[data-theme="dark"] .text-slate-500, body[data-theme="dark"] .text-slate-400 { color: #64748b !important; } + body[data-theme="dark"] .text-slate-300 { color: #cbd5e1 !important; } + body[data-theme="dark"] .border-slate-200 { border-color: #1e293b !important; } + body[data-theme="dark"] .border-slate-300 { border-color: #334155 !important; } + body[data-theme="dark"] .border-slate-700 { border-color: #334155 !important; } + body[data-theme="dark"] .border-slate-800 { border-color: #1e293b !important; } + body[data-theme="dark"] input, + body[data-theme="dark"] textarea, + body[data-theme="dark"] select { + background-color: #0f172a !important; + color: #e2e8f0 !important; + border-color: #334155 !important; + } + body[data-theme="dark"] .glass-panel { + background: rgba(15, 23, 42, 0.92); + border-color: rgba(148, 163, 184, 0.12); + } + body[data-theme="dark"] ::-webkit-scrollbar-track { background: #0f172a; } + body[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #334155; } + .sidebar-collapsible { + transition: max-height 0.24s ease; + } + .log-terminal { + min-height: 18rem; + max-height: 28rem; + white-space: pre-wrap; + word-break: break-word; + } + @media (max-width: 900px) { + #mobileBackdrop:not(.hidden) { + display: block; + } + #sidebar { + position: fixed; + inset: 0 auto 0 0; + z-index: 30; + width: min(88vw, 20rem); + transform: translateX(-100%); + transition: transform 0.25s ease; + } + #sidebar.sidebar-open { + transform: translateX(0); + } + #mainHeader { + padding-left: 1rem; + padding-right: 1rem; + } + #toolbar { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + #recordControls { + width: 100%; + flex-wrap: wrap; + } + #recordSearch { + width: 100%; + } + #contentArea { + padding: 1rem; + } + } diff --git a/index.html b/public/assets/app.js similarity index 61% rename from index.html rename to public/assets/app.js index 82cc4e0..1900041 100644 --- a/index.html +++ b/public/assets/app.js @@ -1,698 +1,4 @@ - - - - - - PostgreSQL SensoLab Panel - - - - - - - - - -
-
-
-
- -
-

PostgreSQL SensoLab

-

Войдите для управления базой данных

-
- -
-
- - -
-
- - -
- -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - diff --git a/favicon.svg b/public/favicon.svg similarity index 100% rename from favicon.svg rename to public/favicon.svg diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..45dea56 --- /dev/null +++ b/public/index.html @@ -0,0 +1,603 @@ + + + + + + PostgreSQL SensoLab Panel + + + + + + + + + +
+
+
+
+ +
+

PostgreSQL SensoLab

+

Войдите для управления базой данных

+
+ +
+
+ + +
+
+ + +
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/server.js b/server.js index 06203f9..3444c48 100644 --- a/server.js +++ b/server.js @@ -3,502 +3,48 @@ const express = require('express'); const { Pool } = require('pg'); const session = require('express-session'); const cors = require('cors'); -const path = require('path'); -const fs = require('fs'); -const http = require('http'); -const bcrypt = require('bcryptjs'); const crypto = require('crypto'); - -let usersConfig = { users: [] }; -try { - usersConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'users.json'), 'utf8')); -} catch (err) { - console.warn('⚠️ users.json not found or invalid JSON. Falling back to env-based admin only.'); -} - -const rolePermissions = { - superadmin: { folders: null, canCreate: true, canEdit: true, canDelete: true }, - frontend_admin: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: true }, - backend_admin: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: true }, - frontend_moder: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: false }, - backend_moder: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: false }, - viewer: { folders: null, canCreate: false, canEdit: false, canDelete: false }, -}; - -function getUser(username) { - return usersConfig.users.find(u => u.username === username); -} - -function getTableFolder(tableName) { - if (!tableName) return 'default'; - const parts = tableName.split('__'); - return parts.length > 1 ? parts[0] : 'default'; -} - -function getRolePermissions(role) { - return rolePermissions[role] || rolePermissions.viewer; -} - -function canAccessTable(role, tableName) { - const perms = getRolePermissions(role); - if (!perms.folders) return true; - const folder = getTableFolder(tableName); - return perms.folders.includes(folder); -} - -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_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41'; -const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; -const ALLOWED_SQL_TYPES = new Set(['VARCHAR(255)', 'TEXT', 'INTEGER', 'BIGINT', 'DECIMAL', 'BOOLEAN', 'DATE', 'TIMESTAMP', 'UUID', 'JSON', 'JSONB']); -const LEGACY_ROLE_MAP = { - frontend_admin: { role: 'admin', folders: ['frontend'] }, - backend_admin: { role: 'admin', folders: ['backend'] }, - frontend_moder: { role: 'moderator', folders: ['frontend'] }, - backend_moder: { role: 'moderator', folders: ['backend'] }, - viewer: { role: 'viewer', folders: null }, - superadmin: { role: 'superadmin', folders: null }, -}; - -function readUsersConfig() { - try { - const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); - const users = Array.isArray(parsed.users) ? parsed.users : []; - return { users: users.map(normalizeUser).filter(Boolean) }; - } catch (err) { - console.warn('users.json not found or invalid JSON. Falling back to env-based superadmin only.'); - return { users: [] }; - } -} - -function normalizeUser(user) { - if (!user || typeof user.username !== 'string') { - return null; - } - - const legacy = LEGACY_ROLE_MAP[user.role]; - const role = legacy ? legacy.role : user.role; - const folders = Array.isArray(user.folders) - ? user.folders.filter(Boolean) - : legacy - ? legacy.folders - : null; - - return { - username: user.username, - password: typeof user.password === 'string' ? user.password : undefined, - passwordHash: typeof user.passwordHash === 'string' ? user.passwordHash : undefined, - role: ['admin', 'moderator', 'viewer', 'superadmin'].includes(role) ? role : 'viewer', - folders, - access: normalizeAccess(user.access, role, folders), - disabled: Boolean(user.disabled), - }; -} - -function getUser(username) { - return readUsersConfig().users.find((user) => user.username === username) || null; -} - -function normalizeScope(scope, fallbackFolders = null) { - if (scope === null) { - return { folders: null, tables: null }; - } - - return { - folders: Array.isArray(scope?.folders) - ? scope.folders.filter(Boolean) - : fallbackFolders, - tables: Array.isArray(scope?.tables) - ? scope.tables.filter(Boolean) - : [], - }; -} - -function normalizeAccess(access, role, folders = null) { - if (role === 'superadmin') { - return { - view: { folders: null, tables: null }, - create: { folders: null, tables: null }, - edit: { folders: null, tables: null }, - delete: { folders: null, tables: null }, - }; - } - - const baseFolders = folders && folders.length ? folders : null; - const defaultsByRole = { - admin: { - view: { folders: baseFolders, tables: [] }, - create: { folders: baseFolders, tables: [] }, - edit: { folders: baseFolders, tables: [] }, - delete: { folders: baseFolders, tables: [] }, - }, - moderator: { - view: { folders: baseFolders, tables: [] }, - create: { folders: baseFolders, tables: [] }, - edit: { folders: baseFolders, tables: [] }, - delete: { folders: [], tables: [] }, - }, - viewer: { - view: { folders: baseFolders, tables: [] }, - create: { folders: [], tables: [] }, - edit: { folders: [], tables: [] }, - delete: { folders: [], tables: [] }, - }, - }; - - const defaults = defaultsByRole[role] || defaultsByRole.viewer; - const source = access && typeof access === 'object' ? access : {}; - - return { - view: normalizeScope(source.view, defaults.view.folders), - create: normalizeScope(source.create, defaults.create.folders), - edit: normalizeScope(source.edit, defaults.edit.folders), - delete: normalizeScope(source.delete, defaults.delete.folders), - }; -} - -function getRolePermissions(role, folders = null, access = null) { - const normalizedAccess = normalizeAccess(access, role, folders); - if (role === 'superadmin') { - return { - role, - folders: null, - access: normalizedAccess, - canCreate: true, - canEdit: true, - canDelete: true, - canViewLogs: true, - canRunSql: true, - canManageUsers: true, - canMoveTables: true, - }; - } - - if (role === 'admin') { - return { - role, - folders: folders && folders.length ? folders : null, - access: normalizedAccess, - canCreate: true, - canEdit: true, - canDelete: true, - canViewLogs: true, - canRunSql: true, - canManageUsers: true, - canMoveTables: true, - }; - } - - if (role === 'moderator') { - return { - role, - folders: folders && folders.length ? folders : null, - access: normalizedAccess, - canCreate: true, - canEdit: true, - canDelete: false, - canViewLogs: false, - canRunSql: false, - canManageUsers: false, - canMoveTables: false, - }; - } - - return { - role: 'viewer', - folders: folders && folders.length ? folders : null, - access: normalizedAccess, - canCreate: false, - canEdit: false, - canDelete: false, - canViewLogs: false, - canRunSql: false, - canManageUsers: false, - canMoveTables: false, - }; -} - -function isScopeAllowed(scope, tableName) { - if (!scope) return false; - if (scope.tables === null || scope.folders === null) return true; - const folder = getTableFolder(tableName); - return scope.tables.includes(tableName) || scope.folders.includes(folder); -} - -function canAccessTable(permissionsOrRole, tableName, folders = null, access = null, action = 'view') { - const perms = typeof permissionsOrRole === 'string' - ? getRolePermissions(permissionsOrRole, folders, access) - : permissionsOrRole; - return isScopeAllowed(perms.access?.[action], tableName); -} - -function canAccessFolder(permissions, folder, action = 'view') { - const scope = permissions.access?.[action]; - if (!scope) return false; - if (scope.folders === null) return true; - return scope.folders.includes(folder); -} - -function isValidIdentifier(value) { - return SAFE_IDENTIFIER.test(value); -} - -function quoteIdentifier(identifier) { - if (!isValidIdentifier(identifier)) { - throw new Error(`Unsafe identifier: ${identifier}`); - } - return `"${identifier}"`; -} - -function createSessionUser({ username, role, folders, access }) { - return { - username, - role, - permissions: getRolePermissions(role, folders, access), - }; -} - -async function verifyPassword(user, password) { - if (!user || user.disabled) { - return false; - } - - if (user.passwordHash) { - return bcrypt.compare(password, user.passwordHash); - } - - return user.password === password; -} - -function sanitizeUser(user) { - return { - username: user.username, - role: user.role, - folders: user.folders, - access: user.access, - disabled: user.disabled, - }; -} - -function validateScopeInput(scope) { - if (scope === null) { - return { folders: null, tables: null }; - } - - return { - folders: Array.isArray(scope?.folders) ? scope.folders.filter(Boolean) : [], - tables: Array.isArray(scope?.tables) ? scope.tables.filter(Boolean) : [], - }; -} - -function validateUserPayload(payload, { allowPasswordOptional = false } = {}) { - if (!payload || typeof payload.username !== 'string' || !payload.username.trim()) { - throw new Error('Username is required'); - } - - if (!['admin', 'moderator', 'viewer'].includes(payload.role)) { - throw new Error('Invalid role'); - } - - if (!allowPasswordOptional && (!payload.password || typeof payload.password !== 'string')) { - throw new Error('Password is required'); - } - - const folders = Array.isArray(payload.folders) ? payload.folders.filter(Boolean) : null; - const access = payload.access && typeof payload.access === 'object' - ? { - view: validateScopeInput(payload.access.view), - create: validateScopeInput(payload.access.create), - edit: validateScopeInput(payload.access.edit), - delete: validateScopeInput(payload.access.delete), - } - : normalizeAccess(null, payload.role, folders); - - return { - username: payload.username.trim(), - password: typeof payload.password === 'string' ? payload.password : undefined, - role: payload.role, - folders, - access, - disabled: Boolean(payload.disabled), - }; -} - -function writeUsersConfig(users) { - const serialized = JSON.stringify({ - users: users.map((user) => { - const payload = { - username: user.username, - role: user.role, - folders: user.folders, - access: user.access, - disabled: Boolean(user.disabled), - }; - if (user.passwordHash) { - payload.passwordHash = user.passwordHash; - } else if (user.password) { - payload.password = user.password; - } - return payload; - }), - }, null, 2); - - fs.writeFileSync(USERS_FILE, serialized, 'utf8'); -} - -function appendAudit(event, actor, details = {}) { - const source = details.source || 'WEB'; - const entry = { - timestamp: new Date().toISOString(), - event, - actor: actor || 'system', - source, - summary: formatAuditSummary(event, details), - details, - }; - fs.appendFileSync(AUDIT_LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8'); -} - -function formatAuditSummary(event, details = {}) { - const summaries = { - 'login.success': `successful login`, - 'login.failed': `failed login attempt`, - 'logout': `logout`, - 'user.created': `created user ${details.username || ''}`.trim(), - 'user.updated': `updated user ${details.username || ''}`.trim(), - 'user.deleted': `deleted user ${details.username || ''}`.trim(), - 'table.created': `created table ${details.table || ''}`.trim(), - 'table.deleted': `deleted table ${details.table || ''}`.trim(), - 'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(), - 'record.created': `created record in ${details.table || ''}`.trim(), - 'record.updated': `updated record in ${details.table || ''}`.trim(), - 'record.deleted': `deleted record from ${details.table || ''}`.trim(), - 'sql.executed': `executed ${details.command || 'SQL'} query`, - }; - - return summaries[event] || event; -} - -function getAuditSource(req, fallback = 'WEB') { - const header = String(req?.headers?.['x-request-source'] || '').trim().toUpperCase(); - if (header === 'AI' || header === 'WEB') { - return header; - } - return fallback; -} - -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 } = {}) { - return new Promise((resolve, reject) => { - const req = http.request({ - socketPath: DOCKER_SOCKET_PATH, - path: `${DOCKER_API_PREFIX}${requestPath}`, - method: 'GET', - }, (response) => { - if (stream) { - if (response.statusCode >= 400) { - const chunks = []; - response.on('data', (chunk) => chunks.push(chunk)); - response.on('end', () => reject(new Error(Buffer.concat(chunks).toString('utf8') || 'Docker stream error'))); - return; - } - resolve(response); - return; - } - - const chunks = []; - response.on('data', (chunk) => chunks.push(chunk)); - response.on('end', () => { - const body = Buffer.concat(chunks); - if (response.statusCode >= 400) { - reject(new Error(body.toString('utf8') || 'Docker API error')); - return; - } - resolve(body); - }); - }); - - req.on('error', reject); - req.end(); - }); -} - -function demuxDockerChunk(buffer) { - let offset = 0; - let output = ''; - - while (offset + 8 <= buffer.length) { - const payloadLength = buffer.readUInt32BE(offset + 4); - const payloadStart = offset + 8; - const payloadEnd = payloadStart + payloadLength; - - if (payloadEnd > buffer.length) { - output += buffer.slice(offset).toString('utf8'); - return output; - } - - output += buffer.slice(payloadStart, payloadEnd).toString('utf8'); - offset = payloadEnd; - } - - if (offset < buffer.length) { - output += buffer.slice(offset).toString('utf8'); - } - - return output; -} - -async function listContainers() { - const body = await dockerRequest('/containers/json?all=1'); - const containers = JSON.parse(body.toString('utf8')); - return containers.map((container) => ({ - id: container.Id, - name: container.Names?.[0]?.replace(/^\//, '') || container.Id.slice(0, 12), - state: container.State, - status: container.Status, - image: container.Image, - })); -} - -async function resolveContainer(nameOrId) { - const containers = await listContainers(); - const container = containers.find((item) => - item.id === nameOrId || item.id.startsWith(nameOrId) || item.name === nameOrId - ); - - if (!container) { - throw new Error('Container not found'); - } - - return container; -} +const { + ALLOWED_SQL_TYPES, + canAccessFolder, + canAccessTable, + createSessionUser, + getTableFolder, + getUser, + isValidIdentifier, + quoteIdentifier, + readUsersConfig, + sanitizeUser, + validateUserPayload, + verifyPassword, + writeUsersConfig, +} = require('./src/lib/access'); +const { + appendAudit, + getAuditSource, + readAuditLog, +} = require('./src/lib/audit'); +const { + demuxDockerChunk, + dockerRequest, + listContainers, + resolveContainer, +} = require('./src/lib/docker'); +const { + createBackup, + getBackupPath, + listBackups, +} = require('./src/services/backups'); +const { + notifyError, +} = require('./src/services/notifications'); const app = express(); // Middleware app.use(cors()); app.use(express.json({ limit: '1mb' })); -app.use(express.static('.')); +app.use(express.static('./public')); // Session configuration app.use(session({ @@ -534,6 +80,29 @@ pool.connect((err, client, release) => { } }); +function applyRecordMetadata(structure, payload, currentUser, { isCreate = false } = {}) { + const data = { ...payload }; + const hasColumn = (name) => structure.some((col) => col.column_name === name); + + if (isCreate) { + if (hasColumn('created_by') && !data.created_by) { + data.created_by = currentUser.username; + } + if (hasColumn('created_at') && !data.created_at) { + data.created_at = new Date().toISOString(); + } + } + + if (hasColumn('updated_by')) { + data.updated_by = currentUser.username; + } + if (hasColumn('updated_at')) { + data.updated_at = new Date().toISOString(); + } + + return data; +} + // Helper: get primary key column for a table (returns null if none) async function getPrimaryKeyColumn(tableName) { const result = await pool.query(` @@ -772,6 +341,39 @@ app.get('/api/audit', requireAuth, requirePermission( res.json(readAuditLog(limit)); }); +app.get('/api/backups', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'Backup access denied' +), (req, res) => { + res.json(listBackups()); +}); + +app.post('/api/backups', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'Backup access denied' +), async (req, res) => { + try { + const backup = await createBackup(pool, req.currentUser.username); + appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) }); + res.json({ success: true, backup }); + } catch (err) { + notifyError('Backup creation failed', err, { actor: req.currentUser.username }).catch(() => {}); + res.status(500).json({ success: false, error: err.message }); + } +}); + +app.get('/api/backups/:filename/download', requireAuth, requirePermission( + (permissions) => permissions.canManageUsers, + 'Backup access denied' +), (req, res) => { + try { + const filePath = getBackupPath(req.params.filename); + res.download(filePath, req.params.filename); + } catch (err) { + res.status(404).json({ success: false, error: err.message }); + } +}); + // Get all tables app.get('/api/tables', requireAuth, async (req, res) => { try { @@ -914,6 +516,7 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => { } try { + const reservedColumns = new Set(columns.map((col) => col.name)); const columnsSQL = columns.map((col) => { if (!isValidIdentifier(col.name) || !ALLOWED_SQL_TYPES.has(col.type)) { throw new Error('Invalid column definition'); @@ -923,7 +526,12 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => { if (col.pk) def += ' PRIMARY KEY'; if (!col.nullable && !col.pk) def += ' NOT NULL'; return def; - }).join(', '); + }).concat([ + !reservedColumns.has('created_at') ? `"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` : null, + !reservedColumns.has('created_by') ? `"created_by" VARCHAR(255)` : null, + !reservedColumns.has('updated_at') ? `"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` : null, + !reservedColumns.has('updated_by') ? `"updated_by" VARCHAR(255)` : null, + ].filter(Boolean)).join(', '); await pool.query(`CREATE TABLE ${quoteIdentifier(name)} (${columnsSQL})`); appendAudit('table.created', req.currentUser.username, { table: name, source: getAuditSource(req) }); @@ -985,7 +593,8 @@ app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requ const structure = structureResult.rows; const filteredData = {}; - for (const [key, value] of Object.entries(data)) { + const dataWithMetadata = applyRecordMetadata(structure, data, req.currentUser, { isCreate: true }); + for (const [key, value] of Object.entries(dataWithMetadata)) { const colInfo = structure.find(col => col.column_name === key); if (!colInfo || value === '') { continue; @@ -1020,15 +629,22 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r ), async (req, res) => { const { tableName, pk } = req.params; const data = req.body || {}; - const columns = Object.keys(data).filter(isValidIdentifier); - - if (!columns.length) { - return res.status(400).json({ success: false, error: 'No valid fields to update' }); - } try { const primaryKey = await getPrimaryKeyColumn(tableName); - const values = columns.map((column) => data[column]); + const structure = await pool.query(` + SELECT column_name, data_type + FROM information_schema.columns + WHERE table_name = $1 AND table_schema = 'public' + `, [tableName]).then((result) => result.rows); + const dataWithMetadata = applyRecordMetadata(structure, data, req.currentUser, { isCreate: false }); + const columns = Object.keys(dataWithMetadata).filter(isValidIdentifier); + + if (!columns.length) { + return res.status(400).json({ success: false, error: 'No valid fields to update' }); + } + + const values = columns.map((column) => dataWithMetadata[column]); const setClause = columns.map((col, i) => `${quoteIdentifier(col)} = $${i + 1}`).join(', '); const whereClause = primaryKey ? `${quoteIdentifier(primaryKey)} = $${values.length + 1}` @@ -1326,3 +942,5 @@ app.listen(PORT, () => { console.log(''); console.log('📝 Make sure to configure your database in .env file'); }); + + diff --git a/src/lib/access.js b/src/lib/access.js new file mode 100644 index 0000000..4adc922 --- /dev/null +++ b/src/lib/access.js @@ -0,0 +1,325 @@ +const fs = require('fs'); +const path = require('path'); +const bcrypt = require('bcryptjs'); + +const USERS_FILE = path.join(__dirname, '..', '..', 'users.json'); +const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/; +const ALLOWED_SQL_TYPES = new Set(['VARCHAR(255)', 'TEXT', 'INTEGER', 'BIGINT', 'DECIMAL', 'BOOLEAN', 'DATE', 'TIMESTAMP', 'UUID', 'JSON', 'JSONB']); +const LEGACY_ROLE_MAP = { + frontend_admin: { role: 'admin', folders: ['frontend'] }, + backend_admin: { role: 'admin', folders: ['backend'] }, + frontend_moder: { role: 'moderator', folders: ['frontend'] }, + backend_moder: { role: 'moderator', folders: ['backend'] }, + viewer: { role: 'viewer', folders: null }, + superadmin: { role: 'superadmin', folders: null }, +}; + +function getTableFolder(tableName) { + if (!tableName) return 'default'; + const parts = tableName.split('__'); + return parts.length > 1 ? parts[0] : 'default'; +} + +function readUsersConfig() { + try { + const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')); + const users = Array.isArray(parsed.users) ? parsed.users : []; + return { users: users.map(normalizeUser).filter(Boolean) }; + } catch (err) { + console.warn('users.json not found or invalid JSON. Falling back to env-based superadmin only.'); + return { users: [] }; + } +} + +function normalizeUser(user) { + if (!user || typeof user.username !== 'string') { + return null; + } + + const legacy = LEGACY_ROLE_MAP[user.role]; + const role = legacy ? legacy.role : user.role; + const folders = Array.isArray(user.folders) + ? user.folders.filter(Boolean) + : legacy + ? legacy.folders + : null; + + return { + username: user.username, + password: typeof user.password === 'string' ? user.password : undefined, + passwordHash: typeof user.passwordHash === 'string' ? user.passwordHash : undefined, + role: ['admin', 'moderator', 'viewer', 'superadmin'].includes(role) ? role : 'viewer', + folders, + access: normalizeAccess(user.access, role, folders), + disabled: Boolean(user.disabled), + }; +} + +function getUser(username) { + return readUsersConfig().users.find((user) => user.username === username) || null; +} + +function normalizeScope(scope, fallbackFolders = null) { + if (scope === null) { + return { folders: null, tables: null }; + } + + return { + folders: Array.isArray(scope?.folders) + ? scope.folders.filter(Boolean) + : fallbackFolders, + tables: Array.isArray(scope?.tables) + ? scope.tables.filter(Boolean) + : [], + }; +} + +function normalizeAccess(access, role, folders = null) { + if (role === 'superadmin') { + return { + view: { folders: null, tables: null }, + create: { folders: null, tables: null }, + edit: { folders: null, tables: null }, + delete: { folders: null, tables: null }, + }; + } + + const baseFolders = folders && folders.length ? folders : null; + const defaultsByRole = { + admin: { + view: { folders: baseFolders, tables: [] }, + create: { folders: baseFolders, tables: [] }, + edit: { folders: baseFolders, tables: [] }, + delete: { folders: baseFolders, tables: [] }, + }, + moderator: { + view: { folders: baseFolders, tables: [] }, + create: { folders: baseFolders, tables: [] }, + edit: { folders: baseFolders, tables: [] }, + delete: { folders: [], tables: [] }, + }, + viewer: { + view: { folders: baseFolders, tables: [] }, + create: { folders: [], tables: [] }, + edit: { folders: [], tables: [] }, + delete: { folders: [], tables: [] }, + }, + }; + + const defaults = defaultsByRole[role] || defaultsByRole.viewer; + const source = access && typeof access === 'object' ? access : {}; + + return { + view: normalizeScope(source.view, defaults.view.folders), + create: normalizeScope(source.create, defaults.create.folders), + edit: normalizeScope(source.edit, defaults.edit.folders), + delete: normalizeScope(source.delete, defaults.delete.folders), + }; +} + +function getRolePermissions(role, folders = null, access = null) { + const normalizedAccess = normalizeAccess(access, role, folders); + if (role === 'superadmin') { + return { + role, + folders: null, + access: normalizedAccess, + canCreate: true, + canEdit: true, + canDelete: true, + canViewLogs: true, + canRunSql: true, + canManageUsers: true, + canMoveTables: true, + }; + } + + if (role === 'admin') { + return { + role, + folders: folders && folders.length ? folders : null, + access: normalizedAccess, + canCreate: true, + canEdit: true, + canDelete: true, + canViewLogs: true, + canRunSql: true, + canManageUsers: true, + canMoveTables: true, + }; + } + + if (role === 'moderator') { + return { + role, + folders: folders && folders.length ? folders : null, + access: normalizedAccess, + canCreate: true, + canEdit: true, + canDelete: false, + canViewLogs: false, + canRunSql: false, + canManageUsers: false, + canMoveTables: false, + }; + } + + return { + role: 'viewer', + folders: folders && folders.length ? folders : null, + access: normalizedAccess, + canCreate: false, + canEdit: false, + canDelete: false, + canViewLogs: false, + canRunSql: false, + canManageUsers: false, + canMoveTables: false, + }; +} + +function isScopeAllowed(scope, tableName) { + if (!scope) return false; + if (scope.tables === null || scope.folders === null) return true; + const folder = getTableFolder(tableName); + return scope.tables.includes(tableName) || scope.folders.includes(folder); +} + +function canAccessTable(permissionsOrRole, tableName, folders = null, access = null, action = 'view') { + const perms = typeof permissionsOrRole === 'string' + ? getRolePermissions(permissionsOrRole, folders, access) + : permissionsOrRole; + return isScopeAllowed(perms.access?.[action], tableName); +} + +function canAccessFolder(permissions, folder, action = 'view') { + const scope = permissions.access?.[action]; + if (!scope) return false; + if (scope.folders === null) return true; + return scope.folders.includes(folder); +} + +function isValidIdentifier(value) { + return SAFE_IDENTIFIER.test(value); +} + +function quoteIdentifier(identifier) { + if (!isValidIdentifier(identifier)) { + throw new Error(`Unsafe identifier: ${identifier}`); + } + return `"${identifier}"`; +} + +function createSessionUser({ username, role, folders, access }) { + return { + username, + role, + permissions: getRolePermissions(role, folders, access), + }; +} + +async function verifyPassword(user, password) { + if (!user || user.disabled) { + return false; + } + + if (user.passwordHash) { + return bcrypt.compare(password, user.passwordHash); + } + + return user.password === password; +} + +function sanitizeUser(user) { + return { + username: user.username, + role: user.role, + folders: user.folders, + access: user.access, + disabled: user.disabled, + }; +} + +function validateScopeInput(scope) { + if (scope === null) { + return { folders: null, tables: null }; + } + + return { + folders: Array.isArray(scope?.folders) ? scope.folders.filter(Boolean) : [], + tables: Array.isArray(scope?.tables) ? scope.tables.filter(Boolean) : [], + }; +} + +function validateUserPayload(payload, { allowPasswordOptional = false } = {}) { + if (!payload || typeof payload.username !== 'string' || !payload.username.trim()) { + throw new Error('Username is required'); + } + + if (!['admin', 'moderator', 'viewer'].includes(payload.role)) { + throw new Error('Invalid role'); + } + + if (!allowPasswordOptional && (!payload.password || typeof payload.password !== 'string')) { + throw new Error('Password is required'); + } + + const folders = Array.isArray(payload.folders) ? payload.folders.filter(Boolean) : null; + const access = payload.access && typeof payload.access === 'object' + ? { + view: validateScopeInput(payload.access.view), + create: validateScopeInput(payload.access.create), + edit: validateScopeInput(payload.access.edit), + delete: validateScopeInput(payload.access.delete), + } + : normalizeAccess(null, payload.role, folders); + + return { + username: payload.username.trim(), + password: typeof payload.password === 'string' ? payload.password : undefined, + role: payload.role, + folders, + access, + disabled: Boolean(payload.disabled), + }; +} + +function writeUsersConfig(users) { + const serialized = JSON.stringify({ + users: users.map((user) => { + const payload = { + username: user.username, + role: user.role, + folders: user.folders, + access: user.access, + disabled: Boolean(user.disabled), + }; + if (user.passwordHash) { + payload.passwordHash = user.passwordHash; + } else if (user.password) { + payload.password = user.password; + } + return payload; + }), + }, null, 2); + + fs.writeFileSync(USERS_FILE, serialized, 'utf8'); +} + +module.exports = { + ALLOWED_SQL_TYPES, + canAccessFolder, + canAccessTable, + createSessionUser, + getRolePermissions, + getTableFolder, + getUser, + isValidIdentifier, + normalizeAccess, + quoteIdentifier, + readUsersConfig, + sanitizeUser, + validateUserPayload, + verifyPassword, + writeUsersConfig, +}; diff --git a/src/lib/audit.js b/src/lib/audit.js new file mode 100644 index 0000000..362c53b --- /dev/null +++ b/src/lib/audit.js @@ -0,0 +1,73 @@ +const fs = require('fs'); +const path = require('path'); + +const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log'); + +function formatAuditSummary(event, details = {}) { + const summaries = { + 'login.success': 'successful login', + 'login.failed': 'failed login attempt', + 'logout': 'logout', + 'user.created': `created user ${details.username || ''}`.trim(), + 'user.updated': `updated user ${details.username || ''}`.trim(), + 'user.deleted': `deleted user ${details.username || ''}`.trim(), + 'backup.created': `created backup ${details.filename || ''}`.trim(), + 'table.created': `created table ${details.table || ''}`.trim(), + 'table.deleted': `deleted table ${details.table || ''}`.trim(), + 'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(), + 'record.created': `created record in ${details.table || ''}`.trim(), + 'record.updated': `updated record in ${details.table || ''}`.trim(), + 'record.deleted': `deleted record from ${details.table || ''}`.trim(), + 'sql.executed': `executed ${details.command || 'SQL'} query`, + }; + + return summaries[event] || event; +} + +function getAuditSource(req, fallback = 'WEB') { + const header = String(req?.headers?.['x-request-source'] || '').trim().toUpperCase(); + if (header === 'AI' || header === 'WEB') { + return header; + } + return fallback; +} + +function appendAudit(event, actor, details = {}) { + const source = details.source || 'WEB'; + const entry = { + timestamp: new Date().toISOString(), + event, + actor: actor || 'system', + source, + summary: formatAuditSummary(event, details), + 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(); +} + +module.exports = { + appendAudit, + formatAuditSummary, + getAuditSource, + readAuditLog, +}; diff --git a/src/lib/docker.js b/src/lib/docker.js new file mode 100644 index 0000000..efd5da0 --- /dev/null +++ b/src/lib/docker.js @@ -0,0 +1,96 @@ +const http = require('http'); + +const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock'; +const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41'; + +function dockerRequest(requestPath, { stream = false } = {}) { + return new Promise((resolve, reject) => { + const req = http.request({ + socketPath: DOCKER_SOCKET_PATH, + path: `${DOCKER_API_PREFIX}${requestPath}`, + method: 'GET', + }, (response) => { + if (stream) { + if (response.statusCode >= 400) { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => reject(new Error(Buffer.concat(chunks).toString('utf8') || 'Docker stream error'))); + return; + } + resolve(response); + return; + } + + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => { + const body = Buffer.concat(chunks); + if (response.statusCode >= 400) { + reject(new Error(body.toString('utf8') || 'Docker API error')); + return; + } + resolve(body); + }); + }); + + req.on('error', reject); + req.end(); + }); +} + +function demuxDockerChunk(buffer) { + let offset = 0; + let output = ''; + + while (offset + 8 <= buffer.length) { + const payloadLength = buffer.readUInt32BE(offset + 4); + const payloadStart = offset + 8; + const payloadEnd = payloadStart + payloadLength; + + if (payloadEnd > buffer.length) { + output += buffer.slice(offset).toString('utf8'); + return output; + } + + output += buffer.slice(payloadStart, payloadEnd).toString('utf8'); + offset = payloadEnd; + } + + if (offset < buffer.length) { + output += buffer.slice(offset).toString('utf8'); + } + + return output; +} + +async function listContainers() { + const body = await dockerRequest('/containers/json?all=1'); + const containers = JSON.parse(body.toString('utf8')); + return containers.map((container) => ({ + id: container.Id, + name: container.Names?.[0]?.replace(/^\//, '') || container.Id.slice(0, 12), + state: container.State, + status: container.Status, + image: container.Image, + })); +} + +async function resolveContainer(nameOrId) { + const containers = await listContainers(); + const container = containers.find((item) => + item.id === nameOrId || item.id.startsWith(nameOrId) || item.name === nameOrId + ); + + if (!container) { + throw new Error('Container not found'); + } + + return container; +} + +module.exports = { + demuxDockerChunk, + dockerRequest, + listContainers, + resolveContainer, +}; diff --git a/src/services/backups.js b/src/services/backups.js new file mode 100644 index 0000000..c102528 --- /dev/null +++ b/src/services/backups.js @@ -0,0 +1,102 @@ +const fs = require('fs'); +const path = require('path'); + +const BACKUPS_DIR = path.join(__dirname, '..', '..', 'backups'); +const USERS_FILE = path.join(__dirname, '..', '..', 'users.json'); +const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log'); + +function ensureBackupsDir() { + fs.mkdirSync(BACKUPS_DIR, { recursive: true }); +} + +function makeBackupFilename() { + const now = new Date().toISOString().replace(/[:.]/g, '-'); + return `backup-${now}.json`; +} + +async function createBackup(pool, actor = 'system') { + ensureBackupsDir(); + + const tablesResult = await pool.query(` + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' + ORDER BY table_name + `); + + const tables = []; + for (const row of tablesResult.rows) { + const tableName = row.table_name; + const structure = await pool.query(` + SELECT + c.column_name AS name, + c.data_type AS type, + c.is_nullable AS nullable, + c.column_default AS default_value + FROM information_schema.columns c + WHERE c.table_name = $1 AND c.table_schema = 'public' + ORDER BY c.ordinal_position + `, [tableName]); + const data = await pool.query(`SELECT * FROM "${tableName}"`); + tables.push({ + name: tableName, + structure: structure.rows, + rows: data.rows, + }); + } + + const backup = { + meta: { + createdAt: new Date().toISOString(), + createdBy: actor, + version: 1, + }, + users: fs.existsSync(USERS_FILE) ? JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')) : { users: [] }, + audit: fs.existsSync(AUDIT_LOG_FILE) + ? fs.readFileSync(AUDIT_LOG_FILE, 'utf8').split(/\r?\n/).filter(Boolean) + : [], + tables, + }; + + const filename = makeBackupFilename(); + const filePath = path.join(BACKUPS_DIR, filename); + fs.writeFileSync(filePath, JSON.stringify(backup, null, 2), 'utf8'); + + return { + filename, + filePath, + size: fs.statSync(filePath).size, + createdAt: backup.meta.createdAt, + }; +} + +function listBackups() { + ensureBackupsDir(); + return fs.readdirSync(BACKUPS_DIR) + .filter((name) => name.endsWith('.json')) + .map((name) => { + const filePath = path.join(BACKUPS_DIR, name); + const stats = fs.statSync(filePath); + return { + filename: name, + size: stats.size, + createdAt: stats.birthtime.toISOString(), + }; + }) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +function getBackupPath(filename) { + const filePath = path.join(BACKUPS_DIR, filename); + if (!filePath.startsWith(BACKUPS_DIR) || !fs.existsSync(filePath)) { + throw new Error('Backup not found'); + } + return filePath; +} + +module.exports = { + BACKUPS_DIR, + createBackup, + getBackupPath, + listBackups, +}; diff --git a/src/services/notifications.js b/src/services/notifications.js new file mode 100644 index 0000000..f6ab0ab --- /dev/null +++ b/src/services/notifications.js @@ -0,0 +1,43 @@ +async function sendTelegramMessage(text) { + const enabled = process.env.ENABLE_TELEGRAM_NOTIFICATIONS === 'true'; + const token = process.env.TELEGRAM_BOT_TOKEN; + const chatId = process.env.TELEGRAM_CHAT_ID; + + if (!enabled || !token || !chatId || !text) { + return { sent: false, reason: 'disabled' }; + } + + const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: chatId, + text, + parse_mode: 'HTML', + disable_web_page_preview: true, + }), + }); + + if (!response.ok) { + const details = await response.text(); + throw new Error(`Telegram API error: ${details}`); + } + + return { sent: true }; +} + +async function notifyError(title, error, context = {}) { + const message = [ + 'PG Admin error', + title, + error?.message || String(error || ''), + Object.keys(context).length ? JSON.stringify(context) : '', + ].filter(Boolean).join('\n'); + + return sendTelegramMessage(message); +} + +module.exports = { + notifyError, + sendTelegramMessage, +};