ебать что это блять

This commit is contained in:
2026-03-20 16:08:38 +07:00
parent d969ac594e
commit 7123aac2cc
11 changed files with 1531 additions and 1196 deletions

325
src/lib/access.js Normal file
View File

@@ -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,
};

73
src/lib/audit.js Normal file
View File

@@ -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,
};

96
src/lib/docker.js Normal file
View File

@@ -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,
};

102
src/services/backups.js Normal file
View File

@@ -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,
};

View File

@@ -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 = [
'<b>PG Admin error</b>',
title,
error?.message || String(error || ''),
Object.keys(context).length ? JSON.stringify(context) : '',
].filter(Boolean).join('\n');
return sendTelegramMessage(message);
}
module.exports = {
notifyError,
sendTelegramMessage,
};