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:
Jonathan Miller
2026-06-22 22:18:48 -05:00
parent 8ea09ee0d1
commit 8a4ebe1bde
6 changed files with 907 additions and 1 deletions
@@ -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.
*/