Compare commits
4 Commits
e51f34d7be
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d5f66ad34 | |||
| 3597b4106c | |||
| 430c7f456e | |||
| d935b7374d |
@@ -15,3 +15,6 @@ SESSION_SECRET=change_me_to_long_random_secret
|
|||||||
ENABLE_TELEGRAM_NOTIFICATIONS=false
|
ENABLE_TELEGRAM_NOTIFICATIONS=false
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# Optional container timezone for scheduler/logs
|
||||||
|
TZ=UTC
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -166,3 +166,4 @@ temp/
|
|||||||
.cache/
|
.cache/
|
||||||
audit.log
|
audit.log
|
||||||
backups/
|
backups/
|
||||||
|
settings.json
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ FROM node:20-alpine
|
|||||||
# Рабочая директория
|
# Рабочая директория
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# pg_dump for database backups
|
||||||
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
# Копируем package.json
|
# Копируем package.json
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./backups:/app/backups
|
||||||
|
- ./audit.log:/app/audit.log
|
||||||
|
- ./settings.json:/app/settings.json
|
||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|||||||
@@ -13,7 +13,8 @@
|
|||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"bcryptjs": "^2.4.3"
|
"bcryptjs": "^2.4.3",
|
||||||
|
"multer": "^1.4.5-lts.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|||||||
@@ -48,10 +48,12 @@
|
|||||||
body[data-theme="dark"] .bg-blue-50 { background-color: rgba(59, 130, 246, 0.16) !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-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"] .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-800 { color: #f1f5f9 !important; }
|
||||||
body[data-theme="dark"] .text-slate-600 { color: #94a3b8 !important; }
|
body[data-theme="dark"] .text-slate-700 { color: #e2e8f0 !important; }
|
||||||
body[data-theme="dark"] .text-slate-500, body[data-theme="dark"] .text-slate-400 { color: #64748b !important; }
|
body[data-theme="dark"] .text-slate-600 { color: #cbd5e1 !important; }
|
||||||
|
body[data-theme="dark"] .text-slate-500 { color: #94a3b8 !important; }
|
||||||
|
body[data-theme="dark"] .text-slate-400 { color: #94a3b8 !important; }
|
||||||
body[data-theme="dark"] .text-slate-300 { color: #cbd5e1 !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-200 { border-color: #1e293b !important; }
|
||||||
body[data-theme="dark"] .border-slate-300 { border-color: #334155 !important; }
|
body[data-theme="dark"] .border-slate-300 { border-color: #334155 !important; }
|
||||||
@@ -64,12 +66,81 @@
|
|||||||
color: #e2e8f0 !important;
|
color: #e2e8f0 !important;
|
||||||
border-color: #334155 !important;
|
border-color: #334155 !important;
|
||||||
}
|
}
|
||||||
|
body[data-theme="dark"] input::placeholder {
|
||||||
|
color: #64748b !important;
|
||||||
|
}
|
||||||
body[data-theme="dark"] .glass-panel {
|
body[data-theme="dark"] .glass-panel {
|
||||||
background: rgba(15, 23, 42, 0.92);
|
background: rgba(15, 23, 42, 0.92);
|
||||||
border-color: rgba(148, 163, 184, 0.12);
|
border-color: rgba(148, 163, 184, 0.12);
|
||||||
}
|
}
|
||||||
body[data-theme="dark"] ::-webkit-scrollbar-track { background: #0f172a; }
|
body[data-theme="dark"] ::-webkit-scrollbar-track { background: #0f172a; }
|
||||||
body[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #334155; }
|
body[data-theme="dark"] ::-webkit-scrollbar-thumb { background: #334155; }
|
||||||
|
|
||||||
|
/* Улучшенный заголовок в темной теме */
|
||||||
|
body[data-theme="dark"] #mainHeader {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
border-color: #1e293b !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] #mainHeader button {
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] table {
|
||||||
|
background-color: #111827 !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] table td,
|
||||||
|
body[data-theme="dark"] table th {
|
||||||
|
border-color: #1e293b !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .hover\:bg-slate-50:hover {
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .bg-slate-50 {
|
||||||
|
background-color: #0f172a !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] a {
|
||||||
|
color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] a:hover {
|
||||||
|
color: #93c5fd !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover эффекты на кнопки */
|
||||||
|
button {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] button:hover:not(:disabled) {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Специфичные hover для цветных кнопок */
|
||||||
|
.bg-blue-600:hover { background-color: #1d4ed8 !important; }
|
||||||
|
.bg-blue-50:hover { background-color: #eff6ff !important; }
|
||||||
|
.bg-amber-600:hover { background-color: #b45309 !important; }
|
||||||
|
.bg-green-500:hover { background-color: #16a34a !important; }
|
||||||
|
.bg-red-600:hover { background-color: #dc2626 !important; }
|
||||||
|
.bg-slate-100:hover:not(:disabled) { background-color: #e2e8f0 !important; }
|
||||||
|
|
||||||
|
/* Для темной темы */
|
||||||
|
body[data-theme="dark"] .bg-blue-600:hover { background-color: #1e40af !important; }
|
||||||
|
body[data-theme="dark"] .bg-amber-600:hover { background-color: #92400e !important; }
|
||||||
|
body[data-theme="dark"] .bg-green-500:hover { background-color: #15803d !important; }
|
||||||
|
body[data-theme="dark"] .bg-red-600:hover { background-color: #991b1b !important; }
|
||||||
|
body[data-theme="dark"] .bg-slate-100:hover { background-color: #334155 !important; }
|
||||||
|
|
||||||
|
/* Логотип кликабельный */
|
||||||
|
.logo-header {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.logo-header:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-collapsible {
|
.sidebar-collapsible {
|
||||||
transition: max-height 0.24s ease;
|
transition: max-height 0.24s ease;
|
||||||
}
|
}
|
||||||
@@ -79,6 +150,40 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Улучшенные стили для кнопок в темной теме */
|
||||||
|
body[data-theme="dark"] button {
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] #themeSelect {
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .bg-slate-100 {
|
||||||
|
background-color: #1e293b !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .bg-blue-50 {
|
||||||
|
background-color: rgba(59, 130, 246, 0.2) !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .text-blue-600 {
|
||||||
|
color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .bg-amber-50 {
|
||||||
|
background-color: rgba(217, 119, 6, 0.2) !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .text-amber-700 {
|
||||||
|
color: #fbbf24 !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .bg-red-50 {
|
||||||
|
background-color: rgba(239, 68, 68, 0.2) !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .text-red-600 {
|
||||||
|
color: #f87171 !important;
|
||||||
|
}
|
||||||
|
body[data-theme="dark"] .text-green-500 {
|
||||||
|
background-color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
#mobileBackdrop:not(.hidden) {
|
#mobileBackdrop:not(.hidden) {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -114,3 +219,59 @@
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Адаптив для очень узких экранов (меньше 640px) */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
#mainHeader {
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
#mainHeader > div:first-child {
|
||||||
|
order: 1;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
#mainHeader > div:first-child .logo-header span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#mainHeader > div:first-child > div:nth-child(3) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#mainHeader > div:last-child {
|
||||||
|
order: 2;
|
||||||
|
width: 100%;
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
#mainHeader > div:last-child > select,
|
||||||
|
#mainHeader > div:last-child > button {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
#managementButton,
|
||||||
|
#logsButton {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
#mainHeader > div:last-child > :nth-child(4) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптив для средних экранов (640px - 900px) */
|
||||||
|
@media (max-width: 900px) and (min-width: 641px) {
|
||||||
|
#mainHeader > div:first-child > div:nth-child(3) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#mainHeader > div:last-child > :nth-child(1) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
#managementButton,
|
||||||
|
#logsButton {
|
||||||
|
display: inline-flex !important;
|
||||||
|
}
|
||||||
|
#managementButton span,
|
||||||
|
#logsButton span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
this.currentContainer = '';
|
this.currentContainer = '';
|
||||||
this.logStream = null;
|
this.logStream = null;
|
||||||
this.logsBuffer = [];
|
this.logsBuffer = [];
|
||||||
|
this.currentSettings = null;
|
||||||
this.currentPage = 1;
|
this.currentPage = 1;
|
||||||
this.limit = 10;
|
this.limit = 10;
|
||||||
this.editingRecord = null;
|
this.editingRecord = null;
|
||||||
@@ -87,6 +88,29 @@
|
|||||||
document.getElementById('mobileBackdrop').classList.add('hidden');
|
document.getElementById('mobileBackdrop').classList.add('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
goHome() {
|
||||||
|
// Close any open panels
|
||||||
|
this.hideWorkspacePanels();
|
||||||
|
|
||||||
|
// Reset to table view
|
||||||
|
this.currentTable = null;
|
||||||
|
this.setToolbarMode('default');
|
||||||
|
document.getElementById('currentTableTitle').textContent = 'Select table';
|
||||||
|
document.getElementById('tableActions').classList.add('hidden');
|
||||||
|
document.getElementById('emptyState').classList.remove('hidden');
|
||||||
|
document.getElementById('dataGrid').classList.add('hidden');
|
||||||
|
document.getElementById('managementPanel').classList.add('hidden');
|
||||||
|
document.getElementById('sqlPanel').classList.add('hidden');
|
||||||
|
document.getElementById('logsPanel').classList.add('hidden');
|
||||||
|
|
||||||
|
// Close sidebar on mobile
|
||||||
|
this.closeSidebar();
|
||||||
|
|
||||||
|
// Scroll to top
|
||||||
|
document.body.scrollTop = 0;
|
||||||
|
document.documentElement.scrollTop = 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Auth Methods
|
// Auth Methods
|
||||||
async login(e) {
|
async login(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -150,15 +174,27 @@
|
|||||||
`${dbInfo.host}:${dbInfo.port}/${dbInfo.database}`;
|
`${dbInfo.host}:${dbInfo.port}/${dbInfo.database}`;
|
||||||
document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer';
|
document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer';
|
||||||
document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs);
|
document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs);
|
||||||
document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
document.getElementById('managementButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
||||||
document.getElementById('backupsButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
|
||||||
document.getElementById('auditButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
|
||||||
document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none';
|
document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none';
|
||||||
document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none';
|
document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none';
|
||||||
|
|
||||||
this.loadTables();
|
this.loadTables();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hideWorkspacePanels() {
|
||||||
|
['emptyState', 'dataGrid', 'sqlPanel', 'logsPanel', 'managementPanel'].forEach((id) => {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (node) {
|
||||||
|
node.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setToolbarMode(mode) {
|
||||||
|
document.getElementById('tableActions').classList.toggle('hidden', mode !== 'table');
|
||||||
|
document.getElementById('recordControls').classList.toggle('hidden', mode !== 'table');
|
||||||
|
}
|
||||||
|
|
||||||
getPermissions() {
|
getPermissions() {
|
||||||
if (this.currentUser?.role === 'superadmin') {
|
if (this.currentUser?.role === 'superadmin') {
|
||||||
return {
|
return {
|
||||||
@@ -291,10 +327,8 @@
|
|||||||
this.closeSidebar();
|
this.closeSidebar();
|
||||||
|
|
||||||
document.getElementById('currentTableTitle').textContent = tableName;
|
document.getElementById('currentTableTitle').textContent = tableName;
|
||||||
document.getElementById('tableActions').classList.remove('hidden');
|
this.setToolbarMode('table');
|
||||||
document.getElementById('emptyState').classList.add('hidden');
|
this.hideWorkspacePanels();
|
||||||
document.getElementById('sqlPanel').classList.add('hidden');
|
|
||||||
document.getElementById('logsPanel').classList.add('hidden');
|
|
||||||
document.getElementById('dataGrid').classList.remove('hidden');
|
document.getElementById('dataGrid').classList.remove('hidden');
|
||||||
|
|
||||||
// Update action buttons based on permissions
|
// Update action buttons based on permissions
|
||||||
@@ -839,10 +873,10 @@
|
|||||||
this.showToast('SQL доступ разрешен только администраторам', 'error');
|
this.showToast('SQL доступ разрешен только администраторам', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.getElementById('emptyState').classList.add('hidden');
|
this.setToolbarMode('workspace');
|
||||||
document.getElementById('dataGrid').classList.add('hidden');
|
this.hideWorkspacePanels();
|
||||||
document.getElementById('logsPanel').classList.add('hidden');
|
|
||||||
document.getElementById('sqlPanel').classList.remove('hidden');
|
document.getElementById('sqlPanel').classList.remove('hidden');
|
||||||
|
document.getElementById('currentTableTitle').textContent = 'SQL Query';
|
||||||
}
|
}
|
||||||
|
|
||||||
executeSQL() {
|
executeSQL() {
|
||||||
@@ -934,10 +968,10 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('emptyState').classList.add('hidden');
|
this.setToolbarMode('workspace');
|
||||||
document.getElementById('dataGrid').classList.add('hidden');
|
this.hideWorkspacePanels();
|
||||||
document.getElementById('sqlPanel').classList.add('hidden');
|
|
||||||
document.getElementById('logsPanel').classList.remove('hidden');
|
document.getElementById('logsPanel').classList.remove('hidden');
|
||||||
|
document.getElementById('currentTableTitle').textContent = 'Container logs';
|
||||||
await this.loadContainers();
|
await this.loadContainers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1068,13 +1102,41 @@
|
|||||||
return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`)).map(input => input.value);
|
return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`)).map(input => input.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
async showUsersModal() {
|
async showManagementPanel(section = 'settings') {
|
||||||
this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
if (!this.getPermissions().canManageUsers) {
|
||||||
this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
this.showToast('Management is available only for admins', 'error');
|
||||||
this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name));
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setToolbarMode('workspace');
|
||||||
|
this.hideWorkspacePanels();
|
||||||
|
document.getElementById('managementPanel').classList.remove('hidden');
|
||||||
|
document.getElementById('currentTableTitle').textContent = 'Management';
|
||||||
|
|
||||||
|
['settings', 'backups', 'users', 'audit'].forEach((item) => {
|
||||||
|
document.getElementById(`managementSection-${item}`).classList.toggle('hidden', item !== section);
|
||||||
|
document.getElementById(`managementTab-${item}`).className = item === section
|
||||||
|
? 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left bg-slate-100 text-slate-900'
|
||||||
|
: 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (section === 'settings') {
|
||||||
|
await this.loadSettings();
|
||||||
|
} else if (section === 'backups') {
|
||||||
|
await this.loadBackups();
|
||||||
|
} else if (section === 'users') {
|
||||||
|
this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||||
|
this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||||
|
this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name));
|
||||||
await this.loadUsers();
|
await this.loadUsers();
|
||||||
this.resetUserForm();
|
this.resetUserForm();
|
||||||
document.getElementById('usersModal').classList.remove('hidden');
|
} else if (section === 'audit') {
|
||||||
|
await this.loadAuditLog();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async showUsersModal() {
|
||||||
|
await this.showManagementPanel('users');
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadUsers() {
|
async loadUsers() {
|
||||||
@@ -1085,7 +1147,7 @@
|
|||||||
throw new Error(users.error || 'Failed to load users');
|
throw new Error(users.error || 'Failed to load users');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('usersTableBody').innerHTML = users.map(user => `
|
document.getElementById('managementUsersTableBody').innerHTML = users.map(user => `
|
||||||
<tr class="border-b border-slate-200">
|
<tr class="border-b border-slate-200">
|
||||||
<td class="p-3">${user.username}</td>
|
<td class="p-3">${user.username}</td>
|
||||||
<td class="p-3">${user.role}</td>
|
<td class="p-3">${user.role}</td>
|
||||||
@@ -1103,43 +1165,43 @@
|
|||||||
|
|
||||||
editUser(serializedUser) {
|
editUser(serializedUser) {
|
||||||
const user = JSON.parse(serializedUser);
|
const user = JSON.parse(serializedUser);
|
||||||
document.getElementById('userEditMode').value = user.username;
|
document.getElementById('managementUserEditMode').value = user.username;
|
||||||
document.getElementById('userUsername').value = user.username;
|
document.getElementById('managementUserUsername').value = user.username;
|
||||||
document.getElementById('userUsername').disabled = true;
|
document.getElementById('managementUserUsername').disabled = true;
|
||||||
document.getElementById('userPassword').value = '';
|
document.getElementById('managementUserPassword').value = '';
|
||||||
document.getElementById('userRole').value = user.role;
|
document.getElementById('managementUserRole').value = user.role;
|
||||||
this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []);
|
this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []);
|
||||||
this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []);
|
this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []);
|
||||||
this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []);
|
this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []);
|
||||||
document.getElementById('userDisabled').checked = Boolean(user.disabled);
|
document.getElementById('managementUserDisabled').checked = Boolean(user.disabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetUserForm() {
|
resetUserForm() {
|
||||||
document.getElementById('userEditMode').value = '';
|
document.getElementById('managementUserEditMode').value = '';
|
||||||
document.getElementById('userUsername').value = '';
|
document.getElementById('managementUserUsername').value = '';
|
||||||
document.getElementById('userUsername').disabled = false;
|
document.getElementById('managementUserUsername').disabled = false;
|
||||||
document.getElementById('userPassword').value = '';
|
document.getElementById('managementUserPassword').value = '';
|
||||||
document.getElementById('userRole').value = 'viewer';
|
document.getElementById('managementUserRole').value = 'viewer';
|
||||||
this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||||
this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||||
this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name));
|
this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name));
|
||||||
document.getElementById('userDisabled').checked = false;
|
document.getElementById('managementUserDisabled').checked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveUser() {
|
async saveUser() {
|
||||||
const username = document.getElementById('userUsername').value.trim();
|
const username = document.getElementById('managementUserUsername').value.trim();
|
||||||
const editMode = document.getElementById('userEditMode').value;
|
const editMode = document.getElementById('managementUserEditMode').value;
|
||||||
const password = document.getElementById('userPassword').value;
|
const password = document.getElementById('managementUserPassword').value;
|
||||||
const payload = {
|
const payload = {
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
role: document.getElementById('userRole').value,
|
role: document.getElementById('managementUserRole').value,
|
||||||
disabled: document.getElementById('userDisabled').checked,
|
disabled: document.getElementById('managementUserDisabled').checked,
|
||||||
access: {
|
access: {
|
||||||
view: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] },
|
view: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] },
|
||||||
create: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] },
|
create: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] },
|
||||||
edit: { folders: [], tables: this.getCheckedValues('userEditTablesList') },
|
edit: { folders: [], tables: this.getCheckedValues('managementUserEditTablesList') },
|
||||||
delete: { folders: this.getCheckedValues('userDeleteFoldersList'), tables: [] },
|
delete: { folders: this.getCheckedValues('managementUserDeleteFoldersList'), tables: [] },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1217,13 +1279,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async showAuditModal() {
|
async showAuditModal() {
|
||||||
await this.loadAuditLog();
|
await this.showManagementPanel('audit');
|
||||||
document.getElementById('auditModal').classList.remove('hidden');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async showBackupsModal() {
|
async showBackupsModal() {
|
||||||
await this.loadBackups();
|
await this.showManagementPanel('backups');
|
||||||
document.getElementById('backupsModal').classList.remove('hidden');
|
}
|
||||||
|
|
||||||
|
async showSettingsModal() {
|
||||||
|
await this.showManagementPanel('settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadBackups() {
|
async loadBackups() {
|
||||||
@@ -1234,15 +1298,18 @@
|
|||||||
throw new Error(backups.error || 'Failed to load backups');
|
throw new Error(backups.error || 'Failed to load backups');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('backupsList').innerHTML = backups.length
|
document.getElementById('managementBackupsList').innerHTML = backups.length
|
||||||
? backups.map(backup => `
|
? backups.map(backup => `
|
||||||
<div class="border border-slate-200 rounded-xl p-4 flex items-center justify-between gap-4">
|
<div class="border border-slate-200 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium text-slate-800">${backup.filename}</div>
|
<div class="font-medium text-slate-800">${backup.filename}</div>
|
||||||
<div class="text-sm text-slate-500">${backup.createdAt} · ${backup.size} bytes</div>
|
<div class="text-sm text-slate-500">${backup.createdAt} - ${backup.kind} - ${backup.size} bytes</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick='app.restoreBackup(${JSON.stringify(backup.filename)})' class="px-4 py-2 bg-amber-50 text-amber-700 rounded-lg">Restore</button>
|
||||||
<a href="/api/backups/${encodeURIComponent(backup.filename)}/download" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Download</a>
|
<a href="/api/backups/${encodeURIComponent(backup.filename)}/download" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Download</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`).join('')
|
`).join('')
|
||||||
: '<div class="text-sm text-slate-500">No backups yet.</div>';
|
: '<div class="text-sm text-slate-500">No backups yet.</div>';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1260,13 +1327,154 @@
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(result.error || 'Failed to create backup');
|
throw new Error(result.error || 'Failed to create backup');
|
||||||
}
|
}
|
||||||
this.showToast('Backup created', 'success');
|
this.showToast('Archive created', 'success');
|
||||||
this.loadBackups();
|
this.loadBackups();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showToast(err.message, 'error');
|
this.showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async restoreBackup(filename) {
|
||||||
|
if (!confirm(`Restore backup ${filename}? The current database will be replaced.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/backups/${encodeURIComponent(filename)}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Request-Source': 'WEB',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
restoreAppSnapshot: document.getElementById('managementRestoreAppSnapshot').checked,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || 'Failed to restore backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showToast('Backup restored', 'success');
|
||||||
|
await this.loadTables();
|
||||||
|
if (this.currentTable) {
|
||||||
|
await this.selectTable(this.currentTable);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadBackup() {
|
||||||
|
const fileInput = document.getElementById('backupFileInput');
|
||||||
|
const file = fileInput.files[0];
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
this.showToast('No file selected', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.name.endsWith('.tar.gz')) {
|
||||||
|
this.showToast('Only .tar.gz files are supported', 'error');
|
||||||
|
fileInput.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Show loading state
|
||||||
|
const uploadBtn = Array.from(document.querySelectorAll('button')).find(btn => btn.textContent.includes('Upload archive'));
|
||||||
|
const originalText = uploadBtn.textContent;
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
uploadBtn.textContent = 'Uploading...';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch('/api/backups/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || 'Failed to upload backup');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showToast('Backup uploaded successfully', 'success');
|
||||||
|
fileInput.value = '';
|
||||||
|
await this.loadBackups();
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast(err.message, 'error');
|
||||||
|
fileInput.value = '';
|
||||||
|
} finally {
|
||||||
|
const uploadBtn = Array.from(document.querySelectorAll('button')).find(btn => btn.textContent.includes('Upload') || btn.textContent.includes('Uploading'));
|
||||||
|
if (uploadBtn) {
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
uploadBtn.textContent = 'Upload archive';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
const settings = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(settings.error || 'Failed to load settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSettings = settings;
|
||||||
|
document.getElementById('managementSettingsBackupsEnabled').checked = Boolean(settings.backups?.enabled);
|
||||||
|
document.getElementById('managementSettingsBackupTime').value = `${String(settings.backups?.hour ?? 3).padStart(2, '0')}:${String(settings.backups?.minute ?? 0).padStart(2, '0')}`;
|
||||||
|
document.getElementById('managementSettingsKeepLast').value = settings.backups?.keepLast ?? 14;
|
||||||
|
document.getElementById('managementSettingsIncludeAppSnapshot').checked = settings.backups?.includeAppSnapshot !== false;
|
||||||
|
document.getElementById('managementSettingsTelegramEnabled').checked = Boolean(settings.telegram?.enabled);
|
||||||
|
document.getElementById('managementSettingsTelegramToken').value = settings.telegram?.botToken || '';
|
||||||
|
document.getElementById('managementSettingsTelegramChatId').value = settings.telegram?.chatId || '';
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSettings() {
|
||||||
|
const [hour, minute] = (document.getElementById('managementSettingsBackupTime').value || '03:00').split(':').map(Number);
|
||||||
|
const payload = {
|
||||||
|
backups: {
|
||||||
|
enabled: document.getElementById('managementSettingsBackupsEnabled').checked,
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
keepLast: Number(document.getElementById('managementSettingsKeepLast').value || 14),
|
||||||
|
includeAppSnapshot: document.getElementById('managementSettingsIncludeAppSnapshot').checked,
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
enabled: document.getElementById('managementSettingsTelegramEnabled').checked,
|
||||||
|
botToken: document.getElementById('managementSettingsTelegramToken').value.trim(),
|
||||||
|
chatId: document.getElementById('managementSettingsTelegramChatId').value.trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Request-Source': 'WEB',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || 'Failed to save settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentSettings = result.settings;
|
||||||
|
this.showToast('Settings saved', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
this.showToast(err.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadAuditLog() {
|
async loadAuditLog() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/audit');
|
const response = await fetch('/api/audit');
|
||||||
@@ -1275,7 +1483,7 @@
|
|||||||
throw new Error(entries.error || 'Failed to load audit log');
|
throw new Error(entries.error || 'Failed to load audit log');
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('auditList').innerHTML = entries.length
|
document.getElementById('managementAuditList').innerHTML = entries.length
|
||||||
? entries.map(entry => `
|
? entries.map(entry => `
|
||||||
<div class="border border-slate-200 rounded-xl p-4">
|
<div class="border border-slate-200 rounded-xl p-4">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<button onclick="app.toggleSidebar()" class="lg:hidden p-2 rounded-lg bg-slate-100 text-slate-700">
|
<button onclick="app.toggleSidebar()" class="lg:hidden p-2 rounded-lg bg-slate-100 text-slate-700">
|
||||||
<i data-lucide="menu" class="w-4 h-4"></i>
|
<i data-lucide="menu" class="w-4 h-4"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-2 text-blue-600">
|
<div class="flex items-center gap-2 text-blue-600 logo-header" onclick="app.goHome()" title="Go to home">
|
||||||
<i data-lucide="database" class="w-6 h-6"></i>
|
<i data-lucide="database" class="w-6 h-6"></i>
|
||||||
<span class="font-bold text-lg">PostgreSQL SensoLab</span>
|
<span class="font-bold text-lg">PostgreSQL SensoLab</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,17 +66,9 @@
|
|||||||
<option value="dark">Dark</option>
|
<option value="dark">Dark</option>
|
||||||
<option value="system">System</option>
|
<option value="system">System</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="usersButton" onclick="app.showUsersModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
<button id="managementButton" onclick="app.showManagementPanel('settings')" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||||
<i data-lucide="users" class="w-4 h-4"></i>
|
<i data-lucide="panel-left-open" class="w-4 h-4"></i>
|
||||||
Users
|
Menu
|
||||||
</button>
|
|
||||||
<button id="backupsButton" onclick="app.showBackupsModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
|
||||||
<i data-lucide="archive" class="w-4 h-4"></i>
|
|
||||||
Backups
|
|
||||||
</button>
|
|
||||||
<button id="auditButton" onclick="app.showAuditModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
|
||||||
<i data-lucide="history" class="w-4 h-4"></i>
|
|
||||||
Audit
|
|
||||||
</button>
|
</button>
|
||||||
<button id="logsButton" onclick="app.showLogsPanel()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
<button id="logsButton" onclick="app.showLogsPanel()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||||
<i data-lucide="scroll-text" class="w-4 h-4"></i>
|
<i data-lucide="scroll-text" class="w-4 h-4"></i>
|
||||||
@@ -256,6 +248,182 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
</div>
|
</div>
|
||||||
<div class="bg-slate-900 text-slate-200 rounded-xl border border-slate-800 p-4 font-mono text-sm log-terminal overflow-auto" id="logOutput">Select a container to load recent logs.</div>
|
<div class="bg-slate-900 text-slate-200 rounded-xl border border-slate-800 p-4 font-mono text-sm log-terminal overflow-auto" id="logOutput">Select a container to load recent logs.</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="managementPanel" class="hidden h-full">
|
||||||
|
<div class="grid lg:grid-cols-[240px,1fr] gap-6 h-full">
|
||||||
|
<aside class="bg-white rounded-2xl border border-slate-200 p-4 space-y-2">
|
||||||
|
<button id="managementTab-settings" onclick="app.showManagementPanel('settings')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left bg-slate-100 text-slate-700">
|
||||||
|
<i data-lucide="sliders-horizontal" class="w-4 h-4"></i>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
<button id="managementTab-backups" onclick="app.showManagementPanel('backups')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100">
|
||||||
|
<i data-lucide="archive" class="w-4 h-4"></i>
|
||||||
|
<span>Backups</span>
|
||||||
|
</button>
|
||||||
|
<button id="managementTab-users" onclick="app.showManagementPanel('users')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100">
|
||||||
|
<i data-lucide="users" class="w-4 h-4"></i>
|
||||||
|
<span>Users</span>
|
||||||
|
</button>
|
||||||
|
<button id="managementTab-audit" onclick="app.showManagementPanel('audit')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100">
|
||||||
|
<i data-lucide="history" class="w-4 h-4"></i>
|
||||||
|
<span>Audit</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
<section class="min-h-0">
|
||||||
|
<div id="managementSection-settings" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800">System settings</h3>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Telegram, automatic backups and retention are configured here.</p>
|
||||||
|
</div>
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-slate-800">Automatic backups</h4>
|
||||||
|
<p class="text-sm text-slate-500">The panel creates a database archive and, optionally, a site snapshot every day.</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="managementSettingsBackupsEnabled" class="w-4 h-4">
|
||||||
|
Enable automatic backups
|
||||||
|
</label>
|
||||||
|
<div class="grid sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Time</label>
|
||||||
|
<input type="time" id="managementSettingsBackupTime" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Keep last archives</label>
|
||||||
|
<input type="number" id="managementSettingsKeepLast" min="1" max="90" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="managementSettingsIncludeAppSnapshot" class="w-4 h-4">
|
||||||
|
Include site settings and audit snapshot
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-slate-800">Telegram notifications</h4>
|
||||||
|
<p class="text-sm text-slate-500">Used for backup errors, restore events and scheduled notifications.</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="managementSettingsTelegramEnabled" class="w-4 h-4">
|
||||||
|
Enable Telegram notifications
|
||||||
|
</label>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Bot token</label>
|
||||||
|
<input type="text" id="managementSettingsTelegramToken" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="123456:ABC...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Chat ID</label>
|
||||||
|
<input type="text" id="managementSettingsTelegramChatId" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="-1001234567890">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button onclick="app.saveSettings()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">Save settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="managementSection-backups" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto p-6 space-y-4">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800">Backups</h3>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Archives contain the SQL dump and, if enabled, the application snapshot.</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="app.createBackup()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Create archive</button>
|
||||||
|
<button onclick="document.getElementById('backupFileInput').click()" class="px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-lg">Upload archive</button>
|
||||||
|
<input id="backupFileInput" type="file" accept=".tar.gz" style="display: none;" onchange="app.uploadBackup()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="managementRestoreAppSnapshot" class="w-4 h-4" checked>
|
||||||
|
Restore users, settings and audit snapshot together with the database
|
||||||
|
</label>
|
||||||
|
<div id="managementBackupsList" class="space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
<div id="managementSection-users" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto">
|
||||||
|
<div class="grid xl:grid-cols-[1.15fr,0.85fr] min-h-full">
|
||||||
|
<div class="border-r border-slate-200 overflow-auto">
|
||||||
|
<div class="p-6 border-b border-slate-200">
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800">Users</h3>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Create accounts and tune access to folders, tables and destructive actions.</p>
|
||||||
|
</div>
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-slate-50 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th class="text-left p-3">Username</th>
|
||||||
|
<th class="text-left p-3">Role</th>
|
||||||
|
<th class="text-left p-3">Status</th>
|
||||||
|
<th class="text-left p-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="managementUsersTableBody"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 overflow-auto space-y-3">
|
||||||
|
<input type="hidden" id="managementUserEditMode" value="">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Username</label>
|
||||||
|
<input type="text" id="managementUserUsername" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
||||||
|
<input type="text" id="managementUserPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="Leave empty to keep current">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Role</label>
|
||||||
|
<select id="managementUserRole" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||||
|
<option value="viewer">viewer</option>
|
||||||
|
<option value="moderator">moderator</option>
|
||||||
|
<option value="admin">admin</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Readable folders</label>
|
||||||
|
<details class="border border-slate-300 rounded-lg p-3">
|
||||||
|
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
|
||||||
|
<div id="managementUserViewFoldersList" class="mt-3 grid gap-2"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label>
|
||||||
|
<details class="border border-slate-300 rounded-lg p-3">
|
||||||
|
<summary class="cursor-pointer text-sm text-slate-700">Choose tables</summary>
|
||||||
|
<div id="managementUserEditTablesList" class="mt-3 grid gap-2 max-h-48 overflow-auto"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label>
|
||||||
|
<details class="border border-slate-300 rounded-lg p-3">
|
||||||
|
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
|
||||||
|
<div id="managementUserDeleteFoldersList" class="mt-3 grid gap-2"></div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="managementUserDisabled" class="w-4 h-4">
|
||||||
|
Disable login
|
||||||
|
</label>
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button onclick="app.resetUserForm()" class="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Reset</button>
|
||||||
|
<button onclick="app.saveUser()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Save user</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="managementSection-audit" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto p-6">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold text-slate-800">Audit log</h3>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">Human-readable actions with time, source and actor.</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="app.loadAuditLog()" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="managementAuditList" class="space-y-3"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -593,6 +761,69 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="settingsModal" class="hidden fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col fade-in">
|
||||||
|
<div class="p-6 border-b border-slate-200 flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold text-slate-800">System settings</h3>
|
||||||
|
<button onclick="app.closeModal('settingsModal')" class="p-2 hover:bg-slate-100 rounded-lg transition-colors">
|
||||||
|
<i data-lucide="x" class="w-5 h-5 text-slate-500"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 overflow-auto space-y-6">
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-slate-800">Automatic backups</h4>
|
||||||
|
<p class="text-sm text-slate-500">Daily SQL dump of PostgreSQL plus optional application snapshot.</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="settingsBackupsEnabled" class="w-4 h-4">
|
||||||
|
Enable automatic backups
|
||||||
|
</label>
|
||||||
|
<div class="grid sm:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Time</label>
|
||||||
|
<input type="time" id="settingsBackupTime" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Keep last days</label>
|
||||||
|
<input type="number" id="settingsKeepLast" min="1" max="90" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end">
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="settingsIncludeAppSnapshot" class="w-4 h-4">
|
||||||
|
Include site settings and audit snapshot
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-semibold text-slate-800">Telegram notifications</h4>
|
||||||
|
<p class="text-sm text-slate-500">Used for backup errors and important service events.</p>
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
|
<input type="checkbox" id="settingsTelegramEnabled" class="w-4 h-4">
|
||||||
|
Enable Telegram notifications
|
||||||
|
</label>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Bot token</label>
|
||||||
|
<input type="text" id="settingsTelegramToken" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="123456:ABC...">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700 mb-1">Chat ID</label>
|
||||||
|
<input type="text" id="settingsTelegramChatId" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="-1001234567890">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="p-6 border-t border-slate-200 bg-slate-50 flex justify-end gap-3">
|
||||||
|
<button onclick="app.closeModal('settingsModal')" class="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors">Cancel</button>
|
||||||
|
<button onclick="app.saveSettings()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">Save settings</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Toast Notifications -->
|
<!-- Toast Notifications -->
|
||||||
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
|
<div id="toastContainer" class="fixed bottom-6 right-6 z-50 flex flex-col gap-2"></div>
|
||||||
|
|
||||||
|
|||||||
158
server.js
158
server.js
@@ -4,6 +4,10 @@ const { Pool } = require('pg');
|
|||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const multer = require('multer');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const {
|
const {
|
||||||
ALLOWED_SQL_TYPES,
|
ALLOWED_SQL_TYPES,
|
||||||
canAccessFolder,
|
canAccessFolder,
|
||||||
@@ -34,13 +38,36 @@ const {
|
|||||||
createBackup,
|
createBackup,
|
||||||
getBackupPath,
|
getBackupPath,
|
||||||
listBackups,
|
listBackups,
|
||||||
|
pruneBackups,
|
||||||
|
restoreBackup,
|
||||||
|
uploadBackup,
|
||||||
} = require('./src/services/backups');
|
} = require('./src/services/backups');
|
||||||
const {
|
const {
|
||||||
notifyError,
|
notifyError,
|
||||||
|
notifyInfo,
|
||||||
} = require('./src/services/notifications');
|
} = require('./src/services/notifications');
|
||||||
|
const {
|
||||||
|
getSettings,
|
||||||
|
saveSettings,
|
||||||
|
validateSettings,
|
||||||
|
} = require('./src/services/runtime-config');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Multer configuration for file uploads
|
||||||
|
const upload = multer({
|
||||||
|
storage: multer.memoryStorage(),
|
||||||
|
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB limit for backups
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
// Only accept tar.gz files
|
||||||
|
if (file.originalname.endsWith('.tar.gz') || file.mimetype === 'application/gzip' || file.mimetype === 'application/x-gzip') {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only .tar.gz files are supported'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '1mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
@@ -103,6 +130,45 @@ function applyRecordMetadata(structure, payload, currentUser, { isCreate = false
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastAutoBackupSlot = '';
|
||||||
|
|
||||||
|
async function runScheduledBackupIfNeeded() {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.backups.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
if (now.getHours() !== settings.backups.hour || now.getMinutes() !== settings.backups.minute) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slot = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${String(settings.backups.hour).padStart(2, '0')}-${String(settings.backups.minute).padStart(2, '0')}`;
|
||||||
|
if (lastAutoBackupSlot === slot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backup = await createBackup(pool, 'system', {
|
||||||
|
includeAppSnapshot: settings.backups.includeAppSnapshot,
|
||||||
|
keepLast: settings.backups.keepLast,
|
||||||
|
});
|
||||||
|
|
||||||
|
lastAutoBackupSlot = slot;
|
||||||
|
appendAudit('backup.auto_created', 'system', {
|
||||||
|
filename: backup.filename,
|
||||||
|
source: 'WEB',
|
||||||
|
});
|
||||||
|
notifyInfo('Scheduled backup completed', [backup.filename]).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startBackupScheduler() {
|
||||||
|
setInterval(() => {
|
||||||
|
runScheduledBackupIfNeeded().catch((error) => {
|
||||||
|
notifyError('Scheduled backup failed', error).catch(() => {});
|
||||||
|
});
|
||||||
|
}, 60000);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper: get primary key column for a table (returns null if none)
|
// Helper: get primary key column for a table (returns null if none)
|
||||||
async function getPrimaryKeyColumn(tableName) {
|
async function getPrimaryKeyColumn(tableName) {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
@@ -353,7 +419,11 @@ app.post('/api/backups', requireAuth, requirePermission(
|
|||||||
'Backup access denied'
|
'Backup access denied'
|
||||||
), async (req, res) => {
|
), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const backup = await createBackup(pool, req.currentUser.username);
|
const settings = getSettings();
|
||||||
|
const backup = await createBackup(pool, req.currentUser.username, {
|
||||||
|
includeAppSnapshot: settings.backups.includeAppSnapshot,
|
||||||
|
keepLast: settings.backups.keepLast,
|
||||||
|
});
|
||||||
appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) });
|
appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) });
|
||||||
res.json({ success: true, backup });
|
res.json({ success: true, backup });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -362,6 +432,90 @@ app.post('/api/backups', requireAuth, requirePermission(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/backups/:filename/restore', requireAuth, requirePermission(
|
||||||
|
(permissions) => permissions.canManageUsers,
|
||||||
|
'Backup access denied'
|
||||||
|
), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await restoreBackup(req.params.filename, {
|
||||||
|
restoreAppSnapshot: req.body?.restoreAppSnapshot !== false,
|
||||||
|
});
|
||||||
|
appendAudit('backup.restored', req.currentUser.username, {
|
||||||
|
filename: req.params.filename,
|
||||||
|
source: getAuditSource(req),
|
||||||
|
});
|
||||||
|
notifyInfo('Backup restored', [req.params.filename, `actor: ${req.currentUser.username}`]).catch(() => {});
|
||||||
|
res.json({ success: true, result });
|
||||||
|
} catch (err) {
|
||||||
|
notifyError('Backup restore failed', err, {
|
||||||
|
actor: req.currentUser.username,
|
||||||
|
filename: req.params.filename,
|
||||||
|
}).catch(() => {});
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/backups/upload', requireAuth, requirePermission(
|
||||||
|
(permissions) => permissions.canManageUsers,
|
||||||
|
'Backup access denied'
|
||||||
|
), upload.single('file'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ success: false, error: 'No file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the file to a temporary location first
|
||||||
|
const tempPath = path.join(require('os').tmpdir(), `backup-upload-${Date.now()}.tar.gz`);
|
||||||
|
await fs.promises.writeFile(tempPath, req.file.buffer);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upload the backup
|
||||||
|
const settings = getSettings();
|
||||||
|
const backup = await uploadBackup(tempPath, {
|
||||||
|
keepLast: settings.backups.keepLast,
|
||||||
|
});
|
||||||
|
|
||||||
|
appendAudit('backup.uploaded', req.currentUser.username, {
|
||||||
|
filename: backup.filename,
|
||||||
|
originalFilename: req.file.originalname,
|
||||||
|
source: getAuditSource(req)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, backup });
|
||||||
|
} finally {
|
||||||
|
// Clean up temp file
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(tempPath);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError('Backup upload failed', err, { actor: req.currentUser.username }).catch(() => {});
|
||||||
|
res.status(500).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/settings', requireAuth, requirePermission(
|
||||||
|
(permissions) => permissions.canManageUsers,
|
||||||
|
'Settings access denied'
|
||||||
|
), (req, res) => {
|
||||||
|
res.json(getSettings());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/settings', requireAuth, requirePermission(
|
||||||
|
(permissions) => permissions.canManageUsers,
|
||||||
|
'Settings access denied'
|
||||||
|
), (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = validateSettings(req.body);
|
||||||
|
const saved = saveSettings(settings);
|
||||||
|
pruneBackups(saved.backups.keepLast);
|
||||||
|
appendAudit('settings.updated', req.currentUser.username, { source: getAuditSource(req) });
|
||||||
|
res.json({ success: true, settings: saved });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(400).json({ success: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/backups/:filename/download', requireAuth, requirePermission(
|
app.get('/api/backups/:filename/download', requireAuth, requirePermission(
|
||||||
(permissions) => permissions.canManageUsers,
|
(permissions) => permissions.canManageUsers,
|
||||||
'Backup access denied'
|
'Backup access denied'
|
||||||
@@ -943,4 +1097,4 @@ app.listen(PORT, () => {
|
|||||||
console.log('📝 Make sure to configure your database in .env file');
|
console.log('📝 Make sure to configure your database in .env file');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
startBackupScheduler();
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ function formatAuditSummary(event, details = {}) {
|
|||||||
'user.updated': `updated user ${details.username || ''}`.trim(),
|
'user.updated': `updated user ${details.username || ''}`.trim(),
|
||||||
'user.deleted': `deleted user ${details.username || ''}`.trim(),
|
'user.deleted': `deleted user ${details.username || ''}`.trim(),
|
||||||
'backup.created': `created backup ${details.filename || ''}`.trim(),
|
'backup.created': `created backup ${details.filename || ''}`.trim(),
|
||||||
|
'backup.auto_created': `created scheduled backup ${details.filename || ''}`.trim(),
|
||||||
|
'backup.restored': `restored backup ${details.filename || ''}`.trim(),
|
||||||
|
'settings.updated': 'updated system settings',
|
||||||
'table.created': `created table ${details.table || ''}`.trim(),
|
'table.created': `created table ${details.table || ''}`.trim(),
|
||||||
'table.deleted': `deleted table ${details.table || ''}`.trim(),
|
'table.deleted': `deleted table ${details.table || ''}`.trim(),
|
||||||
'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(),
|
'table.moved': `moved table ${details.from || ''} to ${details.to || ''}`.trim(),
|
||||||
|
|||||||
@@ -1,22 +1,60 @@
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
|
||||||
const BACKUPS_DIR = path.join(__dirname, '..', '..', 'backups');
|
const BACKUPS_DIR = path.join(__dirname, '..', '..', 'backups');
|
||||||
const USERS_FILE = path.join(__dirname, '..', '..', 'users.json');
|
const USERS_FILE = path.join(__dirname, '..', '..', 'users.json');
|
||||||
const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log');
|
const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log');
|
||||||
|
const SETTINGS_FILE = path.join(__dirname, '..', '..', 'settings.json');
|
||||||
|
const BACKUP_PREFIX = 'backup-';
|
||||||
|
const BACKUP_EXTENSION = '.tar.gz';
|
||||||
|
|
||||||
function ensureBackupsDir() {
|
function ensureBackupsDir() {
|
||||||
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeBackupFilename() {
|
function makeBackupStamp() {
|
||||||
const now = new Date().toISOString().replace(/[:.]/g, '-');
|
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
return `backup-${now}.json`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createBackup(pool, actor = 'system') {
|
function makeTempDir() {
|
||||||
ensureBackupsDir();
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'pg-admin-backup-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupDir(dirPath) {
|
||||||
|
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args, options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, options);
|
||||||
|
const stdout = [];
|
||||||
|
const stderr = [];
|
||||||
|
|
||||||
|
if (child.stdout) {
|
||||||
|
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||||
|
}
|
||||||
|
if (child.stderr) {
|
||||||
|
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||||
|
}
|
||||||
|
|
||||||
|
child.on('error', reject);
|
||||||
|
child.on('close', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
reject(new Error(Buffer.concat(stderr).toString('utf8') || `${command} exited with code ${code}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||||
|
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectAppSnapshot(pool, actor = 'system') {
|
||||||
const tablesResult = await pool.query(`
|
const tablesResult = await pool.query(`
|
||||||
SELECT table_name
|
SELECT table_name
|
||||||
FROM information_schema.tables
|
FROM information_schema.tables
|
||||||
@@ -45,44 +83,138 @@ async function createBackup(pool, actor = 'system') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const backup = {
|
return {
|
||||||
meta: {
|
meta: {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
createdBy: actor,
|
createdBy: actor,
|
||||||
version: 1,
|
version: 2,
|
||||||
},
|
},
|
||||||
users: fs.existsSync(USERS_FILE) ? JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')) : { users: [] },
|
users: fs.existsSync(USERS_FILE) ? JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')) : { users: [] },
|
||||||
|
settings: fs.existsSync(SETTINGS_FILE) ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) : null,
|
||||||
audit: fs.existsSync(AUDIT_LOG_FILE)
|
audit: fs.existsSync(AUDIT_LOG_FILE)
|
||||||
? fs.readFileSync(AUDIT_LOG_FILE, 'utf8').split(/\r?\n/).filter(Boolean)
|
? fs.readFileSync(AUDIT_LOG_FILE, 'utf8').split(/\r?\n/).filter(Boolean)
|
||||||
: [],
|
: [],
|
||||||
tables,
|
tables,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const filename = makeBackupFilename();
|
function createArchive(tempDir, archivePath, fileNames) {
|
||||||
const filePath = path.join(BACKUPS_DIR, filename);
|
return runCommand('tar', ['-czf', archivePath, '-C', tempDir, ...fileNames], {
|
||||||
fs.writeFileSync(filePath, JSON.stringify(backup, null, 2), 'utf8');
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractArchive(archivePath, tempDir) {
|
||||||
|
return runCommand('tar', ['-xzf', archivePath, '-C', tempDir], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPgDump() {
|
||||||
|
const args = [
|
||||||
|
'-h', process.env.DB_HOST || 'db',
|
||||||
|
'-p', String(process.env.DB_PORT || '5432'),
|
||||||
|
'-U', process.env.DB_USER || 'postgres',
|
||||||
|
'-d', process.env.DB_NAME || 'postgres',
|
||||||
|
'--clean',
|
||||||
|
'--if-exists',
|
||||||
|
'--no-owner',
|
||||||
|
'--no-privileges',
|
||||||
|
];
|
||||||
|
|
||||||
|
return runCommand('pg_dump', args, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PGPASSWORD: process.env.DB_PASSWORD || '',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
}).then((result) => result.stdout);
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPsqlFile(filePath) {
|
||||||
|
const args = [
|
||||||
|
'-h', process.env.DB_HOST || 'db',
|
||||||
|
'-p', String(process.env.DB_PORT || '5432'),
|
||||||
|
'-U', process.env.DB_USER || 'postgres',
|
||||||
|
'-d', process.env.DB_NAME || 'postgres',
|
||||||
|
'-v', 'ON_ERROR_STOP=1',
|
||||||
|
'-f', filePath,
|
||||||
|
];
|
||||||
|
|
||||||
|
return runCommand('psql', args, {
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PGPASSWORD: process.env.DB_PASSWORD || '',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBackupEntry(filePath, filename) {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
return {
|
return {
|
||||||
filename,
|
filename,
|
||||||
filePath,
|
size: stats.size,
|
||||||
size: fs.statSync(filePath).size,
|
createdAt: stats.birthtime.toISOString(),
|
||||||
createdAt: backup.meta.createdAt,
|
kind: 'archive',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pruneBackups(keepLast = 14) {
|
||||||
|
ensureBackupsDir();
|
||||||
|
const files = fs.readdirSync(BACKUPS_DIR)
|
||||||
|
.filter((name) => name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_EXTENSION))
|
||||||
|
.map((name) => ({
|
||||||
|
name,
|
||||||
|
filePath: path.join(BACKUPS_DIR, name),
|
||||||
|
mtimeMs: fs.statSync(path.join(BACKUPS_DIR, name)).mtimeMs,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||||
|
|
||||||
|
files.slice(Math.max(1, keepLast)).forEach((file) => {
|
||||||
|
fs.unlinkSync(file.filePath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBackup(pool, actor = 'system', options = {}) {
|
||||||
|
ensureBackupsDir();
|
||||||
|
|
||||||
|
const tempDir = makeTempDir();
|
||||||
|
const stamp = makeBackupStamp();
|
||||||
|
const archiveFilename = `${BACKUP_PREFIX}${stamp}${BACKUP_EXTENSION}`;
|
||||||
|
const archivePath = path.join(BACKUPS_DIR, archiveFilename);
|
||||||
|
const fileNames = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sqlDump = await runPgDump();
|
||||||
|
const sqlFilename = 'database.sql';
|
||||||
|
fs.writeFileSync(path.join(tempDir, sqlFilename), sqlDump, 'utf8');
|
||||||
|
fileNames.push(sqlFilename);
|
||||||
|
|
||||||
|
if (options.includeAppSnapshot !== false) {
|
||||||
|
const snapshot = await collectAppSnapshot(pool, actor);
|
||||||
|
const jsonFilename = 'application.json';
|
||||||
|
fs.writeFileSync(path.join(tempDir, jsonFilename), JSON.stringify(snapshot, null, 2), 'utf8');
|
||||||
|
fileNames.push(jsonFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createArchive(tempDir, archivePath, fileNames);
|
||||||
|
|
||||||
|
if (options.keepLast) {
|
||||||
|
pruneBackups(options.keepLast);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatBackupEntry(archivePath, archiveFilename);
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function listBackups() {
|
function listBackups() {
|
||||||
ensureBackupsDir();
|
ensureBackupsDir();
|
||||||
return fs.readdirSync(BACKUPS_DIR)
|
return fs.readdirSync(BACKUPS_DIR)
|
||||||
.filter((name) => name.endsWith('.json'))
|
.filter((name) => name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_EXTENSION))
|
||||||
.map((name) => {
|
.map((name) => formatBackupEntry(path.join(BACKUPS_DIR, name), 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));
|
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +226,96 @@ function getBackupPath(filename) {
|
|||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function restoreBackup(filename, options = {}) {
|
||||||
|
ensureBackupsDir();
|
||||||
|
const archivePath = getBackupPath(filename);
|
||||||
|
const tempDir = makeTempDir();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await extractArchive(archivePath, tempDir);
|
||||||
|
|
||||||
|
const sqlPath = path.join(tempDir, 'database.sql');
|
||||||
|
const jsonPath = path.join(tempDir, 'application.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(sqlPath)) {
|
||||||
|
throw new Error('Archive does not contain database.sql');
|
||||||
|
}
|
||||||
|
|
||||||
|
await runPsqlFile(sqlPath);
|
||||||
|
|
||||||
|
let restoredAppSnapshot = false;
|
||||||
|
if (options.restoreAppSnapshot !== false && fs.existsSync(jsonPath)) {
|
||||||
|
const snapshot = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||||
|
|
||||||
|
if (snapshot.users) {
|
||||||
|
fs.writeFileSync(USERS_FILE, JSON.stringify(snapshot.users, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
if (snapshot.settings) {
|
||||||
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(snapshot.settings, null, 2), 'utf8');
|
||||||
|
}
|
||||||
|
if (Array.isArray(snapshot.audit)) {
|
||||||
|
fs.writeFileSync(AUDIT_LOG_FILE, `${snapshot.audit.join('\n')}${snapshot.audit.length ? '\n' : ''}`, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
restoredAppSnapshot = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
restoredDatabase: true,
|
||||||
|
restoredAppSnapshot,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadBackup(sourceFilePath, options = {}) {
|
||||||
|
ensureBackupsDir();
|
||||||
|
|
||||||
|
// Generate a unique backup filename
|
||||||
|
const stamp = makeBackupStamp();
|
||||||
|
const archiveFilename = `${BACKUP_PREFIX}${stamp}-uploaded${BACKUP_EXTENSION}`;
|
||||||
|
const archivePath = path.join(BACKUPS_DIR, archiveFilename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Copy the uploaded file to backups directory
|
||||||
|
fs.copyFileSync(sourceFilePath, archivePath);
|
||||||
|
|
||||||
|
// Validate the archive by trying to extract it to a temp directory
|
||||||
|
const tempDir = makeTempDir();
|
||||||
|
try {
|
||||||
|
await extractArchive(archivePath, tempDir);
|
||||||
|
|
||||||
|
// Check if it contains required files
|
||||||
|
const sqlPath = path.join(tempDir, 'database.sql');
|
||||||
|
if (!fs.existsSync(sqlPath)) {
|
||||||
|
throw new Error('Archive does not contain database.sql');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
cleanupDir(tempDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.keepLast) {
|
||||||
|
pruneBackups(options.keepLast);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatBackupEntry(archivePath, archiveFilename);
|
||||||
|
} catch (err) {
|
||||||
|
// Clean up the file if validation failed
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(archivePath);
|
||||||
|
} catch {}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
BACKUPS_DIR,
|
BACKUPS_DIR,
|
||||||
createBackup,
|
createBackup,
|
||||||
getBackupPath,
|
getBackupPath,
|
||||||
listBackups,
|
listBackups,
|
||||||
|
pruneBackups,
|
||||||
|
restoreBackup,
|
||||||
|
uploadBackup,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
|
const { getSettings } = require('./runtime-config');
|
||||||
|
|
||||||
|
function getTelegramConfig() {
|
||||||
|
const settings = getSettings();
|
||||||
|
return {
|
||||||
|
enabled: settings.telegram.enabled,
|
||||||
|
token: settings.telegram.botToken || process.env.TELEGRAM_BOT_TOKEN,
|
||||||
|
chatId: settings.telegram.chatId || process.env.TELEGRAM_CHAT_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function sendTelegramMessage(text) {
|
async function sendTelegramMessage(text) {
|
||||||
const enabled = process.env.ENABLE_TELEGRAM_NOTIFICATIONS === 'true';
|
const { enabled, token, chatId } = getTelegramConfig();
|
||||||
const token = process.env.TELEGRAM_BOT_TOKEN;
|
|
||||||
const chatId = process.env.TELEGRAM_CHAT_ID;
|
|
||||||
|
|
||||||
if (!enabled || !token || !chatId || !text) {
|
if (!enabled || !token || !chatId || !text) {
|
||||||
return { sent: false, reason: 'disabled' };
|
return { sent: false, reason: 'disabled' };
|
||||||
@@ -37,7 +46,18 @@ async function notifyError(title, error, context = {}) {
|
|||||||
return sendTelegramMessage(message);
|
return sendTelegramMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function notifyInfo(title, details = []) {
|
||||||
|
const message = [
|
||||||
|
'<b>PG Admin</b>',
|
||||||
|
title,
|
||||||
|
...details.filter(Boolean),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return sendTelegramMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
notifyError,
|
notifyError,
|
||||||
|
notifyInfo,
|
||||||
sendTelegramMessage,
|
sendTelegramMessage,
|
||||||
};
|
};
|
||||||
|
|||||||
88
src/services/runtime-config.js
Normal file
88
src/services/runtime-config.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SETTINGS_FILE = path.join(__dirname, '..', '..', 'settings.json');
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
backups: {
|
||||||
|
enabled: true,
|
||||||
|
hour: 3,
|
||||||
|
minute: 0,
|
||||||
|
keepLast: 14,
|
||||||
|
includeAppSnapshot: true,
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
enabled: process.env.ENABLE_TELEGRAM_NOTIFICATIONS === 'true',
|
||||||
|
botToken: process.env.TELEGRAM_BOT_TOKEN || '',
|
||||||
|
chatId: process.env.TELEGRAM_CHAT_ID || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeSettings(base, overrides) {
|
||||||
|
return {
|
||||||
|
backups: {
|
||||||
|
...base.backups,
|
||||||
|
...(overrides?.backups || {}),
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
...base.telegram,
|
||||||
|
...(overrides?.telegram || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettings() {
|
||||||
|
if (!fs.existsSync(SETTINGS_FILE)) {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||||
|
return mergeSettings(DEFAULT_SETTINGS, parsed);
|
||||||
|
} catch (error) {
|
||||||
|
return DEFAULT_SETTINGS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings(nextSettings) {
|
||||||
|
const merged = mergeSettings(DEFAULT_SETTINGS, nextSettings);
|
||||||
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(merged, null, 2), 'utf8');
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSettings(payload = {}) {
|
||||||
|
const hour = Number(payload?.backups?.hour ?? DEFAULT_SETTINGS.backups.hour);
|
||||||
|
const minute = Number(payload?.backups?.minute ?? DEFAULT_SETTINGS.backups.minute);
|
||||||
|
const keepLast = Number(payload?.backups?.keepLast ?? DEFAULT_SETTINGS.backups.keepLast);
|
||||||
|
|
||||||
|
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
||||||
|
throw new Error('Backup hour must be between 0 and 23');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
||||||
|
throw new Error('Backup minute must be between 0 and 59');
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(keepLast) || keepLast < 1 || keepLast > 90) {
|
||||||
|
throw new Error('Keep last must be between 1 and 90');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
backups: {
|
||||||
|
enabled: Boolean(payload?.backups?.enabled),
|
||||||
|
hour,
|
||||||
|
minute,
|
||||||
|
keepLast,
|
||||||
|
includeAppSnapshot: payload?.backups?.includeAppSnapshot !== false,
|
||||||
|
},
|
||||||
|
telegram: {
|
||||||
|
enabled: Boolean(payload?.telegram?.enabled),
|
||||||
|
botToken: String(payload?.telegram?.botToken || '').trim(),
|
||||||
|
chatId: String(payload?.telegram?.chatId || '').trim(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSettings,
|
||||||
|
saveSettings,
|
||||||
|
validateSettings,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user