Добавление админки
This commit is contained in:
353
server.js
353
server.js
@@ -90,6 +90,7 @@ function normalizeUser(user) {
|
||||
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),
|
||||
};
|
||||
}
|
||||
@@ -98,33 +99,144 @@ function getUser(username) {
|
||||
return readUsersConfig().users.find((user) => user.username === username) || null;
|
||||
}
|
||||
|
||||
function getRolePermissions(role, folders = 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 { role, folders: null, canCreate: true, canEdit: true, canDelete: true, canViewLogs: true, canRunSql: true };
|
||||
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, canCreate: true, canEdit: true, canDelete: true, canViewLogs: true, canRunSql: true };
|
||||
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, canCreate: true, canEdit: true, canDelete: false, canViewLogs: false, canRunSql: false };
|
||||
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, canCreate: false, canEdit: false, canDelete: false, canViewLogs: false, canRunSql: 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 canAccessTable(permissionsOrRole, tableName, folders = null) {
|
||||
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)
|
||||
? getRolePermissions(permissionsOrRole, folders, access)
|
||||
: permissionsOrRole;
|
||||
if (!perms.folders) return true;
|
||||
return perms.folders.includes(getTableFolder(tableName));
|
||||
return isScopeAllowed(perms.access?.[action], tableName);
|
||||
}
|
||||
|
||||
function canAccessFolder(permissions, folder) {
|
||||
if (!permissions.folders) return true;
|
||||
return permissions.folders.includes(folder);
|
||||
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) {
|
||||
@@ -138,11 +250,11 @@ function quoteIdentifier(identifier) {
|
||||
return `"${identifier}"`;
|
||||
}
|
||||
|
||||
function createSessionUser({ username, role, folders }) {
|
||||
function createSessionUser({ username, role, folders, access }) {
|
||||
return {
|
||||
username,
|
||||
role,
|
||||
permissions: getRolePermissions(role, folders),
|
||||
permissions: getRolePermissions(role, folders, access),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +270,82 @@ async function verifyPassword(user, password) {
|
||||
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 dockerRequest(requestPath, { stream = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
@@ -326,7 +514,7 @@ const requireTableAccess = (req, res, next) => {
|
||||
return res.status(400).json({ success: false, error: 'Invalid table name' });
|
||||
}
|
||||
|
||||
if (!canAccessTable(req.currentUser.permissions, tableName)) {
|
||||
if (!canAccessTable(req.currentUser.permissions, tableName, null, null, 'view')) {
|
||||
return res.status(403).json({ success: false, error: 'Access denied' });
|
||||
}
|
||||
|
||||
@@ -429,6 +617,83 @@ app.get('/api/session', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Get all tables
|
||||
app.get('/api/tables', requireAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -440,7 +705,7 @@ app.get('/api/tables', requireAuth, async (req, res) => {
|
||||
ORDER BY table_name
|
||||
`);
|
||||
|
||||
const accessibleTables = result.rows.filter(table => canAccessTable(req.currentUser.permissions, 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 {
|
||||
@@ -512,7 +777,7 @@ app.get('/api/tables/:tableName/data', requireAuth, requireTableAccess, async (r
|
||||
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 * FROM ${quoteIdentifier(tableName)}
|
||||
SELECT ctid::text AS "__rowid", * FROM ${quoteIdentifier(tableName)}
|
||||
${whereClause}
|
||||
${orderBy}
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
|
||||
@@ -562,7 +827,7 @@ app.get('/api/tables/:tableName/structure', requireAuth, requireTableAccess, asy
|
||||
|
||||
app.post('/api/tables', requireAuth, requirePermission((permissions, req) => {
|
||||
const folder = getTableFolder(req.body?.name);
|
||||
return permissions.canCreate && canAccessFolder(permissions, folder);
|
||||
return permissions.canCreate && canAccessFolder(permissions, folder, 'create');
|
||||
}, 'Access denied'), async (req, res) => {
|
||||
const { name, columns } = req.body || {};
|
||||
|
||||
@@ -590,7 +855,7 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => {
|
||||
});
|
||||
|
||||
app.delete('/api/tables/:tableName', requireAuth, requireTableAccess, requirePermission(
|
||||
(permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName),
|
||||
(permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'),
|
||||
'Access denied'
|
||||
), async (req, res) => {
|
||||
try {
|
||||
@@ -601,8 +866,30 @@ app.delete('/api/tables/:tableName', requireAuth, requireTableAccess, requirePer
|
||||
}
|
||||
});
|
||||
|
||||
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)}`);
|
||||
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),
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'create'),
|
||||
'Access denied'
|
||||
), async (req, res) => {
|
||||
const { tableName } = req.params;
|
||||
@@ -646,7 +933,7 @@ app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requ
|
||||
});
|
||||
|
||||
app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, requirePermission(
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName),
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'),
|
||||
'Access denied'
|
||||
), async (req, res) => {
|
||||
const { tableName, pk } = req.params;
|
||||
@@ -658,10 +945,13 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r
|
||||
}
|
||||
|
||||
try {
|
||||
const primaryKey = await getPrimaryKeyColumn(tableName) || 'id';
|
||||
const primaryKey = await getPrimaryKeyColumn(tableName);
|
||||
const values = columns.map((column) => data[column]);
|
||||
const setClause = columns.map((col, i) => `${quoteIdentifier(col)} = $${i + 1}`).join(', ');
|
||||
const sql = `UPDATE ${quoteIdentifier(tableName)} SET ${setClause} WHERE ${quoteIdentifier(primaryKey)} = $${values.length + 1} RETURNING *`;
|
||||
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]);
|
||||
res.json({ success: true, data: result.rows[0] });
|
||||
} catch (err) {
|
||||
@@ -670,14 +960,17 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r
|
||||
});
|
||||
|
||||
app.delete('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, requirePermission(
|
||||
(permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName),
|
||||
(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) || 'id';
|
||||
await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${quoteIdentifier(primaryKey)} = $1`, [pk]);
|
||||
const primaryKey = await getPrimaryKeyColumn(tableName);
|
||||
const whereClause = primaryKey
|
||||
? `${quoteIdentifier(primaryKey)} = $1`
|
||||
: 'ctid::text = $1';
|
||||
await pool.query(`DELETE FROM ${quoteIdentifier(tableName)} WHERE ${whereClause}`, [pk]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
@@ -685,7 +978,7 @@ app.delete('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess
|
||||
});
|
||||
|
||||
app.post('/api/tables/:tableName/columns', requireAuth, requireTableAccess, requirePermission(
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName),
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'),
|
||||
'Access denied'
|
||||
), async (req, res) => {
|
||||
const { tableName } = req.params;
|
||||
@@ -711,7 +1004,7 @@ app.post('/api/tables/:tableName/columns', requireAuth, requireTableAccess, requ
|
||||
});
|
||||
|
||||
app.put('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableAccess, requirePermission(
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName),
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'),
|
||||
'Access denied'
|
||||
), async (req, res) => {
|
||||
const { tableName, columnName } = req.params;
|
||||
@@ -749,7 +1042,7 @@ app.put('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableA
|
||||
});
|
||||
|
||||
app.delete('/api/tables/:tableName/columns/:columnName', requireAuth, requireTableAccess, requirePermission(
|
||||
(permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName),
|
||||
(permissions, req) => permissions.canDelete && canAccessTable(permissions, req.params.tableName, null, null, 'delete'),
|
||||
'Access denied'
|
||||
), async (req, res) => {
|
||||
const { tableName, columnName } = req.params;
|
||||
@@ -815,7 +1108,7 @@ app.get('/api/tables/:tableName/indexes', requireAuth, requireTableAccess, async
|
||||
});
|
||||
|
||||
app.post('/api/tables/:tableName/indexes', requireAuth, requireTableAccess, requirePermission(
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName),
|
||||
(permissions, req) => permissions.canEdit && canAccessTable(permissions, req.params.tableName, null, null, 'edit'),
|
||||
'Access denied'
|
||||
), async (req, res) => {
|
||||
const { tableName } = req.params;
|
||||
|
||||
Reference in New Issue
Block a user