diff --git a/.gitignore b/.gitignore
index 41bfca2..e6dd91c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -164,3 +164,5 @@ tmp/
temp/
*.tmp
.cache/
+audit.log
+backups/
diff --git a/public/assets/app.css b/public/assets/app.css
new file mode 100644
index 0000000..8726def
--- /dev/null
+++ b/public/assets/app.css
@@ -0,0 +1,116 @@
+:root {
+ color-scheme: light;
+ }
+ body[data-theme="dark"] {
+ color-scheme: dark;
+ }
+ body { font-family: 'Inter', sans-serif; }
+ .font-mono { font-family: 'JetBrains Mono', monospace; }
+ .glass-panel {
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ }
+ .sidebar-item:hover {
+ background: linear-gradient(90deg, rgba(59, 130, 246, 0.1) 0%, transparent 100%);
+ }
+ .sql-keyword { color: #c678dd; }
+ .sql-string { color: #98c379; }
+ .sql-function { color: #61afef; }
+ .sql-comment { color: #5c6370; font-style: italic; }
+
+ /* Custom scrollbar */
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
+ ::-webkit-scrollbar-track { background: #f1f5f9; }
+ ::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
+ ::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
+
+ .fade-in { animation: fadeIn 0.3s ease-in; }
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
+
+ .loader {
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #3b82f6;
+ border-radius: 50%;
+ width: 24px;
+ height: 24px;
+ animation: spin 1s linear infinite;
+ }
+ @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
+ body {
+ transition: background-color 0.25s ease, color 0.25s ease;
+ }
+ body[data-theme="dark"] .bg-white { background-color: #111827 !important; }
+ body[data-theme="dark"] .bg-slate-50 { background-color: #0f172a !important; }
+ body[data-theme="dark"] .bg-slate-100 { background-color: #1e293b !important; }
+ body[data-theme="dark"] .bg-slate-800 { background-color: #0f172a !important; }
+ body[data-theme="dark"] .bg-slate-900 { background-color: #020617 !important; }
+ body[data-theme="dark"] .bg-blue-50 { background-color: rgba(59, 130, 246, 0.16) !important; }
+ body[data-theme="dark"] .bg-green-50 { background-color: rgba(34, 197, 94, 0.16) !important; }
+ body[data-theme="dark"] .bg-red-50 { background-color: rgba(239, 68, 68, 0.16) !important; }
+ body[data-theme="dark"] .text-slate-800 { color: #e2e8f0 !important; }
+ body[data-theme="dark"] .text-slate-700 { color: #cbd5e1 !important; }
+ body[data-theme="dark"] .text-slate-600 { color: #94a3b8 !important; }
+ body[data-theme="dark"] .text-slate-500, body[data-theme="dark"] .text-slate-400 { color: #64748b !important; }
+ body[data-theme="dark"] .text-slate-300 { color: #cbd5e1 !important; }
+ body[data-theme="dark"] .border-slate-200 { border-color: #1e293b !important; }
+ body[data-theme="dark"] .border-slate-300 { border-color: #334155 !important; }
+ body[data-theme="dark"] .border-slate-700 { border-color: #334155 !important; }
+ body[data-theme="dark"] .border-slate-800 { border-color: #1e293b !important; }
+ body[data-theme="dark"] input,
+ body[data-theme="dark"] textarea,
+ body[data-theme="dark"] select {
+ background-color: #0f172a !important;
+ color: #e2e8f0 !important;
+ border-color: #334155 !important;
+ }
+ body[data-theme="dark"] .glass-panel {
+ background: rgba(15, 23, 42, 0.92);
+ border-color: rgba(148, 163, 184, 0.12);
+ }
+ body[data-theme="dark"] ::-webkit-scrollbar-track { background: #0f172a; }
+ body[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #334155; }
+ .sidebar-collapsible {
+ transition: max-height 0.24s ease;
+ }
+ .log-terminal {
+ min-height: 18rem;
+ max-height: 28rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+ @media (max-width: 900px) {
+ #mobileBackdrop:not(.hidden) {
+ display: block;
+ }
+ #sidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ z-index: 30;
+ width: min(88vw, 20rem);
+ transform: translateX(-100%);
+ transition: transform 0.25s ease;
+ }
+ #sidebar.sidebar-open {
+ transform: translateX(0);
+ }
+ #mainHeader {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ }
+ #toolbar {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.75rem;
+ }
+ #recordControls {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+ #recordSearch {
+ width: 100%;
+ }
+ #contentArea {
+ padding: 1rem;
+ }
+ }
diff --git a/index.html b/public/assets/app.js
similarity index 61%
rename from index.html
rename to public/assets/app.js
index 82cc4e0..1900041 100644
--- a/index.html
+++ b/public/assets/app.js
@@ -1,698 +1,4 @@
-
-
-
-
-
- PostgreSQL SensoLab Panel
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
PostgreSQL SensoLab
-
Войдите для управления базой данных
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- PostgreSQL SensoLab
-
-
-
-
-
localhost:5432/postgres
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Выберите таблицу из списка слева или выполните SQL-запрос
-
-
-
-
-
-
-
- Страница 1 из 1
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- SQL Query Editor
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Logs are available for admin roles.
-
-
Select a container to load recent logs.
-
-
-
-
-
-
-
-
-
-
-
-
-
Создать новую таблицу
-
-
-
-
-
-
-
Если указано, имя таблицы будет создано как папка__имя. Оставьте пустым для создания в корне.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Добавить запись
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Структура таблицы:
-
-
-
-
-
-
-
-
-
- | Колонка |
- Тип |
- NULL |
- По умолчанию |
- Действия |
-
-
-
-
-
-
-
-
-
-
-
-
-
Индексы таблицы:
-
-
-
-
-
-
-
-
-
- | Название |
- Колонки |
- Тип |
- Уникальный |
- Действия |
-
-
-
-
-
-
-
-
-
-
-
-
-
Добавить колонку
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Создать индекс
-
-
-
-
-
-
-
-
-
-
-
-
-
Move table
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Users & access
-
-
-
-
-
-
-
- | Username |
- Role |
- Status |
- Actions |
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Choose folders
-
-
-
-
-
-
- Choose tables
-
-
-
-
-
-
- Choose folders
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Audit log
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/favicon.svg b/public/favicon.svg
similarity index 100%
rename from favicon.svg
rename to public/favicon.svg
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..45dea56
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,603 @@
+
+
+
+
+
+ PostgreSQL SensoLab Panel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
PostgreSQL SensoLab
+
Войдите для управления базой данных
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PostgreSQL SensoLab
+
+
+
+
+
localhost:5432/postgres
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Выберите таблицу из списка слева или выполните SQL-запрос
+
+
+
+
+
+
+
+ Страница 1 из 1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ SQL Query Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Logs are available for admin roles.
+
+
Select a container to load recent logs.
+
+
+
+
+
+
+
+
+
+
+
+
+
Создать новую таблицу
+
+
+
+
+
+
+
Если указано, имя таблицы будет создано как папка__имя. Оставьте пустым для создания в корне.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Добавить запись
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Структура таблицы:
+
+
+
+
+
+
+
+
+
+ | Колонка |
+ Тип |
+ NULL |
+ По умолчанию |
+ Действия |
+
+
+
+
+
+
+
+
+
+
+
+
+
Индексы таблицы:
+
+
+
+
+
+
+
+
+
+ | Название |
+ Колонки |
+ Тип |
+ Уникальный |
+ Действия |
+
+
+
+
+
+
+
+
+
+
+
+
+
Добавить колонку
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Создать индекс
+
+
+
+
+
+
+
+
+
+
+
+
+
Move table
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Users & access
+
+
+
+
+
+
+
+ | Username |
+ Role |
+ Status |
+ Actions |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Choose folders
+
+
+
+
+
+
+ Choose tables
+
+
+
+
+
+
+ Choose folders
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Audit log
+
+
+
+
+
+
+
+
+
+
Backups
+
+
+
+
+
Create and download recovery snapshots.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server.js b/server.js
index 06203f9..3444c48 100644
--- a/server.js
+++ b/server.js
@@ -3,502 +3,48 @@ const express = require('express');
const { Pool } = require('pg');
const session = require('express-session');
const cors = require('cors');
-const path = require('path');
-const fs = require('fs');
-const http = require('http');
-const bcrypt = require('bcryptjs');
const crypto = require('crypto');
-
-let usersConfig = { users: [] };
-try {
- usersConfig = JSON.parse(fs.readFileSync(path.join(__dirname, 'users.json'), 'utf8'));
-} catch (err) {
- console.warn('⚠️ users.json not found or invalid JSON. Falling back to env-based admin only.');
-}
-
-const rolePermissions = {
- superadmin: { folders: null, canCreate: true, canEdit: true, canDelete: true },
- frontend_admin: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: true },
- backend_admin: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: true },
- frontend_moder: { folders: ['frontend'], canCreate: true, canEdit: true, canDelete: false },
- backend_moder: { folders: ['backend'], canCreate: true, canEdit: true, canDelete: false },
- viewer: { folders: null, canCreate: false, canEdit: false, canDelete: false },
-};
-
-function getUser(username) {
- return usersConfig.users.find(u => u.username === username);
-}
-
-function getTableFolder(tableName) {
- if (!tableName) return 'default';
- const parts = tableName.split('__');
- return parts.length > 1 ? parts[0] : 'default';
-}
-
-function getRolePermissions(role) {
- return rolePermissions[role] || rolePermissions.viewer;
-}
-
-function canAccessTable(role, tableName) {
- const perms = getRolePermissions(role);
- if (!perms.folders) return true;
- const folder = getTableFolder(tableName);
- return perms.folders.includes(folder);
-}
-
-const USERS_FILE = path.join(__dirname, 'users.json');
-const AUDIT_LOG_FILE = path.join(__dirname, 'audit.log');
-const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock';
-const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41';
-const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
-const ALLOWED_SQL_TYPES = new Set(['VARCHAR(255)', 'TEXT', 'INTEGER', 'BIGINT', 'DECIMAL', 'BOOLEAN', 'DATE', 'TIMESTAMP', 'UUID', 'JSON', 'JSONB']);
-const LEGACY_ROLE_MAP = {
- frontend_admin: { role: 'admin', folders: ['frontend'] },
- backend_admin: { role: 'admin', folders: ['backend'] },
- frontend_moder: { role: 'moderator', folders: ['frontend'] },
- backend_moder: { role: 'moderator', folders: ['backend'] },
- viewer: { role: 'viewer', folders: null },
- superadmin: { role: 'superadmin', folders: null },
-};
-
-function readUsersConfig() {
- try {
- const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
- const users = Array.isArray(parsed.users) ? parsed.users : [];
- return { users: users.map(normalizeUser).filter(Boolean) };
- } catch (err) {
- console.warn('users.json not found or invalid JSON. Falling back to env-based superadmin only.');
- return { users: [] };
- }
-}
-
-function normalizeUser(user) {
- if (!user || typeof user.username !== 'string') {
- return null;
- }
-
- const legacy = LEGACY_ROLE_MAP[user.role];
- const role = legacy ? legacy.role : user.role;
- const folders = Array.isArray(user.folders)
- ? user.folders.filter(Boolean)
- : legacy
- ? legacy.folders
- : null;
-
- return {
- username: user.username,
- password: typeof user.password === 'string' ? user.password : undefined,
- passwordHash: typeof user.passwordHash === 'string' ? user.passwordHash : undefined,
- role: ['admin', 'moderator', 'viewer', 'superadmin'].includes(role) ? role : 'viewer',
- folders,
- access: normalizeAccess(user.access, role, folders),
- disabled: Boolean(user.disabled),
- };
-}
-
-function getUser(username) {
- return readUsersConfig().users.find((user) => user.username === username) || null;
-}
-
-function normalizeScope(scope, fallbackFolders = null) {
- if (scope === null) {
- return { folders: null, tables: null };
- }
-
- return {
- folders: Array.isArray(scope?.folders)
- ? scope.folders.filter(Boolean)
- : fallbackFolders,
- tables: Array.isArray(scope?.tables)
- ? scope.tables.filter(Boolean)
- : [],
- };
-}
-
-function normalizeAccess(access, role, folders = null) {
- if (role === 'superadmin') {
- return {
- view: { folders: null, tables: null },
- create: { folders: null, tables: null },
- edit: { folders: null, tables: null },
- delete: { folders: null, tables: null },
- };
- }
-
- const baseFolders = folders && folders.length ? folders : null;
- const defaultsByRole = {
- admin: {
- view: { folders: baseFolders, tables: [] },
- create: { folders: baseFolders, tables: [] },
- edit: { folders: baseFolders, tables: [] },
- delete: { folders: baseFolders, tables: [] },
- },
- moderator: {
- view: { folders: baseFolders, tables: [] },
- create: { folders: baseFolders, tables: [] },
- edit: { folders: baseFolders, tables: [] },
- delete: { folders: [], tables: [] },
- },
- viewer: {
- view: { folders: baseFolders, tables: [] },
- create: { folders: [], tables: [] },
- edit: { folders: [], tables: [] },
- delete: { folders: [], tables: [] },
- },
- };
-
- const defaults = defaultsByRole[role] || defaultsByRole.viewer;
- const source = access && typeof access === 'object' ? access : {};
-
- return {
- view: normalizeScope(source.view, defaults.view.folders),
- create: normalizeScope(source.create, defaults.create.folders),
- edit: normalizeScope(source.edit, defaults.edit.folders),
- delete: normalizeScope(source.delete, defaults.delete.folders),
- };
-}
-
-function getRolePermissions(role, folders = null, access = null) {
- const normalizedAccess = normalizeAccess(access, role, folders);
- if (role === 'superadmin') {
- return {
- role,
- folders: null,
- access: normalizedAccess,
- canCreate: true,
- canEdit: true,
- canDelete: true,
- canViewLogs: true,
- canRunSql: true,
- canManageUsers: true,
- canMoveTables: true,
- };
- }
-
- if (role === 'admin') {
- return {
- role,
- folders: folders && folders.length ? folders : null,
- access: normalizedAccess,
- canCreate: true,
- canEdit: true,
- canDelete: true,
- canViewLogs: true,
- canRunSql: true,
- canManageUsers: true,
- canMoveTables: true,
- };
- }
-
- if (role === 'moderator') {
- return {
- role,
- folders: folders && folders.length ? folders : null,
- access: normalizedAccess,
- canCreate: true,
- canEdit: true,
- canDelete: false,
- canViewLogs: false,
- canRunSql: false,
- canManageUsers: false,
- canMoveTables: false,
- };
- }
-
- return {
- role: 'viewer',
- folders: folders && folders.length ? folders : null,
- access: normalizedAccess,
- canCreate: false,
- canEdit: false,
- canDelete: false,
- canViewLogs: false,
- canRunSql: false,
- canManageUsers: false,
- canMoveTables: false,
- };
-}
-
-function isScopeAllowed(scope, tableName) {
- if (!scope) return false;
- if (scope.tables === null || scope.folders === null) return true;
- const folder = getTableFolder(tableName);
- return scope.tables.includes(tableName) || scope.folders.includes(folder);
-}
-
-function canAccessTable(permissionsOrRole, tableName, folders = null, access = null, action = 'view') {
- const perms = typeof permissionsOrRole === 'string'
- ? getRolePermissions(permissionsOrRole, folders, access)
- : permissionsOrRole;
- return isScopeAllowed(perms.access?.[action], tableName);
-}
-
-function canAccessFolder(permissions, folder, action = 'view') {
- const scope = permissions.access?.[action];
- if (!scope) return false;
- if (scope.folders === null) return true;
- return scope.folders.includes(folder);
-}
-
-function isValidIdentifier(value) {
- return SAFE_IDENTIFIER.test(value);
-}
-
-function quoteIdentifier(identifier) {
- if (!isValidIdentifier(identifier)) {
- throw new Error(`Unsafe identifier: ${identifier}`);
- }
- return `"${identifier}"`;
-}
-
-function createSessionUser({ username, role, folders, access }) {
- return {
- username,
- role,
- permissions: getRolePermissions(role, folders, access),
- };
-}
-
-async function verifyPassword(user, password) {
- if (!user || user.disabled) {
- return false;
- }
-
- if (user.passwordHash) {
- return bcrypt.compare(password, user.passwordHash);
- }
-
- return user.password === password;
-}
-
-function sanitizeUser(user) {
- return {
- username: user.username,
- role: user.role,
- folders: user.folders,
- access: user.access,
- disabled: user.disabled,
- };
-}
-
-function validateScopeInput(scope) {
- if (scope === null) {
- return { folders: null, tables: null };
- }
-
- return {
- folders: Array.isArray(scope?.folders) ? scope.folders.filter(Boolean) : [],
- tables: Array.isArray(scope?.tables) ? scope.tables.filter(Boolean) : [],
- };
-}
-
-function validateUserPayload(payload, { allowPasswordOptional = false } = {}) {
- if (!payload || typeof payload.username !== 'string' || !payload.username.trim()) {
- throw new Error('Username is required');
- }
-
- if (!['admin', 'moderator', 'viewer'].includes(payload.role)) {
- throw new Error('Invalid role');
- }
-
- if (!allowPasswordOptional && (!payload.password || typeof payload.password !== 'string')) {
- throw new Error('Password is required');
- }
-
- const folders = Array.isArray(payload.folders) ? payload.folders.filter(Boolean) : null;
- const access = payload.access && typeof payload.access === 'object'
- ? {
- view: validateScopeInput(payload.access.view),
- create: validateScopeInput(payload.access.create),
- edit: validateScopeInput(payload.access.edit),
- delete: validateScopeInput(payload.access.delete),
- }
- : normalizeAccess(null, payload.role, folders);
-
- return {
- username: payload.username.trim(),
- password: typeof payload.password === 'string' ? payload.password : undefined,
- role: payload.role,
- folders,
- access,
- disabled: Boolean(payload.disabled),
- };
-}
-
-function writeUsersConfig(users) {
- const serialized = JSON.stringify({
- users: users.map((user) => {
- const payload = {
- username: user.username,
- role: user.role,
- folders: user.folders,
- access: user.access,
- disabled: Boolean(user.disabled),
- };
- if (user.passwordHash) {
- payload.passwordHash = user.passwordHash;
- } else if (user.password) {
- payload.password = user.password;
- }
- return payload;
- }),
- }, null, 2);
-
- fs.writeFileSync(USERS_FILE, serialized, 'utf8');
-}
-
-function appendAudit(event, actor, details = {}) {
- const source = details.source || 'WEB';
- const entry = {
- timestamp: new Date().toISOString(),
- event,
- actor: actor || 'system',
- source,
- summary: formatAuditSummary(event, details),
- details,
- };
- fs.appendFileSync(AUDIT_LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8');
-}
-
-function formatAuditSummary(event, details = {}) {
- const summaries = {
- 'login.success': `successful login`,
- 'login.failed': `failed login attempt`,
- 'logout': `logout`,
- 'user.created': `created user ${details.username || ''}`.trim(),
- 'user.updated': `updated user ${details.username || ''}`.trim(),
- 'user.deleted': `deleted user ${details.username || ''}`.trim(),
- 'table.created': `created table ${details.table || ''}`.trim(),
- 'table.deleted': `deleted table ${details.table || ''}`.trim(),
- 'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(),
- 'record.created': `created record in ${details.table || ''}`.trim(),
- 'record.updated': `updated record in ${details.table || ''}`.trim(),
- 'record.deleted': `deleted record from ${details.table || ''}`.trim(),
- 'sql.executed': `executed ${details.command || 'SQL'} query`,
- };
-
- return summaries[event] || event;
-}
-
-function getAuditSource(req, fallback = 'WEB') {
- const header = String(req?.headers?.['x-request-source'] || '').trim().toUpperCase();
- if (header === 'AI' || header === 'WEB') {
- return header;
- }
- return fallback;
-}
-
-function readAuditLog(limit = 200) {
- if (!fs.existsSync(AUDIT_LOG_FILE)) {
- return [];
- }
-
- return fs.readFileSync(AUDIT_LOG_FILE, 'utf8')
- .split(/\r?\n/)
- .filter(Boolean)
- .map((line) => {
- try {
- return JSON.parse(line);
- } catch (error) {
- return null;
- }
- })
- .filter(Boolean)
- .slice(-limit)
- .reverse();
-}
-
-function dockerRequest(requestPath, { stream = false } = {}) {
- return new Promise((resolve, reject) => {
- const req = http.request({
- socketPath: DOCKER_SOCKET_PATH,
- path: `${DOCKER_API_PREFIX}${requestPath}`,
- method: 'GET',
- }, (response) => {
- if (stream) {
- if (response.statusCode >= 400) {
- const chunks = [];
- response.on('data', (chunk) => chunks.push(chunk));
- response.on('end', () => reject(new Error(Buffer.concat(chunks).toString('utf8') || 'Docker stream error')));
- return;
- }
- resolve(response);
- return;
- }
-
- const chunks = [];
- response.on('data', (chunk) => chunks.push(chunk));
- response.on('end', () => {
- const body = Buffer.concat(chunks);
- if (response.statusCode >= 400) {
- reject(new Error(body.toString('utf8') || 'Docker API error'));
- return;
- }
- resolve(body);
- });
- });
-
- req.on('error', reject);
- req.end();
- });
-}
-
-function demuxDockerChunk(buffer) {
- let offset = 0;
- let output = '';
-
- while (offset + 8 <= buffer.length) {
- const payloadLength = buffer.readUInt32BE(offset + 4);
- const payloadStart = offset + 8;
- const payloadEnd = payloadStart + payloadLength;
-
- if (payloadEnd > buffer.length) {
- output += buffer.slice(offset).toString('utf8');
- return output;
- }
-
- output += buffer.slice(payloadStart, payloadEnd).toString('utf8');
- offset = payloadEnd;
- }
-
- if (offset < buffer.length) {
- output += buffer.slice(offset).toString('utf8');
- }
-
- return output;
-}
-
-async function listContainers() {
- const body = await dockerRequest('/containers/json?all=1');
- const containers = JSON.parse(body.toString('utf8'));
- return containers.map((container) => ({
- id: container.Id,
- name: container.Names?.[0]?.replace(/^\//, '') || container.Id.slice(0, 12),
- state: container.State,
- status: container.Status,
- image: container.Image,
- }));
-}
-
-async function resolveContainer(nameOrId) {
- const containers = await listContainers();
- const container = containers.find((item) =>
- item.id === nameOrId || item.id.startsWith(nameOrId) || item.name === nameOrId
- );
-
- if (!container) {
- throw new Error('Container not found');
- }
-
- return container;
-}
+const {
+ ALLOWED_SQL_TYPES,
+ canAccessFolder,
+ canAccessTable,
+ createSessionUser,
+ getTableFolder,
+ getUser,
+ isValidIdentifier,
+ quoteIdentifier,
+ readUsersConfig,
+ sanitizeUser,
+ validateUserPayload,
+ verifyPassword,
+ writeUsersConfig,
+} = require('./src/lib/access');
+const {
+ appendAudit,
+ getAuditSource,
+ readAuditLog,
+} = require('./src/lib/audit');
+const {
+ demuxDockerChunk,
+ dockerRequest,
+ listContainers,
+ resolveContainer,
+} = require('./src/lib/docker');
+const {
+ createBackup,
+ getBackupPath,
+ listBackups,
+} = require('./src/services/backups');
+const {
+ notifyError,
+} = require('./src/services/notifications');
const app = express();
// Middleware
app.use(cors());
app.use(express.json({ limit: '1mb' }));
-app.use(express.static('.'));
+app.use(express.static('./public'));
// Session configuration
app.use(session({
@@ -534,6 +80,29 @@ pool.connect((err, client, release) => {
}
});
+function applyRecordMetadata(structure, payload, currentUser, { isCreate = false } = {}) {
+ const data = { ...payload };
+ const hasColumn = (name) => structure.some((col) => col.column_name === name);
+
+ if (isCreate) {
+ if (hasColumn('created_by') && !data.created_by) {
+ data.created_by = currentUser.username;
+ }
+ if (hasColumn('created_at') && !data.created_at) {
+ data.created_at = new Date().toISOString();
+ }
+ }
+
+ if (hasColumn('updated_by')) {
+ data.updated_by = currentUser.username;
+ }
+ if (hasColumn('updated_at')) {
+ data.updated_at = new Date().toISOString();
+ }
+
+ return data;
+}
+
// Helper: get primary key column for a table (returns null if none)
async function getPrimaryKeyColumn(tableName) {
const result = await pool.query(`
@@ -772,6 +341,39 @@ app.get('/api/audit', requireAuth, requirePermission(
res.json(readAuditLog(limit));
});
+app.get('/api/backups', requireAuth, requirePermission(
+ (permissions) => permissions.canManageUsers,
+ 'Backup access denied'
+), (req, res) => {
+ res.json(listBackups());
+});
+
+app.post('/api/backups', requireAuth, requirePermission(
+ (permissions) => permissions.canManageUsers,
+ 'Backup access denied'
+), async (req, res) => {
+ try {
+ const backup = await createBackup(pool, req.currentUser.username);
+ appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) });
+ res.json({ success: true, backup });
+ } catch (err) {
+ notifyError('Backup creation failed', err, { actor: req.currentUser.username }).catch(() => {});
+ res.status(500).json({ success: false, error: err.message });
+ }
+});
+
+app.get('/api/backups/:filename/download', requireAuth, requirePermission(
+ (permissions) => permissions.canManageUsers,
+ 'Backup access denied'
+), (req, res) => {
+ try {
+ const filePath = getBackupPath(req.params.filename);
+ res.download(filePath, req.params.filename);
+ } catch (err) {
+ res.status(404).json({ success: false, error: err.message });
+ }
+});
+
// Get all tables
app.get('/api/tables', requireAuth, async (req, res) => {
try {
@@ -914,6 +516,7 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => {
}
try {
+ const reservedColumns = new Set(columns.map((col) => col.name));
const columnsSQL = columns.map((col) => {
if (!isValidIdentifier(col.name) || !ALLOWED_SQL_TYPES.has(col.type)) {
throw new Error('Invalid column definition');
@@ -923,7 +526,12 @@ app.post('/api/tables', requireAuth, requirePermission((permissions, req) => {
if (col.pk) def += ' PRIMARY KEY';
if (!col.nullable && !col.pk) def += ' NOT NULL';
return def;
- }).join(', ');
+ }).concat([
+ !reservedColumns.has('created_at') ? `"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` : null,
+ !reservedColumns.has('created_by') ? `"created_by" VARCHAR(255)` : null,
+ !reservedColumns.has('updated_at') ? `"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP` : null,
+ !reservedColumns.has('updated_by') ? `"updated_by" VARCHAR(255)` : null,
+ ].filter(Boolean)).join(', ');
await pool.query(`CREATE TABLE ${quoteIdentifier(name)} (${columnsSQL})`);
appendAudit('table.created', req.currentUser.username, { table: name, source: getAuditSource(req) });
@@ -985,7 +593,8 @@ app.post('/api/tables/:tableName/records', requireAuth, requireTableAccess, requ
const structure = structureResult.rows;
const filteredData = {};
- for (const [key, value] of Object.entries(data)) {
+ const dataWithMetadata = applyRecordMetadata(structure, data, req.currentUser, { isCreate: true });
+ for (const [key, value] of Object.entries(dataWithMetadata)) {
const colInfo = structure.find(col => col.column_name === key);
if (!colInfo || value === '') {
continue;
@@ -1020,15 +629,22 @@ app.put('/api/tables/:tableName/records/:pk', requireAuth, requireTableAccess, r
), async (req, res) => {
const { tableName, pk } = req.params;
const data = req.body || {};
- const columns = Object.keys(data).filter(isValidIdentifier);
-
- if (!columns.length) {
- return res.status(400).json({ success: false, error: 'No valid fields to update' });
- }
try {
const primaryKey = await getPrimaryKeyColumn(tableName);
- const values = columns.map((column) => data[column]);
+ const structure = await pool.query(`
+ SELECT column_name, data_type
+ FROM information_schema.columns
+ WHERE table_name = $1 AND table_schema = 'public'
+ `, [tableName]).then((result) => result.rows);
+ const dataWithMetadata = applyRecordMetadata(structure, data, req.currentUser, { isCreate: false });
+ const columns = Object.keys(dataWithMetadata).filter(isValidIdentifier);
+
+ if (!columns.length) {
+ return res.status(400).json({ success: false, error: 'No valid fields to update' });
+ }
+
+ const values = columns.map((column) => dataWithMetadata[column]);
const setClause = columns.map((col, i) => `${quoteIdentifier(col)} = $${i + 1}`).join(', ');
const whereClause = primaryKey
? `${quoteIdentifier(primaryKey)} = $${values.length + 1}`
@@ -1326,3 +942,5 @@ app.listen(PORT, () => {
console.log('');
console.log('📝 Make sure to configure your database in .env file');
});
+
+
diff --git a/src/lib/access.js b/src/lib/access.js
new file mode 100644
index 0000000..4adc922
--- /dev/null
+++ b/src/lib/access.js
@@ -0,0 +1,325 @@
+const fs = require('fs');
+const path = require('path');
+const bcrypt = require('bcryptjs');
+
+const USERS_FILE = path.join(__dirname, '..', '..', 'users.json');
+const SAFE_IDENTIFIER = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
+const ALLOWED_SQL_TYPES = new Set(['VARCHAR(255)', 'TEXT', 'INTEGER', 'BIGINT', 'DECIMAL', 'BOOLEAN', 'DATE', 'TIMESTAMP', 'UUID', 'JSON', 'JSONB']);
+const LEGACY_ROLE_MAP = {
+ frontend_admin: { role: 'admin', folders: ['frontend'] },
+ backend_admin: { role: 'admin', folders: ['backend'] },
+ frontend_moder: { role: 'moderator', folders: ['frontend'] },
+ backend_moder: { role: 'moderator', folders: ['backend'] },
+ viewer: { role: 'viewer', folders: null },
+ superadmin: { role: 'superadmin', folders: null },
+};
+
+function getTableFolder(tableName) {
+ if (!tableName) return 'default';
+ const parts = tableName.split('__');
+ return parts.length > 1 ? parts[0] : 'default';
+}
+
+function readUsersConfig() {
+ try {
+ const parsed = JSON.parse(fs.readFileSync(USERS_FILE, 'utf8'));
+ const users = Array.isArray(parsed.users) ? parsed.users : [];
+ return { users: users.map(normalizeUser).filter(Boolean) };
+ } catch (err) {
+ console.warn('users.json not found or invalid JSON. Falling back to env-based superadmin only.');
+ return { users: [] };
+ }
+}
+
+function normalizeUser(user) {
+ if (!user || typeof user.username !== 'string') {
+ return null;
+ }
+
+ const legacy = LEGACY_ROLE_MAP[user.role];
+ const role = legacy ? legacy.role : user.role;
+ const folders = Array.isArray(user.folders)
+ ? user.folders.filter(Boolean)
+ : legacy
+ ? legacy.folders
+ : null;
+
+ return {
+ username: user.username,
+ password: typeof user.password === 'string' ? user.password : undefined,
+ passwordHash: typeof user.passwordHash === 'string' ? user.passwordHash : undefined,
+ role: ['admin', 'moderator', 'viewer', 'superadmin'].includes(role) ? role : 'viewer',
+ folders,
+ access: normalizeAccess(user.access, role, folders),
+ disabled: Boolean(user.disabled),
+ };
+}
+
+function getUser(username) {
+ return readUsersConfig().users.find((user) => user.username === username) || null;
+}
+
+function normalizeScope(scope, fallbackFolders = null) {
+ if (scope === null) {
+ return { folders: null, tables: null };
+ }
+
+ return {
+ folders: Array.isArray(scope?.folders)
+ ? scope.folders.filter(Boolean)
+ : fallbackFolders,
+ tables: Array.isArray(scope?.tables)
+ ? scope.tables.filter(Boolean)
+ : [],
+ };
+}
+
+function normalizeAccess(access, role, folders = null) {
+ if (role === 'superadmin') {
+ return {
+ view: { folders: null, tables: null },
+ create: { folders: null, tables: null },
+ edit: { folders: null, tables: null },
+ delete: { folders: null, tables: null },
+ };
+ }
+
+ const baseFolders = folders && folders.length ? folders : null;
+ const defaultsByRole = {
+ admin: {
+ view: { folders: baseFolders, tables: [] },
+ create: { folders: baseFolders, tables: [] },
+ edit: { folders: baseFolders, tables: [] },
+ delete: { folders: baseFolders, tables: [] },
+ },
+ moderator: {
+ view: { folders: baseFolders, tables: [] },
+ create: { folders: baseFolders, tables: [] },
+ edit: { folders: baseFolders, tables: [] },
+ delete: { folders: [], tables: [] },
+ },
+ viewer: {
+ view: { folders: baseFolders, tables: [] },
+ create: { folders: [], tables: [] },
+ edit: { folders: [], tables: [] },
+ delete: { folders: [], tables: [] },
+ },
+ };
+
+ const defaults = defaultsByRole[role] || defaultsByRole.viewer;
+ const source = access && typeof access === 'object' ? access : {};
+
+ return {
+ view: normalizeScope(source.view, defaults.view.folders),
+ create: normalizeScope(source.create, defaults.create.folders),
+ edit: normalizeScope(source.edit, defaults.edit.folders),
+ delete: normalizeScope(source.delete, defaults.delete.folders),
+ };
+}
+
+function getRolePermissions(role, folders = null, access = null) {
+ const normalizedAccess = normalizeAccess(access, role, folders);
+ if (role === 'superadmin') {
+ return {
+ role,
+ folders: null,
+ access: normalizedAccess,
+ canCreate: true,
+ canEdit: true,
+ canDelete: true,
+ canViewLogs: true,
+ canRunSql: true,
+ canManageUsers: true,
+ canMoveTables: true,
+ };
+ }
+
+ if (role === 'admin') {
+ return {
+ role,
+ folders: folders && folders.length ? folders : null,
+ access: normalizedAccess,
+ canCreate: true,
+ canEdit: true,
+ canDelete: true,
+ canViewLogs: true,
+ canRunSql: true,
+ canManageUsers: true,
+ canMoveTables: true,
+ };
+ }
+
+ if (role === 'moderator') {
+ return {
+ role,
+ folders: folders && folders.length ? folders : null,
+ access: normalizedAccess,
+ canCreate: true,
+ canEdit: true,
+ canDelete: false,
+ canViewLogs: false,
+ canRunSql: false,
+ canManageUsers: false,
+ canMoveTables: false,
+ };
+ }
+
+ return {
+ role: 'viewer',
+ folders: folders && folders.length ? folders : null,
+ access: normalizedAccess,
+ canCreate: false,
+ canEdit: false,
+ canDelete: false,
+ canViewLogs: false,
+ canRunSql: false,
+ canManageUsers: false,
+ canMoveTables: false,
+ };
+}
+
+function isScopeAllowed(scope, tableName) {
+ if (!scope) return false;
+ if (scope.tables === null || scope.folders === null) return true;
+ const folder = getTableFolder(tableName);
+ return scope.tables.includes(tableName) || scope.folders.includes(folder);
+}
+
+function canAccessTable(permissionsOrRole, tableName, folders = null, access = null, action = 'view') {
+ const perms = typeof permissionsOrRole === 'string'
+ ? getRolePermissions(permissionsOrRole, folders, access)
+ : permissionsOrRole;
+ return isScopeAllowed(perms.access?.[action], tableName);
+}
+
+function canAccessFolder(permissions, folder, action = 'view') {
+ const scope = permissions.access?.[action];
+ if (!scope) return false;
+ if (scope.folders === null) return true;
+ return scope.folders.includes(folder);
+}
+
+function isValidIdentifier(value) {
+ return SAFE_IDENTIFIER.test(value);
+}
+
+function quoteIdentifier(identifier) {
+ if (!isValidIdentifier(identifier)) {
+ throw new Error(`Unsafe identifier: ${identifier}`);
+ }
+ return `"${identifier}"`;
+}
+
+function createSessionUser({ username, role, folders, access }) {
+ return {
+ username,
+ role,
+ permissions: getRolePermissions(role, folders, access),
+ };
+}
+
+async function verifyPassword(user, password) {
+ if (!user || user.disabled) {
+ return false;
+ }
+
+ if (user.passwordHash) {
+ return bcrypt.compare(password, user.passwordHash);
+ }
+
+ return user.password === password;
+}
+
+function sanitizeUser(user) {
+ return {
+ username: user.username,
+ role: user.role,
+ folders: user.folders,
+ access: user.access,
+ disabled: user.disabled,
+ };
+}
+
+function validateScopeInput(scope) {
+ if (scope === null) {
+ return { folders: null, tables: null };
+ }
+
+ return {
+ folders: Array.isArray(scope?.folders) ? scope.folders.filter(Boolean) : [],
+ tables: Array.isArray(scope?.tables) ? scope.tables.filter(Boolean) : [],
+ };
+}
+
+function validateUserPayload(payload, { allowPasswordOptional = false } = {}) {
+ if (!payload || typeof payload.username !== 'string' || !payload.username.trim()) {
+ throw new Error('Username is required');
+ }
+
+ if (!['admin', 'moderator', 'viewer'].includes(payload.role)) {
+ throw new Error('Invalid role');
+ }
+
+ if (!allowPasswordOptional && (!payload.password || typeof payload.password !== 'string')) {
+ throw new Error('Password is required');
+ }
+
+ const folders = Array.isArray(payload.folders) ? payload.folders.filter(Boolean) : null;
+ const access = payload.access && typeof payload.access === 'object'
+ ? {
+ view: validateScopeInput(payload.access.view),
+ create: validateScopeInput(payload.access.create),
+ edit: validateScopeInput(payload.access.edit),
+ delete: validateScopeInput(payload.access.delete),
+ }
+ : normalizeAccess(null, payload.role, folders);
+
+ return {
+ username: payload.username.trim(),
+ password: typeof payload.password === 'string' ? payload.password : undefined,
+ role: payload.role,
+ folders,
+ access,
+ disabled: Boolean(payload.disabled),
+ };
+}
+
+function writeUsersConfig(users) {
+ const serialized = JSON.stringify({
+ users: users.map((user) => {
+ const payload = {
+ username: user.username,
+ role: user.role,
+ folders: user.folders,
+ access: user.access,
+ disabled: Boolean(user.disabled),
+ };
+ if (user.passwordHash) {
+ payload.passwordHash = user.passwordHash;
+ } else if (user.password) {
+ payload.password = user.password;
+ }
+ return payload;
+ }),
+ }, null, 2);
+
+ fs.writeFileSync(USERS_FILE, serialized, 'utf8');
+}
+
+module.exports = {
+ ALLOWED_SQL_TYPES,
+ canAccessFolder,
+ canAccessTable,
+ createSessionUser,
+ getRolePermissions,
+ getTableFolder,
+ getUser,
+ isValidIdentifier,
+ normalizeAccess,
+ quoteIdentifier,
+ readUsersConfig,
+ sanitizeUser,
+ validateUserPayload,
+ verifyPassword,
+ writeUsersConfig,
+};
diff --git a/src/lib/audit.js b/src/lib/audit.js
new file mode 100644
index 0000000..362c53b
--- /dev/null
+++ b/src/lib/audit.js
@@ -0,0 +1,73 @@
+const fs = require('fs');
+const path = require('path');
+
+const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log');
+
+function formatAuditSummary(event, details = {}) {
+ const summaries = {
+ 'login.success': 'successful login',
+ 'login.failed': 'failed login attempt',
+ 'logout': 'logout',
+ 'user.created': `created user ${details.username || ''}`.trim(),
+ 'user.updated': `updated user ${details.username || ''}`.trim(),
+ 'user.deleted': `deleted user ${details.username || ''}`.trim(),
+ 'backup.created': `created backup ${details.filename || ''}`.trim(),
+ 'table.created': `created table ${details.table || ''}`.trim(),
+ 'table.deleted': `deleted table ${details.table || ''}`.trim(),
+ 'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(),
+ 'record.created': `created record in ${details.table || ''}`.trim(),
+ 'record.updated': `updated record in ${details.table || ''}`.trim(),
+ 'record.deleted': `deleted record from ${details.table || ''}`.trim(),
+ 'sql.executed': `executed ${details.command || 'SQL'} query`,
+ };
+
+ return summaries[event] || event;
+}
+
+function getAuditSource(req, fallback = 'WEB') {
+ const header = String(req?.headers?.['x-request-source'] || '').trim().toUpperCase();
+ if (header === 'AI' || header === 'WEB') {
+ return header;
+ }
+ return fallback;
+}
+
+function appendAudit(event, actor, details = {}) {
+ const source = details.source || 'WEB';
+ const entry = {
+ timestamp: new Date().toISOString(),
+ event,
+ actor: actor || 'system',
+ source,
+ summary: formatAuditSummary(event, details),
+ details,
+ };
+ fs.appendFileSync(AUDIT_LOG_FILE, `${JSON.stringify(entry)}\n`, 'utf8');
+}
+
+function readAuditLog(limit = 200) {
+ if (!fs.existsSync(AUDIT_LOG_FILE)) {
+ return [];
+ }
+
+ return fs.readFileSync(AUDIT_LOG_FILE, 'utf8')
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .map((line) => {
+ try {
+ return JSON.parse(line);
+ } catch (error) {
+ return null;
+ }
+ })
+ .filter(Boolean)
+ .slice(-limit)
+ .reverse();
+}
+
+module.exports = {
+ appendAudit,
+ formatAuditSummary,
+ getAuditSource,
+ readAuditLog,
+};
diff --git a/src/lib/docker.js b/src/lib/docker.js
new file mode 100644
index 0000000..efd5da0
--- /dev/null
+++ b/src/lib/docker.js
@@ -0,0 +1,96 @@
+const http = require('http');
+
+const DOCKER_SOCKET_PATH = process.env.DOCKER_SOCKET_PATH || '/var/run/docker.sock';
+const DOCKER_API_PREFIX = process.env.DOCKER_API_PREFIX || '/v1.41';
+
+function dockerRequest(requestPath, { stream = false } = {}) {
+ return new Promise((resolve, reject) => {
+ const req = http.request({
+ socketPath: DOCKER_SOCKET_PATH,
+ path: `${DOCKER_API_PREFIX}${requestPath}`,
+ method: 'GET',
+ }, (response) => {
+ if (stream) {
+ if (response.statusCode >= 400) {
+ const chunks = [];
+ response.on('data', (chunk) => chunks.push(chunk));
+ response.on('end', () => reject(new Error(Buffer.concat(chunks).toString('utf8') || 'Docker stream error')));
+ return;
+ }
+ resolve(response);
+ return;
+ }
+
+ const chunks = [];
+ response.on('data', (chunk) => chunks.push(chunk));
+ response.on('end', () => {
+ const body = Buffer.concat(chunks);
+ if (response.statusCode >= 400) {
+ reject(new Error(body.toString('utf8') || 'Docker API error'));
+ return;
+ }
+ resolve(body);
+ });
+ });
+
+ req.on('error', reject);
+ req.end();
+ });
+}
+
+function demuxDockerChunk(buffer) {
+ let offset = 0;
+ let output = '';
+
+ while (offset + 8 <= buffer.length) {
+ const payloadLength = buffer.readUInt32BE(offset + 4);
+ const payloadStart = offset + 8;
+ const payloadEnd = payloadStart + payloadLength;
+
+ if (payloadEnd > buffer.length) {
+ output += buffer.slice(offset).toString('utf8');
+ return output;
+ }
+
+ output += buffer.slice(payloadStart, payloadEnd).toString('utf8');
+ offset = payloadEnd;
+ }
+
+ if (offset < buffer.length) {
+ output += buffer.slice(offset).toString('utf8');
+ }
+
+ return output;
+}
+
+async function listContainers() {
+ const body = await dockerRequest('/containers/json?all=1');
+ const containers = JSON.parse(body.toString('utf8'));
+ return containers.map((container) => ({
+ id: container.Id,
+ name: container.Names?.[0]?.replace(/^\//, '') || container.Id.slice(0, 12),
+ state: container.State,
+ status: container.Status,
+ image: container.Image,
+ }));
+}
+
+async function resolveContainer(nameOrId) {
+ const containers = await listContainers();
+ const container = containers.find((item) =>
+ item.id === nameOrId || item.id.startsWith(nameOrId) || item.name === nameOrId
+ );
+
+ if (!container) {
+ throw new Error('Container not found');
+ }
+
+ return container;
+}
+
+module.exports = {
+ demuxDockerChunk,
+ dockerRequest,
+ listContainers,
+ resolveContainer,
+};
diff --git a/src/services/backups.js b/src/services/backups.js
new file mode 100644
index 0000000..c102528
--- /dev/null
+++ b/src/services/backups.js
@@ -0,0 +1,102 @@
+const fs = require('fs');
+const path = require('path');
+
+const BACKUPS_DIR = path.join(__dirname, '..', '..', 'backups');
+const USERS_FILE = path.join(__dirname, '..', '..', 'users.json');
+const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log');
+
+function ensureBackupsDir() {
+ fs.mkdirSync(BACKUPS_DIR, { recursive: true });
+}
+
+function makeBackupFilename() {
+ const now = new Date().toISOString().replace(/[:.]/g, '-');
+ return `backup-${now}.json`;
+}
+
+async function createBackup(pool, actor = 'system') {
+ ensureBackupsDir();
+
+ const tablesResult = await pool.query(`
+ SELECT table_name
+ FROM information_schema.tables
+ WHERE table_schema = 'public'
+ ORDER BY table_name
+ `);
+
+ const tables = [];
+ for (const row of tablesResult.rows) {
+ const tableName = row.table_name;
+ const structure = await pool.query(`
+ SELECT
+ c.column_name AS name,
+ c.data_type AS type,
+ c.is_nullable AS nullable,
+ c.column_default AS default_value
+ FROM information_schema.columns c
+ WHERE c.table_name = $1 AND c.table_schema = 'public'
+ ORDER BY c.ordinal_position
+ `, [tableName]);
+ const data = await pool.query(`SELECT * FROM "${tableName}"`);
+ tables.push({
+ name: tableName,
+ structure: structure.rows,
+ rows: data.rows,
+ });
+ }
+
+ const backup = {
+ meta: {
+ createdAt: new Date().toISOString(),
+ createdBy: actor,
+ version: 1,
+ },
+ users: fs.existsSync(USERS_FILE) ? JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')) : { users: [] },
+ audit: fs.existsSync(AUDIT_LOG_FILE)
+ ? fs.readFileSync(AUDIT_LOG_FILE, 'utf8').split(/\r?\n/).filter(Boolean)
+ : [],
+ tables,
+ };
+
+ const filename = makeBackupFilename();
+ const filePath = path.join(BACKUPS_DIR, filename);
+ fs.writeFileSync(filePath, JSON.stringify(backup, null, 2), 'utf8');
+
+ return {
+ filename,
+ filePath,
+ size: fs.statSync(filePath).size,
+ createdAt: backup.meta.createdAt,
+ };
+}
+
+function listBackups() {
+ ensureBackupsDir();
+ return fs.readdirSync(BACKUPS_DIR)
+ .filter((name) => name.endsWith('.json'))
+ .map((name) => {
+ const filePath = path.join(BACKUPS_DIR, name);
+ const stats = fs.statSync(filePath);
+ return {
+ filename: name,
+ size: stats.size,
+ createdAt: stats.birthtime.toISOString(),
+ };
+ })
+ .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
+}
+
+function getBackupPath(filename) {
+ const filePath = path.join(BACKUPS_DIR, filename);
+ if (!filePath.startsWith(BACKUPS_DIR) || !fs.existsSync(filePath)) {
+ throw new Error('Backup not found');
+ }
+ return filePath;
+}
+
+module.exports = {
+ BACKUPS_DIR,
+ createBackup,
+ getBackupPath,
+ listBackups,
+};
diff --git a/src/services/notifications.js b/src/services/notifications.js
new file mode 100644
index 0000000..f6ab0ab
--- /dev/null
+++ b/src/services/notifications.js
@@ -0,0 +1,43 @@
+async function sendTelegramMessage(text) {
+ const enabled = process.env.ENABLE_TELEGRAM_NOTIFICATIONS === 'true';
+ const token = process.env.TELEGRAM_BOT_TOKEN;
+ const chatId = process.env.TELEGRAM_CHAT_ID;
+
+ if (!enabled || !token || !chatId || !text) {
+ return { sent: false, reason: 'disabled' };
+ }
+
+ const response = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ chat_id: chatId,
+ text,
+ parse_mode: 'HTML',
+ disable_web_page_preview: true,
+ }),
+ });
+
+ if (!response.ok) {
+ const details = await response.text();
+ throw new Error(`Telegram API error: ${details}`);
+ }
+
+ return { sent: true };
+}
+
+async function notifyError(title, error, context = {}) {
+ const message = [
+ 'PG Admin error',
+ title,
+ error?.message || String(error || ''),
+ Object.keys(context).length ? JSON.stringify(context) : '',
+ ].filter(Boolean).join('\n');
+
+ return sendTelegramMessage(message);
+}
+
+module.exports = {
+ notifyError,
+ sendTelegramMessage,
+};