947 lines
35 KiB
JavaScript
947 lines
35 KiB
JavaScript
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 {
|
|
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('./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;
|
|
}
|
|
|
|
// 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 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 {
|
|
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');
|
|
});
|
|
|
|
|