Тест без кодекаса
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -1342,6 +1342,57 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async loadSettings() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/settings');
|
const response = await fetch('/api/settings');
|
||||||
|
|||||||
@@ -330,7 +330,11 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
|||||||
<h3 class="text-2xl font-bold text-slate-800">Backups</h3>
|
<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>
|
<p class="text-sm text-slate-500 mt-1">Archives contain the SQL dump and, if enabled, the application snapshot.</p>
|
||||||
</div>
|
</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="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>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||||
<input type="checkbox" id="managementRestoreAppSnapshot" class="w-4 h-4" checked>
|
<input type="checkbox" id="managementRestoreAppSnapshot" class="w-4 h-4" checked>
|
||||||
|
|||||||
57
server.js
57
server.js
@@ -5,6 +5,9 @@ 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 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,
|
||||||
@@ -37,6 +40,7 @@ const {
|
|||||||
listBackups,
|
listBackups,
|
||||||
pruneBackups,
|
pruneBackups,
|
||||||
restoreBackup,
|
restoreBackup,
|
||||||
|
uploadBackup,
|
||||||
} = require('./src/services/backups');
|
} = require('./src/services/backups');
|
||||||
const {
|
const {
|
||||||
notifyError,
|
notifyError,
|
||||||
@@ -50,6 +54,20 @@ const {
|
|||||||
|
|
||||||
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' }));
|
||||||
@@ -437,6 +455,45 @@ app.post('/api/backups/:filename/restore', requireAuth, requirePermission(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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(
|
app.get('/api/settings', requireAuth, requirePermission(
|
||||||
(permissions) => permissions.canManageUsers,
|
(permissions) => permissions.canManageUsers,
|
||||||
'Settings access denied'
|
'Settings access denied'
|
||||||
|
|||||||
@@ -270,6 +270,46 @@ async function restoreBackup(filename, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -277,4 +317,5 @@ module.exports = {
|
|||||||
listBackups,
|
listBackups,
|
||||||
pruneBackups,
|
pruneBackups,
|
||||||
restoreBackup,
|
restoreBackup,
|
||||||
|
uploadBackup,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user