- Centralized common formatting, DOM, and status utilities in `includes/plugins/sdk.js` - Created `sdk.utils.format`, `sdk.utils.dom`, and `sdk.utils.status` namespaces - Refactored `domain.consensus` and `peer.visualize` plugins to use the global SDK - Updated `plugin-handler` to serve SDK utilities globally via `/sdk-utils.js` - Enhanced SDK validation by delegating to the core validation infrastructure - Fixed bugs in metric rendering and missing frontend utility functions
270 lines
10 KiB
JavaScript
270 lines
10 KiB
JavaScript
const { createBackup, listBackups, restoreBackup, cleanupOldBackups } = require('../../../maintenance/backup');
|
|
const { logError } = require('../../../infrastructure/logger');
|
|
const { trackRequest, trackRequestWithTiming } = require('../../../maintenance/metrics');
|
|
const { createErrorResponse } = require('../../../infrastructure/error_handler');
|
|
const sdk = require('../../../plugins/sdk');
|
|
const fs = require('fs').promises;
|
|
const path = require('path');
|
|
|
|
async function handleBackupsRoutes(req, res) {
|
|
const urlPath = req.urlPath || new URL(req.url, `https://${req.headers.host}`).pathname;
|
|
const method = req.method;
|
|
|
|
// GET /api/backups - List all backups
|
|
if (method === 'GET' && urlPath === '/api/backups') {
|
|
try {
|
|
const startTime = Date.now();
|
|
const backups = await listBackups();
|
|
|
|
// Add formatted size to each backup (size is already calculated in listBackups for tar.gz)
|
|
const backupsWithSize = backups.map((backup) => {
|
|
return {
|
|
...backup,
|
|
size: backup.size || 0,
|
|
sizeFormatted: sdk.utils.format.formatBytes(backup.size || 0)
|
|
};
|
|
});
|
|
|
|
const responseTime = Date.now() - startTime;
|
|
trackRequestWithTiming('/api/backups', true, responseTime);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(backupsWithSize));
|
|
} catch (err) {
|
|
logError('Admin', `Failed to list backups: ${err.message}`);
|
|
trackRequest('/api/backups', false);
|
|
const errorResponse = createErrorResponse(err, 500);
|
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
|
res.end(errorResponse.body);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// POST /api/backups/create - Create manual backup
|
|
if (method === 'POST' && urlPath === '/api/backups/create') {
|
|
try {
|
|
const startTime = Date.now();
|
|
// Cleanup before creating backup
|
|
await cleanupOldBackups();
|
|
const backupPath = await createBackup();
|
|
const responseTime = Date.now() - startTime;
|
|
trackRequestWithTiming('/api/backups/create', true, responseTime);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, path: backupPath }));
|
|
} catch (err) {
|
|
logError('Admin', `Failed to create backup: ${err.message}`);
|
|
trackRequest('/api/backups/create', false);
|
|
const errorResponse = createErrorResponse(err, 500);
|
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
|
res.end(errorResponse.body);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// POST /api/backups/restore - Restore from backup
|
|
if (method === 'POST' && urlPath === '/api/backups/restore') {
|
|
try {
|
|
let body = '';
|
|
for await (const chunk of req) {
|
|
body += chunk.toString();
|
|
}
|
|
const data = JSON.parse(body);
|
|
const { backupName } = data;
|
|
|
|
if (!backupName) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'backupName is required' }));
|
|
trackRequest('/api/backups/restore', false);
|
|
return true;
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
await restoreBackup(backupName);
|
|
const responseTime = Date.now() - startTime;
|
|
trackRequestWithTiming('/api/backups/restore', true, responseTime);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, message: 'Backup restored successfully' }));
|
|
} catch (err) {
|
|
logError('Admin', `Failed to restore backup: ${err.message}`);
|
|
trackRequest('/api/backups/restore', false);
|
|
const errorResponse = createErrorResponse(err, 500);
|
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
|
res.end(errorResponse.body);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// DELETE /api/backups/:id - Delete backup
|
|
if (method === 'DELETE' && urlPath.startsWith('/api/backups/')) {
|
|
try {
|
|
const backupName = urlPath.split('/api/backups/')[1];
|
|
if (!backupName) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Backup name is required' }));
|
|
trackRequest(urlPath, false);
|
|
return true;
|
|
}
|
|
|
|
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
|
|
const backupPath = path.join(BACKUP_DIR, backupName);
|
|
|
|
// Verify it's a backup (directory or tar.gz)
|
|
if (!backupName.startsWith('backup-')) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Invalid backup name' }));
|
|
trackRequest(urlPath, false);
|
|
return true;
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
// Check if it's a file (tar.gz) or directory
|
|
const stats = await fs.stat(backupPath).catch(() => null);
|
|
if (stats) {
|
|
if (stats.isFile()) {
|
|
// Delete tar.gz file
|
|
await fs.unlink(backupPath);
|
|
} else {
|
|
// Delete directory
|
|
await fs.rm(backupPath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
const responseTime = Date.now() - startTime;
|
|
trackRequestWithTiming(urlPath, true, responseTime);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ success: true, message: 'Backup deleted successfully' }));
|
|
} catch (err) {
|
|
logError('Admin', `Failed to delete backup: ${err.message}`);
|
|
trackRequest(urlPath, false);
|
|
const errorResponse = createErrorResponse(err, 500);
|
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
|
res.end(errorResponse.body);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// GET /api/backups/:id/metadata - Get backup metadata
|
|
if (method === 'GET' && urlPath.startsWith('/api/backups/') && urlPath.endsWith('/metadata')) {
|
|
try {
|
|
const backupName = urlPath.split('/api/backups/')[1].replace('/metadata', '');
|
|
if (!backupName) {
|
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Backup name is required' }));
|
|
trackRequest(urlPath, false);
|
|
return true;
|
|
}
|
|
|
|
const BACKUP_DIR = process.env.BACKUP_DIR || './backups';
|
|
const backupPath = path.join(BACKUP_DIR, backupName);
|
|
|
|
const startTime = Date.now();
|
|
let metadata, restoreSource, extractDir = null;
|
|
|
|
try {
|
|
// Check if it's a tar.gz file
|
|
const stats = await fs.stat(backupPath);
|
|
const isTarGz = backupName.endsWith('.tar.gz') && stats.isFile();
|
|
|
|
if (isTarGz) {
|
|
// Extract tar.gz to read metadata
|
|
const { exec } = require('child_process');
|
|
const { promisify } = require('util');
|
|
const execAsync = promisify(exec);
|
|
|
|
extractDir = path.join(BACKUP_DIR, `extract-metadata-${Date.now()}`);
|
|
await fs.mkdir(extractDir, { recursive: true });
|
|
|
|
try {
|
|
await execAsync(`tar -xzf "${backupPath}" -C "${extractDir}"`);
|
|
const entries = await fs.readdir(extractDir, { withFileTypes: true });
|
|
if (entries.length === 1 && entries[0].isDirectory()) {
|
|
restoreSource = path.join(extractDir, entries[0].name);
|
|
} else {
|
|
restoreSource = extractDir;
|
|
}
|
|
const metadataPath = path.join(restoreSource, 'metadata.json');
|
|
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
|
} finally {
|
|
// Clean up extraction
|
|
if (extractDir) {
|
|
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
}
|
|
} else {
|
|
// Directory format
|
|
restoreSource = backupPath;
|
|
const metadataPath = path.join(backupPath, 'metadata.json');
|
|
metadata = JSON.parse(await fs.readFile(metadataPath, 'utf8'));
|
|
}
|
|
|
|
// Get file sizes (only for directory format, tar.gz files are already compressed)
|
|
const filesWithSize = await Promise.all(
|
|
(metadata.files || []).map(async (fileName) => {
|
|
if (isTarGz) {
|
|
// For tar.gz, we can't easily get individual file sizes without extracting
|
|
return {
|
|
name: fileName,
|
|
size: 0,
|
|
sizeFormatted: 'N/A (compressed)',
|
|
modified: null
|
|
};
|
|
} else {
|
|
const filePath = path.join(restoreSource, 'cache', fileName);
|
|
const altPath = path.join(restoreSource, fileName);
|
|
try {
|
|
let stats;
|
|
try {
|
|
stats = await fs.stat(filePath);
|
|
} catch {
|
|
stats = await fs.stat(altPath);
|
|
}
|
|
return {
|
|
name: fileName,
|
|
size: stats.size,
|
|
sizeFormatted: sdk.utils.format.formatBytes(stats.size),
|
|
modified: stats.mtime.toISOString()
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
name: fileName,
|
|
size: 0,
|
|
sizeFormatted: '0 Bytes',
|
|
modified: null
|
|
};
|
|
}
|
|
}
|
|
})
|
|
);
|
|
|
|
const responseTime = Date.now() - startTime;
|
|
trackRequestWithTiming(urlPath, true, responseTime);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
...metadata,
|
|
files: filesWithSize
|
|
}));
|
|
} catch (readErr) {
|
|
if (extractDir) {
|
|
await fs.rm(extractDir, { recursive: true, force: true }).catch(() => {});
|
|
}
|
|
throw readErr;
|
|
}
|
|
} catch (err) {
|
|
if (err.code === 'ENOENT') {
|
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ error: 'Backup not found' }));
|
|
} else {
|
|
logError('Admin', `Failed to get backup metadata: ${err.message}`);
|
|
const errorResponse = createErrorResponse(err, 500);
|
|
res.writeHead(errorResponse.statusCode, errorResponse.headers);
|
|
res.end(errorResponse.body);
|
|
}
|
|
trackRequest(urlPath, false);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
module.exports = { handleBackupsRoutes };
|
|
|