require('dotenv').config(); const express = require('express'); const { Pool } = require('pg'); const session = require('express-session'); const cors = require('cors'); const crypto = require('crypto'); const bcrypt = require('bcryptjs'); 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, pruneBackups, } = require('./src/services/backups'); const { notifyError, notifyInfo, } = require('./src/services/notifications'); const { getSettings, saveSettings, validateSettings, } = require('./src/services/runtime-config'); const app = express(); // Middleware app.use(cors()); app.use(express.json({ limit: '1mb' })); app.use(express.static('./public')); // Session configuration app.use(session({ secret: process.env.SESSION_SECRET || 'default-secret-change-this', resave: false, saveUninitialized: false, cookie: { secure: false, httpOnly: true, sameSite: 'lax', } })); // Database connection pool (uses .env configuration) const pool = new Pool({ host: process.env.DB_HOST, port: process.env.DB_PORT, database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, }); // Test database connection on startup pool.connect((err, client, release) => { if (err) { console.error('❌ Error connecting to PostgreSQL:', err.message); console.log('Проверьте настройки в .env файле'); } else { console.log('✅ Connected to PostgreSQL database'); console.log(` Host: ${process.env.DB_HOST}:${process.env.DB_PORT}`); console.log(` Database: ${process.env.DB_NAME}`); 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; } let lastAutoBackupSlot = ''; async function runScheduledBackupIfNeeded() { const settings = getSettings(); if (!settings.backups.enabled) { return; } const now = new Date(); if (now.getHours() !== settings.backups.hour || now.getMinutes() !== settings.backups.minute) { return; } const slot = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${String(settings.backups.hour).padStart(2, '0')}-${String(settings.backups.minute).padStart(2, '0')}`; if (lastAutoBackupSlot === slot) { return; } const backup = await createBackup(pool, 'system', { includeAppSnapshot: settings.backups.includeAppSnapshot, keepLast: settings.backups.keepLast, }); lastAutoBackupSlot = slot; appendAudit('backup.auto_created', 'system', { files: backup.files, source: 'WEB', }); notifyInfo('Scheduled backup completed', backup.files.map((file) => `${file.kind}: ${file.filename}`)).catch(() => {}); } function startBackupScheduler() { setInterval(() => { runScheduledBackupIfNeeded().catch((error) => { notifyError('Scheduled backup failed', error).catch(() => {}); }); }, 60000); } // Helper: get primary key column for a table (returns null if none) async function getPrimaryKeyColumn(tableName) { const result = await pool.query(` SELECT kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_name = $1 AND tc.table_schema = 'public' LIMIT 1 `, [tableName]); return result.rows[0]?.column_name || null; } const requireAuth = (req, res, next) => { if (!req.session || !req.session.user) { return res.status(401).json({ success: false, error: 'Unauthorized' }); } req.currentUser = req.session.user; return next(); }; const requirePermission = (check, errorMessage) => (req, res, next) => { if (!check(req.currentUser.permissions, req)) { return res.status(403).json({ success: false, error: errorMessage }); } return next(); }; const requireTableAccess = (req, res, next) => { const { tableName } = req.params; try { quoteIdentifier(tableName); } catch (error) { return res.status(400).json({ success: false, error: 'Invalid table name' }); } if (!canAccessTable(req.currentUser.permissions, tableName, null, null, 'view')) { return res.status(403).json({ success: false, error: 'Access denied' }); } return next(); }; app.post('/api/login', async (req, res) => { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ success: false, error: 'Username and password are required' }); } const user = getUser(username); if (user && await verifyPassword(user, password)) { try { const result = await pool.query('SELECT NOW() as time'); const sessionUser = createSessionUser(user); req.session.user = sessionUser; appendAudit('login.success', sessionUser.username, { role: sessionUser.role, source: getAuditSource(req) }); return res.json({ success: true, username: sessionUser.username, role: sessionUser.role, permissions: sessionUser.permissions, dbInfo: { host: process.env.DB_HOST, port: process.env.DB_PORT, database: process.env.DB_NAME, connected: true, serverTime: result.rows[0].time, } }); } catch (err) { return res.status(500).json({ success: false, error: 'Database connection failed', details: err.message, }); } } if (username === process.env.ADMIN_USERNAME && password === process.env.ADMIN_PASSWORD) { try { const result = await pool.query('SELECT NOW() as time'); const sessionUser = createSessionUser({ username, role: 'superadmin', folders: null }); req.session.user = sessionUser; appendAudit('login.success', sessionUser.username, { role: sessionUser.role, source: getAuditSource(req) }); return res.json({ success: true, username: sessionUser.username, role: sessionUser.role, permissions: sessionUser.permissions, dbInfo: { host: process.env.DB_HOST, port: process.env.DB_PORT, database: process.env.DB_NAME, connected: true, serverTime: result.rows[0].time, } }); } catch (err) { return res.status(500).json({ success: false, error: 'Database connection failed', details: err.message, }); } } appendAudit('login.failed', username, { source: getAuditSource(req) }); return res.status(401).json({ success: false, error: 'Invalid credentials' }); }); // Logout app.post('/api/logout', (req, res) => { const actor = req.session?.user?.username; req.session.destroy(() => { appendAudit('logout', actor, { source: 'WEB' }); res.json({ success: true }); }); }); // Check session app.get('/api/session', (req, res) => { if (req.session && req.session.user) { res.json({ authenticated: true, username: req.session.user.username, role: req.session.user.role, permissions: req.session.user.permissions, dbInfo: { host: process.env.DB_HOST, port: process.env.DB_PORT, database: process.env.DB_NAME } }); } else { res.json({ authenticated: false }); } }); app.get('/api/users', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'User management access denied' ), (req, res) => { const config = readUsersConfig(); res.json(config.users.map(sanitizeUser)); }); app.post('/api/users', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'User management access denied' ), async (req, res) => { try { const payload = validateUserPayload(req.body); const config = readUsersConfig(); if (config.users.some((user) => user.username === payload.username)) { return res.status(409).json({ success: false, error: 'User already exists' }); } const passwordHash = await bcrypt.hash(payload.password, 10); config.users.push({ username: payload.username, passwordHash, role: payload.role, folders: payload.folders, access: payload.access, disabled: payload.disabled, }); writeUsersConfig(config.users); appendAudit('user.created', req.currentUser.username, { username: payload.username, role: payload.role, source: getAuditSource(req) }); res.json({ success: true, user: sanitizeUser(config.users[config.users.length - 1]) }); } catch (err) { res.status(400).json({ success: false, error: err.message }); } }); app.put('/api/users/:username', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'User management access denied' ), async (req, res) => { try { const payload = validateUserPayload({ ...req.body, username: req.params.username }, { allowPasswordOptional: true }); const config = readUsersConfig(); const user = config.users.find((item) => item.username === req.params.username); if (!user) { return res.status(404).json({ success: false, error: 'User not found' }); } user.role = payload.role; user.folders = payload.folders; user.access = payload.access; user.disabled = payload.disabled; if (payload.password) { user.passwordHash = await bcrypt.hash(payload.password, 10); delete user.password; } writeUsersConfig(config.users); appendAudit('user.updated', req.currentUser.username, { username: user.username, role: user.role, source: getAuditSource(req) }); res.json({ success: true, user: sanitizeUser(user) }); } catch (err) { res.status(400).json({ success: false, error: err.message }); } }); app.delete('/api/users/:username', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'User management access denied' ), (req, res) => { const config = readUsersConfig(); const nextUsers = config.users.filter((user) => user.username !== req.params.username); if (nextUsers.length === config.users.length) { return res.status(404).json({ success: false, error: 'User not found' }); } writeUsersConfig(nextUsers); appendAudit('user.deleted', req.currentUser.username, { username: req.params.username, source: getAuditSource(req) }); 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)); }); 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 settings = getSettings(); const backup = await createBackup(pool, req.currentUser.username, { includeAppSnapshot: settings.backups.includeAppSnapshot, keepLast: settings.backups.keepLast, }); appendAudit('backup.created', req.currentUser.username, { files: backup.files, 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/settings', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'Settings access denied' ), (req, res) => { res.json(getSettings()); }); app.put('/api/settings', requireAuth, requirePermission( (permissions) => permissions.canManageUsers, 'Settings access denied' ), (req, res) => { try { const settings = validateSettings(req.body); const saved = saveSettings(settings); pruneBackups(saved.backups.keepLast); appendAudit('settings.updated', req.currentUser.username, { source: getAuditSource(req) }); res.json({ success: true, settings: saved }); } catch (err) { res.status(400).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 { const result = await pool.query(` SELECT table_name as name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name `); const accessibleTables = result.rows.filter(table => canAccessTable(req.currentUser.permissions, table.name, null, null, 'view')); const tablesWithCounts = await Promise.all( accessibleTables.map(async (table) => { try { const countResult = await pool.query(`SELECT COUNT(*)::int as count FROM ${quoteIdentifier(table.name)}`); return { ...table, rows: countResult.rows[0].count }; } catch (e) { return { ...table, rows: 0 }; } }) ); res.json(tablesWithCounts); } catch (err) { res.status(500).json({ error: err.message }); } }); app.get('/api/tables/:tableName/data', requireAuth, requireTableAccess, async (req, res) => { const { tableName } = req.params; const page = Math.max(parseInt(req.query.page, 10) || 1, 1); const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 25, 1), 100); const search = String(req.query.search || '').trim(); const sortColumn = String(req.query.sortColumn || '').trim(); const sortDirection = String(req.query.sortDirection || 'ASC').toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const offset = (page - 1) * limit; let filters = {}; try { filters = req.query.filters ? JSON.parse(req.query.filters) : {}; } catch (err) { return res.status(400).json({ success: false, error: 'Invalid filters payload' }); } try { const columnsResult = await pool.query(` SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' ORDER BY ordinal_position `, [tableName]); const columns = columnsResult.rows.map(row => row.column_name); const whereParts = []; const params = []; if (search && columns.length) { params.push(`%${search}%`); const placeholder = `$${params.length}`; whereParts.push(`(${columns.map(col => `CAST(${quoteIdentifier(col)} AS TEXT) ILIKE ${placeholder}`).join(' OR ')})`); } if (filters && typeof filters === 'object') { Object.entries(filters).forEach(([column, value]) => { if (!columns.includes(column) || !String(value || '').trim()) { return; } params.push(`%${String(value).trim()}%`); whereParts.push(`CAST(${quoteIdentifier(column)} AS TEXT) ILIKE $${params.length}`); }); } const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''; const orderBy = columns.includes(sortColumn) ? `ORDER BY ${quoteIdentifier(sortColumn)} ${sortDirection}` : 'ORDER BY 1'; const countResult = await pool.query(`SELECT COUNT(*)::int as total FROM ${quoteIdentifier(tableName)} ${whereClause}`, params); const total = countResult.rows[0].total; const result = await pool.query(` SELECT ctid::text AS "__rowid", * FROM ${quoteIdentifier(tableName)} ${whereClause} ${orderBy} LIMIT $${params.length + 1} OFFSET $${params.length + 2} `, [...params, limit, offset]); res.json({ data: result.rows, total, page, limit, totalPages: Math.max(Math.ceil(total / limit), 1) }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.get('/api/tables/:tableName/structure', requireAuth, requireTableAccess, async (req, res) => { const { tableName } = req.params; try { const result = 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, CASE WHEN kcu.column_name IS NOT NULL THEN true ELSE false END as is_primary FROM information_schema.columns c LEFT JOIN information_schema.table_constraints tc ON tc.table_name = c.table_name AND tc.table_schema = c.table_schema AND tc.constraint_type = 'PRIMARY KEY' LEFT JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name AND kcu.table_schema = tc.table_schema AND kcu.column_name = c.column_name WHERE c.table_name = $1 AND c.table_schema = 'public' ORDER BY c.ordinal_position `, [tableName]); res.json(result.rows); } catch (err) { res.status(500).json({ error: err.message }); } }); app.post('/api/tables', requireAuth, requirePermission((permissions, req) => { const folder = getTableFolder(req.body?.name); return permissions.canCreate && canAccessFolder(permissions, folder, 'create'); }, 'Access denied'), async (req, res) => { const { name, columns } = req.body || {}; if (!isValidIdentifier(name) || !Array.isArray(columns) || !columns.length) { return res.status(400).json({ success: false, error: 'Invalid table payload' }); } 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'); } let def = `${quoteIdentifier(col.name)} ${col.type}`; if (col.pk) def += ' PRIMARY KEY'; if (!col.nullable && !col.pk) def += ' NOT NULL'; return def; }).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) }); res.json({ success: true, message: 'Table created' }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.delete('/api/tables/:tableName', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'), 'Access denied' ), async (req, res) => { try { await pool.query(`DROP TABLE IF EXISTS ${quoteIdentifier(req.params.tableName)}`); appendAudit('table.deleted', req.currentUser.username, { table: req.params.tableName, source: getAuditSource(req) }); res.json({ success: true, message: 'Table deleted' }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/tables/:tableName/move', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canMoveTables && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName } = req.params; const { folder, name } = req.body || {}; const cleanName = String(name || '').trim(); const cleanFolder = String(folder || '').trim(); if (!isValidIdentifier(cleanName) || (cleanFolder && !isValidIdentifier(cleanFolder))) { return res.status(400).json({ success: false, error: 'Invalid table or folder name' }); } const nextName = cleanFolder ? `${cleanFolder}__${cleanName}` : cleanName; try { await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} RENAME TO ${quoteIdentifier(nextName)}`); appendAudit('table.moved', req.currentUser.username, { from: tableName, to: nextName, folder: cleanFolder || 'default', source: getAuditSource(req) }); res.json({ success: true, name: nextName }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'create'), 'Access denied' ), async (req, res) => { const { tableName } = req.params; const data = req.body || {}; try { const structureResult = await pool.query(` SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 AND table_schema = 'public' `, [tableName]); const structure = structureResult.rows; const filteredData = {}; 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; } if (colInfo.data_type === 'uuid') { filteredData[key] = value && String(value).trim() ? value : crypto.randomUUID(); } else { filteredData[key] = value; } } const columns = Object.keys(filteredData); if (!columns.length) { return res.status(400).json({ success: false, error: 'No record values provided' }); } const values = Object.values(filteredData); const placeholders = values.map((_, i) => `$${i + 1}`).join(', '); const sql = `INSERT INTO ${quoteIdentifier(tableName)} (${columns.map(quoteIdentifier).join(', ')}) VALUES (${placeholders}) RETURNING *`; const result = await pool.query(sql, values); appendAudit('record.created', req.currentUser.username, { table: tableName, source: getAuditSource(req) }); res.json({ success: true, data: result.rows[0] }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName, pk } = req.params; const data = req.body || {}; try { const primaryKey = await getPrimaryKeyColumn(tableName); 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}` : 'ctid::text = $' + (values.length + 1); const sql = `UPDATE ${quoteIdentifier(tableName)} SET ${setClause} WHERE ${whereClause} RETURNING *`; const result = await pool.query(sql, [...values, pk]); appendAudit('record.updated', req.currentUser.username, { table: tableName, key: pk, source: getAuditSource(req) }); res.json({ success: true, data: result.rows[0] }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.delete('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'), 'Access denied' ), async (req, res) => { const { tableName, pk } = req.params; try { const primaryKey = await getPrimaryKeyColumn(tableName); const whereClause = primaryKey ? `${quoteIdentifier(primaryKey)} = $1` : 'ctid::text = $1'; await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${whereClause}`, [pk]); appendAudit('record.deleted', req.currentUser.username, { table: tableName, key: pk, source: getAuditSource(req) }); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/tables/:tableName/columns', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName } = req.params; const { name, type, nullable = true, defaultValue, primaryKey } = req.body || {}; if (!isValidIdentifier(name) || !ALLOWED_SQL_TYPES.has(type)) { return res.status(400).json({ success: false, error: 'Invalid column definition' }); } const parts = [`${quoteIdentifier(name)} ${type}`]; if (primaryKey) parts.push('PRIMARY KEY'); if (!nullable) parts.push('NOT NULL'); if (defaultValue !== undefined && defaultValue !== null && defaultValue !== '') { parts.push(`DEFAULT ${defaultValue}`); } try { await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} ADD COLUMN ${parts.join(' ')}`); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.put('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName, columnName } = req.params; const { type, nullable, defaultValue } = req.body || {}; if (!isValidIdentifier(columnName)) { return res.status(400).json({ success: false, error: 'Invalid column name' }); } try { if (type) { if (!ALLOWED_SQL_TYPES.has(type)) { return res.status(400).json({ success: false, error: 'Invalid column type' }); } await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER COLUMN ${quoteIdentifier(columnName)} TYPE ${type}`); } if (typeof nullable === 'boolean') { const nullSql = nullable ? 'DROP NOT NULL' : 'SET NOT NULL'; await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER COLUMN ${quoteIdentifier(columnName)} ${nullSql}`); } if (defaultValue !== undefined) { if (defaultValue === null || defaultValue === '') { await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER COLUMN ${quoteIdentifier(columnName)} DROP DEFAULT`); } else { await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} ALTER COLUMN ${quoteIdentifier(columnName)} SET DEFAULT ${defaultValue}`); } } res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.delete('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'), 'Access denied' ), async (req, res) => { const { tableName, columnName } = req.params; if (!isValidIdentifier(columnName)) { return res.status(400).json({ success: false, error: 'Invalid column name' }); } try { await pool.query(`ALTER TABLE ${quoteIdentifier(tableName)} DROP COLUMN IF EXISTS ${quoteIdentifier(columnName)}`); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/query', requireAuth, requirePermission( (permissions) => permissions.canRunSql, 'SQL access denied' ), async (req, res) => { const { sql } = req.body || {}; if (!sql || typeof sql !== 'string') { return res.status(400).json({ success: false, error: 'SQL query is required' }); } try { const result = await pool.query(sql); appendAudit('sql.executed', req.currentUser.username, { command: result.command, sql: sql.slice(0, 400), source: getAuditSource(req, 'WEB') }); res.json({ success: true, rows: result.rows, rowCount: result.rowCount, command: result.command }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.get('/api/tables/:tableName/indexes', requireAuth, requireTableAccess, async (req, res) => { const { tableName } = req.params; try { const result = await pool.query(` SELECT indexname as name, indexdef as definition FROM pg_indexes WHERE tablename = $1 `, [tableName]); const indexes = result.rows.map(row => ({ name: row.name, columns: row.definition.match(/\((.*?)\)/)?.[1] || 'unknown', unique: row.definition.includes('UNIQUE'), type: 'btree' })); res.json(indexes); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.post('/api/tables/:tableName/indexes', requireAuth, requireTableAccess, requirePermission( (permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'), 'Access denied' ), async (req, res) => { const { tableName } = req.params; const { name, columns, unique } = req.body || {}; if (!isValidIdentifier(name)) { return res.status(400).json({ success: false, error: 'Invalid index name' }); } try { const uniqueStr = unique ? 'UNIQUE ' : ''; const columnList = String(columns || '').split(',').map((item) => item.trim()).filter(Boolean); if (!columnList.length || !columnList.every(isValidIdentifier)) { return res.status(400).json({ success: false, error: 'Invalid index columns' }); } const sql = `CREATE ${uniqueStr}INDEX ${quoteIdentifier(name)} ON ${quoteIdentifier(tableName)} (${columnList.map(quoteIdentifier).join(', ')})`; await pool.query(sql); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.delete('/api/indexes/:indexName', requireAuth, requirePermission( (permissions) => permissions.canDelete, 'Access denied' ), async (req, res) => { const { indexName } = req.params; if (!isValidIdentifier(indexName)) { return res.status(400).json({ success: false, error: 'Invalid index name' }); } try { await pool.query(`DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`); res.json({ success: true }); } catch (err) { res.status(500).json({ success: false, error: err.message }); } }); app.get('/api/containers', requireAuth, requirePermission( (permissions) => permissions.canViewLogs, 'Logs access denied' ), async (req, res) => { try { const containers = await listContainers(); res.json(containers); } catch (err) { res.status(500).json({ success: false, error: `Docker is unavailable: ${err.message}` }); } }); app.get('/api/containers/:name/logs', requireAuth, requirePermission( (permissions) => permissions.canViewLogs, 'Logs access denied' ), async (req, res) => { const tail = Math.min(Math.max(parseInt(req.query.tail, 10) || 200, 20), 1000); try { const container = await resolveContainer(req.params.name); const body = await dockerRequest(`/containers/${container.id}/logs?stdout=1&stderr=1&tail=${tail}×tamps=1`); res.json({ success: true, container, logs: demuxDockerChunk(body), }); } catch (err) { res.status(500).json({ success: false, error: `Failed to read container logs: ${err.message}` }); } }); app.get('/api/containers/:name/logs/stream', requireAuth, requirePermission( (permissions) => permissions.canViewLogs, 'Logs access denied' ), async (req, res) => { try { const container = await resolveContainer(req.params.name); const stream = await dockerRequest(`/containers/${container.id}/logs?stdout=1&stderr=1&follow=1&tail=50×tamps=1`, { stream: true }); res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', }); res.write(`event: meta\ndata: ${JSON.stringify({ name: container.name, status: container.status })}\n\n`); const heartbeat = setInterval(() => { res.write('event: heartbeat\ndata: {}\n\n'); }, 15000); stream.on('data', (chunk) => { const text = demuxDockerChunk(chunk); if (!text) return; text.split(/\r?\n/).filter(Boolean).forEach((line) => { res.write(`event: log\ndata: ${JSON.stringify({ line })}\n\n`); }); }); stream.on('end', () => { clearInterval(heartbeat); res.write('event: end\ndata: {}\n\n'); res.end(); }); stream.on('error', (error) => { clearInterval(heartbeat); res.write(`event: error\ndata: ${JSON.stringify({ message: error.message })}\n\n`); res.end(); }); req.on('close', () => { clearInterval(heartbeat); stream.destroy(); }); } catch (err) { res.status(500).json({ success: false, error: `Failed to stream logs: ${err.message}` }); } }); // Start server const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`🚀 Server running on http://localhost:${PORT}`); console.log(''); console.log('🔑 Default login credentials:'); console.log(` Username: ${process.env.ADMIN_USERNAME || 'admin'}`); console.log(` Password: ${process.env.ADMIN_PASSWORD || 'admin'}`); console.log(''); console.log('📝 Make sure to configure your database in .env file'); }); startBackupScheduler();