feat: selective article restore from snapshot (#58)
Browse articles inside a snapshot and restore individual items: - SnapshotRestoreEngine::restoreSelectedArticles() merges by ID - AjaxController::browseSnapshot() returns article list as JSON - SnapshotsController::restoreSelected() handles selective restore - Browse modal with checkboxes + Restore Selected button Closes #58
This commit is contained in:
@@ -377,6 +377,391 @@ class AjaxController extends BaseController
|
||||
$this->sendJson($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse archive contents without extracting.
|
||||
* POST: task=ajax.browseArchive&id=123
|
||||
*/
|
||||
public function browseArchive(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing record ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName(['absolute_path', 'status', 'filesexist']))
|
||||
->from($db->quoteName('#__mokosuitebackup_records'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: browseArchive() DB error for record ' . $id . ': ' . $e->getMessage());
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to load backup record'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$record) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Record not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete' || !$record->filesexist) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Archive not available']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$archivePath = $record->absolute_path;
|
||||
|
||||
if (!is_file($archivePath)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Archive file not found on disk']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$maxEntries = 500;
|
||||
|
||||
try {
|
||||
$files = [];
|
||||
$totalFiles = 0;
|
||||
$totalSize = 0;
|
||||
$truncated = false;
|
||||
|
||||
$lower = strtolower($archivePath);
|
||||
|
||||
if (substr($lower, -4) === '.zip') {
|
||||
$files = $this->browseZipArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
||||
} elseif (substr($lower, -7) === '.tar.gz' || substr($lower, -4) === '.tgz') {
|
||||
$files = $this->browseTarArchive($archivePath, $maxEntries, $totalFiles, $totalSize, $truncated);
|
||||
} else {
|
||||
$this->sendJson(['error' => true, 'message' => 'Unsupported archive format']);
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: browseArchive() error for record ' . $id . ': ' . $e->getMessage());
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to read archive: ' . $e->getMessage()]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'files' => $files,
|
||||
'total_files' => $totalFiles,
|
||||
'total_size' => $totalSize,
|
||||
'truncated' => $truncated,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse a ZIP archive and return file entries.
|
||||
*
|
||||
* @param string $path Absolute path to the ZIP file
|
||||
* @param int $maxEntries Maximum entries to return
|
||||
* @param int &$totalFiles Total number of files (by reference)
|
||||
* @param int &$totalSize Total uncompressed size (by reference)
|
||||
* @param bool &$truncated Whether results were truncated (by reference)
|
||||
*
|
||||
* @return array List of file entry arrays
|
||||
*/
|
||||
private function browseZipArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($path, \ZipArchive::RDONLY) !== true) {
|
||||
throw new \RuntimeException('Cannot open ZIP archive');
|
||||
}
|
||||
|
||||
$files = [];
|
||||
$totalFiles = $zip->numFiles;
|
||||
|
||||
for ($i = 0; $i < $totalFiles; $i++) {
|
||||
$stat = $zip->statIndex($i);
|
||||
|
||||
if ($stat === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$totalSize += $stat['size'];
|
||||
|
||||
if (\count($files) < $maxEntries) {
|
||||
$files[] = [
|
||||
'name' => $stat['name'],
|
||||
'size' => $stat['size'],
|
||||
'compressed_size' => $stat['comp_size'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$truncated = $totalFiles > $maxEntries;
|
||||
$zip->close();
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse a tar.gz archive and return file entries.
|
||||
*
|
||||
* @param string $path Absolute path to the tar.gz file
|
||||
* @param int $maxEntries Maximum entries to return
|
||||
* @param int &$totalFiles Total number of files (by reference)
|
||||
* @param int &$totalSize Total uncompressed size (by reference)
|
||||
* @param bool &$truncated Whether results were truncated (by reference)
|
||||
*
|
||||
* @return array List of file entry arrays
|
||||
*/
|
||||
private function browseTarArchive(string $path, int $maxEntries, int &$totalFiles, int &$totalSize, bool &$truncated): array
|
||||
{
|
||||
$phar = new \PharData($path);
|
||||
$files = [];
|
||||
|
||||
foreach (new \RecursiveIteratorIterator($phar) as $entry) {
|
||||
$totalFiles++;
|
||||
$entrySize = $entry->getSize();
|
||||
$totalSize += $entrySize;
|
||||
|
||||
if (\count($files) < $maxEntries) {
|
||||
// Strip the phar:// prefix and archive path to get relative name
|
||||
$fullPath = str_replace('\\', '/', $entry->getPathname());
|
||||
$relativeName = preg_replace('#^phar://.+?\.tar\.gz/#i', '', $fullPath)
|
||||
?: preg_replace('#^phar://.+?\.tgz/#i', '', $fullPath)
|
||||
?: $fullPath;
|
||||
|
||||
$files[] = [
|
||||
'name' => $relativeName,
|
||||
'size' => $entrySize,
|
||||
'compressed_size' => $entrySize,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$truncated = $totalFiles > $maxEntries;
|
||||
|
||||
return $files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse articles inside a snapshot — returns JSON list for the browse modal.
|
||||
* POST: task=ajax.browseSnapshot&id=123
|
||||
*/
|
||||
public function browseSnapshot(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('mokosuitebackup.snapshot.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id = $this->input->getInt('id', 0);
|
||||
|
||||
if (!$id) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Missing snapshot ID']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__mokosuitebackup_snapshots'))
|
||||
->where($db->quoteName('id') . ' = ' . (int) $id);
|
||||
$db->setQuery($query);
|
||||
$record = $db->loadObject();
|
||||
|
||||
if (!$record) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($record->status !== 'complete') {
|
||||
$this->sendJson(['error' => true, 'message' => 'Cannot browse a failed snapshot']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_file($record->data_file) || !is_readable($record->data_file)) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot data file not found']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$json = file_get_contents($record->data_file);
|
||||
|
||||
if ($json === false) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Cannot read snapshot file']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE || empty($data['tables']['#__content'])) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Snapshot does not contain articles']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$articles = [];
|
||||
|
||||
foreach ($data['tables']['#__content'] as $row) {
|
||||
$articles[] = [
|
||||
'id' => (int) ($row['id'] ?? 0),
|
||||
'title' => $row['title'] ?? '',
|
||||
'catid' => (int) ($row['catid'] ?? 0),
|
||||
'state' => (int) ($row['state'] ?? 0),
|
||||
'created' => $row['created'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'articles' => $articles,
|
||||
'total' => count($articles),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two backup records side-by-side.
|
||||
* POST: task=ajax.compareBackups&id1=123&id2=456
|
||||
*/
|
||||
public function compareBackups(): void
|
||||
{
|
||||
if (!Session::checkToken('get') && !Session::checkToken('post')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Invalid token'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!$this->app->getIdentity()->authorise('core.manage', 'com_mokosuitebackup')) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Access denied'], 403);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$id1 = $this->input->getInt('id1', 0);
|
||||
$id2 = $this->input->getInt('id2', 0);
|
||||
|
||||
if (!$id1 || !$id2) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Two backup record IDs are required']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($id1 === $id2) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Please select two different backup records']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$fields = [
|
||||
'r.id', 'r.description', 'r.status', 'r.backup_type',
|
||||
'r.total_size', 'r.db_size', 'r.files_count', 'r.tables_count',
|
||||
'r.backupstart', 'r.backupend',
|
||||
];
|
||||
|
||||
try {
|
||||
$db = \Joomla\CMS\Factory::getDbo();
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName($fields))
|
||||
->select($db->quoteName('p.title', 'profile_title'))
|
||||
->from($db->quoteName('#__mokosuitebackup_records', 'r'))
|
||||
->join('LEFT', $db->quoteName('#__mokosuitebackup_profiles', 'p')
|
||||
. ' ON ' . $db->quoteName('p.id') . ' = ' . $db->quoteName('r.profile_id'))
|
||||
->where($db->quoteName('r.id') . ' IN (' . (int) $id1 . ', ' . (int) $id2 . ')');
|
||||
|
||||
$db->setQuery($query);
|
||||
$rows = $db->loadObjectList('id');
|
||||
} catch (\Exception $e) {
|
||||
error_log('MokoSuiteBackup: compareBackups() DB error: ' . $e->getMessage());
|
||||
$this->sendJson(['error' => true, 'message' => 'Failed to load backup records'], 500);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($rows[$id1])) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id1 . ' not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isset($rows[$id2])) {
|
||||
$this->sendJson(['error' => true, 'message' => 'Backup record #' . $id2 . ' not found'], 404);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$b1 = $rows[$id1];
|
||||
$b2 = $rows[$id2];
|
||||
|
||||
// Calculate durations in seconds
|
||||
$duration1 = 0;
|
||||
$duration2 = 0;
|
||||
|
||||
if ($b1->backupstart !== '0000-00-00 00:00:00' && $b1->backupend !== '0000-00-00 00:00:00') {
|
||||
$duration1 = strtotime($b1->backupend) - strtotime($b1->backupstart);
|
||||
}
|
||||
|
||||
if ($b2->backupstart !== '0000-00-00 00:00:00' && $b2->backupend !== '0000-00-00 00:00:00') {
|
||||
$duration2 = strtotime($b2->backupend) - strtotime($b2->backupstart);
|
||||
}
|
||||
|
||||
$formatRecord = function ($row) {
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'description' => $row->description,
|
||||
'status' => $row->status,
|
||||
'backup_type' => $row->backup_type,
|
||||
'total_size' => (int) $row->total_size,
|
||||
'db_size' => (int) $row->db_size,
|
||||
'files_count' => (int) $row->files_count,
|
||||
'tables_count' => (int) $row->tables_count,
|
||||
'backupstart' => $row->backupstart,
|
||||
'backupend' => $row->backupend,
|
||||
'profile_title' => $row->profile_title ?? '',
|
||||
];
|
||||
};
|
||||
|
||||
$this->sendJson([
|
||||
'error' => false,
|
||||
'backup1' => $formatRecord($b1),
|
||||
'backup2' => $formatRecord($b2),
|
||||
'delta' => [
|
||||
'size_diff' => (int) $b2->total_size - (int) $b1->total_size,
|
||||
'files_diff' => (int) $b2->files_count - (int) $b1->files_count,
|
||||
'tables_diff' => (int) $b2->tables_count - (int) $b1->tables_count,
|
||||
'duration_diff_seconds' => $duration2 - $duration1,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a JSON response and close the application.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user