Files
MokoSuiteBackup/source/packages/com_mokosuitebackup/src/Engine/SnapshotEngine.php
T
Jonathan Miller d5421738b7
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 4s
Universal: Secret Scanning / Gitleaks Secret Scan (pull_request) Successful in 9s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Generic: Project CI / Lint & Validate (pull_request) Successful in 44s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 49s
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 51s
Generic: Project CI / Tests (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Has been cancelled
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Has been cancelled
Joomla: Extension CI / PHPStan Analysis (pull_request) Has been cancelled
Joomla: Extension CI / Build RC Pre-Release (pull_request) Has been cancelled
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
fix: address PR review findings — error handling and safety
Fixes from code review and silent failure audit:

- SnapshotRestoreEngine: catch only duplicate key errors (MySQL 1062)
  in merge mode, re-throw all other exceptions instead of swallowing
- SnapshotRestoreEngine: add json_last_error() check for better error
  messages on corrupt snapshot files
- SnapshotRestoreEngine: log warnings when set_time_limit/ini_set fail
- SnapshotEngine: use strlen($json) instead of filesize() to avoid
  race conditions; catch \Exception instead of \Throwable
- SnapshotsController: remove @unlink suppression, add try-catch
  around delete loop with partial failure reporting
- script.php: add user-facing warning when webcron secret generation
  fails (was silently swallowed, inconsistent with other catch blocks)
2026-06-21 17:21:55 -05:00

239 lines
6.8 KiB
PHP

<?php
/**
* @package MokoSuiteBackup
* @subpackage com_mokosuitebackup
* @author Moko Consulting <hello@mokoconsulting.tech>
* @copyright Copyright (C) 2026 Moko Consulting. All rights reserved.
* @license GNU General Public License version 3 or later; see LICENSE
*
* Snapshot engine — creates lightweight JSON snapshots of specific content
* types (articles, categories, modules) without touching the filesystem.
*/
namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory;
class SnapshotEngine
{
private array $log = [];
/** Content type => tables mapping */
private const TYPE_TABLES = [
'articles' => [
'#__content',
'#__content_frontpage',
],
'categories' => [
'#__categories',
],
'modules' => [
'#__modules',
'#__modules_menu',
],
];
/** Related tables always captured when articles are included */
private const ARTICLE_RELATED = [
'#__workflow_associations',
'#__contentitem_tag_map',
];
/**
* Create a snapshot of selected content types.
*
* @param array $contentTypes Types to snapshot: articles, categories, modules
* @param string $description User-provided description
*
* @return array{success: bool, message: string, id?: int}
*/
public function create(array $contentTypes, string $description = ''): array
{
$db = Factory::getDbo();
$prefix = $db->getPrefix();
if (empty($contentTypes)) {
return ['success' => false, 'message' => 'No content types selected'];
}
$validTypes = array_intersect($contentTypes, ['articles', 'categories', 'modules']);
if (empty($validTypes)) {
return ['success' => false, 'message' => 'No valid content types selected'];
}
$this->log('Starting snapshot: ' . implode(', ', $validTypes));
try {
$data = [
'version' => 1,
'created' => date('Y-m-d H:i:s'),
'content_types' => array_values($validTypes),
'tables' => [],
];
$counts = [
'articles' => 0,
'categories' => 0,
'modules' => 0,
];
// Dump each selected content type
foreach ($validTypes as $type) {
foreach (self::TYPE_TABLES[$type] as $abstractTable) {
$realTable = str_replace('#__', $prefix, $abstractTable);
$rows = $this->dumpTable($db, $realTable, $abstractTable, $type);
$data['tables'][$abstractTable] = $rows;
$this->log(' ' . $abstractTable . ': ' . count($rows) . ' rows');
}
}
// Capture related tables for articles
if (in_array('articles', $validTypes)) {
$rows = $this->dumpFilteredTable(
$db,
str_replace('#__', $prefix, '#__workflow_associations'),
'#__workflow_associations',
'extension',
'com_content.article'
);
$data['tables']['#__workflow_associations'] = $rows;
$this->log(' #__workflow_associations: ' . count($rows) . ' rows');
$rows = $this->dumpTagMap($db, $prefix);
$data['tables']['#__contentitem_tag_map'] = $rows;
$this->log(' #__contentitem_tag_map: ' . count($rows) . ' rows');
}
// Count items
if (in_array('articles', $validTypes)) {
$counts['articles'] = count($data['tables']['#__content'] ?? []);
}
if (in_array('categories', $validTypes)) {
$counts['categories'] = count($data['tables']['#__categories'] ?? []);
}
if (in_array('modules', $validTypes)) {
$counts['modules'] = count($data['tables']['#__modules'] ?? []);
}
// Write JSON file to backup directory
$backupDir = BackupDirectory::getDefaultAbsolute();
BackupDirectory::ensureReady($backupDir);
$filename = 'snapshot_' . date('Ymd_His') . '_' . implode('-', $validTypes) . '.json';
$filePath = $backupDir . '/' . $filename;
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
if ($json === false) {
throw new \RuntimeException('Failed to encode snapshot data as JSON');
}
if (file_put_contents($filePath, $json) === false) {
throw new \RuntimeException('Failed to write snapshot file: ' . $filePath);
}
$fileSize = strlen($json);
$this->log('Snapshot saved: ' . $filename . ' (' . number_format($fileSize) . ' bytes)');
// Create database record
$now = Factory::getDate()->toSql();
$userId = Factory::getApplication()->getIdentity()->id ?? 0;
$record = (object) [
'description' => $description ?: 'Snapshot: ' . implode(', ', $validTypes),
'content_types' => json_encode(array_values($validTypes)),
'status' => 'complete',
'articles_count' => $counts['articles'],
'categories_count' => $counts['categories'],
'modules_count' => $counts['modules'],
'data_file' => $filePath,
'data_size' => $fileSize,
'log' => implode("\n", $this->log),
'created' => $now,
'created_by' => $userId,
];
$db->insertObject('#__mokosuitebackup_snapshots', $record, 'id');
$this->log('Snapshot record created: ID ' . $record->id);
return [
'success' => true,
'message' => sprintf(
'Snapshot created: %d articles, %d categories, %d modules',
$counts['articles'],
$counts['categories'],
$counts['modules']
),
'id' => $record->id,
];
} catch (\Exception $e) {
$this->log('FATAL: ' . $e->getMessage());
return [
'success' => false,
'message' => 'Snapshot failed: ' . $e->getMessage(),
'log' => implode("\n", $this->log),
];
}
}
/**
* Dump all rows from a table.
*/
private function dumpTable(object $db, string $realTable, string $abstractTable, string $type): array
{
$query = $db->getQuery(true)->select('*')->from($db->quoteName($realTable));
// Filter categories to com_content only
if ($abstractTable === '#__categories' && $type === 'categories') {
$query->where($db->quoteName('extension') . ' = ' . $db->quote('com_content'));
}
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
/**
* Dump rows from a table filtered by a column value.
*/
private function dumpFilteredTable(object $db, string $realTable, string $abstractTable, string $column, string $value): array
{
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($realTable))
->where($db->quoteName($column) . ' = ' . $db->quote($value));
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
/**
* Dump tag map entries for com_content items.
*/
private function dumpTagMap(object $db, string $prefix): array
{
$table = $prefix . 'contentitem_tag_map';
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName($table))
->where($db->quoteName('type_alias') . ' LIKE ' . $db->quote('com_content.%'));
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
private function log(string $message): void
{
$this->log[] = '[' . date('H:i:s') . '] ' . $message;
}
}