diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml index a240c82..e11a558 100644 --- a/.mokogitea/workflows/issue-branch.yml +++ b/.mokogitea/workflows/issue-branch.yml @@ -5,7 +5,7 @@ # FILE INFORMATION # DEFGROUP: Gitea.Workflow # INGROUP: moko-platform.Automation -# VERSION: 01.26.00 +# VERSION: 01.26.02 # BRIEF: Auto-create feature branch when an issue is opened name: "Universal: Issue Branch" diff --git a/README.md b/README.md index d67eaa8..5c83fa9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MokoSuiteBackup - + Full-site backup and restore for Joomla — database, files, and configuration. diff --git a/mokosuitebackup.xml b/mokosuitebackup.xml index ead2eea..e3a6121 100644 --- a/mokosuitebackup.xml +++ b/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Web Services - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/mokosuitebackup.xml b/source/packages/com_mokosuitebackup/mokosuitebackup.xml index 4ac4483..9d2b620 100644 --- a/source/packages/com_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/com_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php index a15527e..5e1e9e6 100644 --- a/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php +++ b/source/packages/com_mokosuitebackup/src/Controller/BackupsController.php @@ -49,6 +49,13 @@ class BackupsController extends AdminController $engine = new BackupEngine(); $result = $engine->run($profileId, $description, 'backend'); + // Surface preflight warnings as Joomla messages + if (!empty($result['warnings'])) { + foreach ($result['warnings'] as $warning) { + $this->app->enqueueMessage($warning, 'warning'); + } + } + if ($result['success']) { $this->setMessage($result['message']); } else { diff --git a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php index 626073c..77fae79 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/BackupEngine.php @@ -32,16 +32,21 @@ class BackupEngine */ public function run(int $profileId, string $description, string $origin = 'backend'): array { + // Run pre-flight checks before creating any backup record + $preflight = new PreflightCheck(); + $preflightResult = $preflight->run($profileId); + + if (!$preflightResult['pass']) { + return [ + 'success' => false, + 'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']), + 'warnings' => $preflightResult['warnings'], + ]; + } + // Override PHP limits for long-running backup operations $this->overridePhpLimits(); - // Verify required extensions - $extCheck = $this->checkRequiredExtensions(); - - if ($extCheck !== true) { - return ['success' => false, 'message' => $extCheck]; - } - $db = Factory::getDbo(); // Load profile @@ -53,7 +58,12 @@ class BackupEngine $profile = $db->loadObject(); if (!$profile) { - return ['success' => false, 'message' => 'Profile not found: ' . $profileId]; + return ['success' => false, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []]; + } + + // Log any preflight warnings + foreach ($preflightResult['warnings'] as $warning) { + $this->log('PREFLIGHT WARNING: ' . $warning); } // Read settings directly from profile columns @@ -68,7 +78,7 @@ class BackupEngine $this->backupDir = BackupDirectory::resolve($resolver->resolve($configuredDir)); if (!BackupDirectory::ensureReady($this->backupDir)) { - return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0]; + return ['success' => false, 'message' => 'Cannot create backup directory: ' . $this->backupDir, 'record_id' => 0, 'warnings' => $preflightResult['warnings']]; } // Create backup record @@ -300,6 +310,7 @@ class BackupEngine 'success' => true, 'message' => 'Backup complete: ' . $archiveName . ' (' . $sizeHuman . ')', 'record_id' => $recordId, + 'warnings' => $preflightResult['warnings'], ]; } catch (\Throwable $e) { $this->log('FATAL: ' . $e->getMessage()); @@ -328,7 +339,7 @@ class BackupEngine // Dispatch event for actionlog and other listeners $this->dispatchAfterRun(false, $recordId, $description, $profileId, $origin); - return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId]; + return ['success' => false, 'message' => 'Backup failed: ' . $e->getMessage(), 'record_id' => $recordId, 'warnings' => $preflightResult['warnings'] ?? []]; } } @@ -383,35 +394,6 @@ class BackupEngine }; } - /** - * Verify required PHP extensions are loaded. - * - * @return true|string True if all ok, or error message string - */ - private function checkRequiredExtensions(): true|string - { - $required = [ - 'zip' => 'ext-zip (required for archive creation)', - 'pdo' => 'ext-pdo (required for database operations)', - 'pdo_mysql' => 'ext-pdo_mysql (required for MySQL database dumps)', - 'mbstring' => 'ext-mbstring (required for binary-safe operations)', - ]; - - $missing = []; - - foreach ($required as $ext => $label) { - if (!extension_loaded($ext)) { - $missing[] = $label; - } - } - - if (!empty($missing)) { - return 'Missing PHP extensions: ' . implode(', ', $missing); - } - - return true; - } - /** * Create the appropriate archiver based on the archive format. */ diff --git a/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php new file mode 100644 index 0000000..fea8fee --- /dev/null +++ b/source/packages/com_mokosuitebackup/src/Engine/PreflightCheck.php @@ -0,0 +1,288 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Pre-flight validation for backup operations. + * + * Runs before any backup record is created, catching problems early + * with clear messages instead of failing mid-backup. Returns a result + * with errors (blockers) and warnings (informational). + */ + +namespace Joomla\Component\MokoSuiteBackup\Administrator\Engine; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\Component\MokoSuiteBackup\Administrator\Utility\BackupDirectory; + +class PreflightCheck +{ + /** @var string[] Fatal issues that prevent backup from starting */ + private array $errors = []; + + /** @var string[] Non-fatal issues the user should know about */ + private array $warnings = []; + + /** + * Run all pre-flight checks for a backup profile. + * + * @param int $profileId Profile to validate + * + * @return array{pass: bool, errors: string[], warnings: string[]} + */ + public function run(int $profileId): array + { + $db = Factory::getDbo(); + + // Load profile + $query = $db->getQuery(true) + ->select('*') + ->from($db->quoteName('#__mokosuitebackup_profiles')) + ->where($db->quoteName('id') . ' = ' . $profileId); + $db->setQuery($query); + $profile = $db->loadObject(); + + if (!$profile) { + $this->errors[] = 'Profile not found: #' . $profileId; + + return $this->result(); + } + + if (!$profile->published) { + $this->errors[] = 'Profile is unpublished: ' . $profile->title; + + return $this->result(); + } + + $this->checkPhpExtensions($profile); + $this->checkBackupDirectory($profile); + $this->checkDiskSpace($profile, $db); + $this->checkRunningBackup($profile, $db); + $this->checkExcludedTables($profile, $db); + $this->checkRemoteCredentials($profile); + + return $this->result(); + } + + /** + * Check that required PHP extensions are loaded. + */ + private function checkPhpExtensions(object $profile): void + { + $required = ['pdo', 'pdo_mysql', 'mbstring']; + + // ZIP is required unless using tar.gz + $format = $profile->archive_format ?? 'zip'; + + if ($format === 'zip') { + $required[] = 'zip'; + } + + foreach ($required as $ext) { + if (!extension_loaded($ext)) { + $this->errors[] = 'Missing required PHP extension: ext-' . $ext; + } + } + + // curl is only needed for remote upload and ntfy notifications + $needsCurl = ($profile->remote_storage ?? 'none') !== 'none' + || !empty($profile->ntfy_topic); + + if ($needsCurl && !extension_loaded('curl')) { + $this->warnings[] = 'ext-curl is not loaded — remote upload and ntfy notifications will not work'; + } + } + + /** + * Check that the backup directory exists and is writable. + */ + private function checkBackupDirectory(object $profile): void + { + $configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER; + + // Resolve placeholders using a temporary resolver + $resolver = new PlaceholderResolver($profile); + $resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir)); + + if (BackupDirectory::hasPlaceholders($resolvedDir)) { + // Can't fully validate paths with unresolved placeholders + return; + } + + if (!is_dir($resolvedDir)) { + // Try to create it + if (!@mkdir($resolvedDir, 0755, true)) { + $this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir; + + return; + } + } + + if (!is_writable($resolvedDir)) { + $this->errors[] = 'Backup directory is not writable: ' . $resolvedDir; + } + } + + /** + * Check available disk space against the last backup size + 20% buffer. + * Skipped if no previous backup exists for this profile. + */ + private function checkDiskSpace(object $profile, object $db): void + { + $configuredDir = $profile->backup_dir ?: BackupDirectory::PLACEHOLDER; + $resolver = new PlaceholderResolver($profile); + $resolvedDir = BackupDirectory::resolve($resolver->resolve($configuredDir)); + + if (BackupDirectory::hasPlaceholders($resolvedDir) || !is_dir($resolvedDir)) { + return; + } + + // Find last successful backup size for this profile + $query = $db->getQuery(true) + ->select($db->quoteName('total_size')) + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) + ->where($db->quoteName('status') . ' = ' . $db->quote('complete')) + ->where($db->quoteName('total_size') . ' > 0') + ->order($db->quoteName('backupstart') . ' DESC'); + $db->setQuery($query, 0, 1); + $lastSize = (int) $db->loadResult(); + + if ($lastSize === 0) { + // No previous backup — skip disk space check + return; + } + + $requiredBytes = (int) ($lastSize * 1.2); // 20% buffer + $freeBytes = @disk_free_space($resolvedDir); + + if ($freeBytes === false) { + $this->warnings[] = 'Could not determine free disk space for: ' . $resolvedDir; + + return; + } + + if ($freeBytes < $requiredBytes) { + $freeMB = number_format($freeBytes / 1048576, 1); + $neededMB = number_format($requiredBytes / 1048576, 1); + + $this->warnings[] = 'Low disk space: ' . $freeMB . ' MB free, estimated ' . $neededMB . ' MB needed' + . ' (based on last backup + 20% buffer)'; + } + } + + /** + * Check if another backup is already running for this profile. + */ + private function checkRunningBackup(object $profile, object $db): void + { + $query = $db->getQuery(true) + ->select('COUNT(*)') + ->from($db->quoteName('#__mokosuitebackup_records')) + ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) + ->where($db->quoteName('status') . ' = ' . $db->quote('running')); + $db->setQuery($query); + $running = (int) $db->loadResult(); + + if ($running > 0) { + $this->errors[] = 'Another backup is already running for profile: ' . $profile->title + . ' — wait for it to finish or delete the stale record'; + } + } + + /** + * Check that excluded tables actually exist in the database. + * Missing tables are warnings, not errors — the profile may have + * been copied from another site or a table may have been removed. + */ + private function checkExcludedTables(object $profile, object $db): void + { + $excludeRaw = BackupDirectory::parseNewlineList($profile->exclude_tables ?? ''); + + if (empty($excludeRaw)) { + return; + } + + $prefix = $db->getPrefix(); + $allTables = array_flip($db->getTableList()); + + foreach ($excludeRaw as $entry) { + // Strip :data-only / :structure-only suffixes + $tableName = preg_replace('/:(?:data-only|structure-only)$/', '', $entry); + + // Resolve #__ prefix to real prefix + $realName = str_replace('#__', $prefix, $tableName); + + if (!isset($allTables[$realName])) { + $this->warnings[] = 'Excluded table does not exist: ' . $tableName + . ' — it will be silently skipped during backup'; + } + } + } + + /** + * Check that remote storage credentials are minimally configured. + * Does not test the actual connection (too slow for preflight). + */ + private function checkRemoteCredentials(object $profile): void + { + $remote = $profile->remote_storage ?? 'none'; + + if ($remote === 'none') { + return; + } + + switch ($remote) { + case 'ftp': + if (empty($profile->ftp_host)) { + $this->warnings[] = 'FTP host is not configured — remote upload will fail'; + } + + if (empty($profile->ftp_username)) { + $this->warnings[] = 'FTP username is not configured — remote upload will fail'; + } + + break; + + case 's3': + if (empty($profile->s3_bucket)) { + $this->warnings[] = 'S3 bucket is not configured — remote upload will fail'; + } + + if (empty($profile->s3_access_key) || empty($profile->s3_secret_key)) { + $this->warnings[] = 'S3 credentials are not configured — remote upload will fail'; + } + + break; + + case 'google_drive': + if (empty($profile->gdrive_client_id) || empty($profile->gdrive_client_secret)) { + $this->warnings[] = 'Google Drive OAuth credentials are not configured — remote upload will fail'; + } + + if (empty($profile->gdrive_refresh_token)) { + $this->warnings[] = 'Google Drive refresh token is missing — remote upload will fail'; + } + + break; + } + } + + /** + * Build the result array. + */ + private function result(): array + { + return [ + 'pass' => empty($this->errors), + 'errors' => $this->errors, + 'warnings' => $this->warnings, + ]; + } +} diff --git a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php index 7df30e7..6ba97ea 100644 --- a/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php +++ b/source/packages/com_mokosuitebackup/src/Engine/SteppedBackupEngine.php @@ -32,6 +32,18 @@ class SteppedBackupEngine */ public function init(int $profileId, string $description = '', string $origin = 'backend'): array { + // Run pre-flight checks before creating any backup record + $preflight = new PreflightCheck(); + $preflightResult = $preflight->run($profileId); + + if (!$preflightResult['pass']) { + return [ + 'error' => true, + 'message' => 'Pre-flight failed: ' . implode('; ', $preflightResult['errors']), + 'warnings' => $preflightResult['warnings'], + ]; + } + $db = Factory::getDbo(); // Load profile @@ -43,7 +55,7 @@ class SteppedBackupEngine $profile = $db->loadObject(); if (!$profile) { - return ['error' => true, 'message' => 'Profile not found: ' . $profileId]; + return ['error' => true, 'message' => 'Profile not found: ' . $profileId, 'warnings' => []]; } // Create session @@ -130,6 +142,11 @@ class SteppedBackupEngine $session->phase = ($profile->backup_type !== 'files') ? 'database' : 'files'; $session->log('Backup initialized: ' . $session->description); $session->log('Total steps: ' . $totalSteps . ' (tables: ' . count($session->tables) . ', file batches: ' . count($session->fileBatches) . ')'); + // Log any preflight warnings into the session + foreach ($preflightResult['warnings'] as $warning) { + $session->log('PREFLIGHT WARNING: ' . $warning); + } + $session->statusMessage = 'Initialized — starting backup...'; $session->save(); @@ -138,6 +155,7 @@ class SteppedBackupEngine 'phase' => $session->phase, 'progress' => $session->getProgress(), 'message' => $session->statusMessage, + 'warnings' => $preflightResult['warnings'], ]; } diff --git a/source/packages/com_mokosuitebackup/tmpl/backups/default.php b/source/packages/com_mokosuitebackup/tmpl/backups/default.php index c7a4bf6..ce1213b 100644 --- a/source/packages/com_mokosuitebackup/tmpl/backups/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/backups/default.php @@ -270,10 +270,17 @@ $listDirn = $this->escape($this->state->get('list.direction')); if (initResult.error) { updateProgress(0, 'ERROR: ' + initResult.message, 'failed'); - setTimeout(hideModal, 3000); + setTimeout(hideModal, 5000); return; } + // Show preflight warnings if any + if (initResult.warnings && initResult.warnings.length > 0) { + var warningEl = document.getElementById('mb-phase'); + warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; '); + warningEl.style.color = '#856404'; + } + const sessionId = initResult.session_id; updateProgress(initResult.progress, initResult.message, initResult.phase); diff --git a/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php b/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php index 50576b3..d82ac57 100644 --- a/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php +++ b/source/packages/com_mokosuitebackup/tmpl/dashboard/default.php @@ -255,10 +255,17 @@ document.querySelectorAll('.mb-tile').forEach(function(tile) { if (initResult.error) { updateProgress(0, 'ERROR: ' + initResult.message, 'failed'); - setTimeout(hideModal, 3000); + setTimeout(hideModal, 5000); return; } + // Show preflight warnings if any + if (initResult.warnings && initResult.warnings.length > 0) { + var warningEl = document.getElementById('mb-phase'); + warningEl.textContent = 'Warnings: ' + initResult.warnings.join('; '); + warningEl.style.color = '#856404'; + } + const sessionId = initResult.session_id; updateProgress(initResult.progress, initResult.message, initResult.phase); diff --git a/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml index 142e50a..433b7de 100644 --- a/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_actionlog_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Action Log - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-04 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml index 0295240..8a2423a 100644 --- a/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_console_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Console - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-04 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml index 7bf765f..10aa4cc 100644 --- a/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_content_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Content - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-04 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml index f50c6e9..a3c8024 100644 --- a/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_quickicon_mokosuitebackup/mokosuitebackup.xml @@ -1,7 +1,7 @@ Quick Icon - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml index 80e0fb9..04a1da0 100644 --- a/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_system_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> System - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml index aad969f..d1ea675 100644 --- a/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_task_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Task - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml b/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml index ead2eea..e3a6121 100644 --- a/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml +++ b/source/packages/plg_webservices_mokosuitebackup/mokosuitebackup.xml @@ -7,7 +7,7 @@ --> Web Services - MokoSuiteBackup - 01.26.00 + 01.26.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech diff --git a/source/pkg_mokosuitebackup.xml b/source/pkg_mokosuitebackup.xml index 6157a75..3fa8079 100644 --- a/source/pkg_mokosuitebackup.xml +++ b/source/pkg_mokosuitebackup.xml @@ -8,7 +8,7 @@ Package - MokoSuiteBackup mokosuitebackup - 01.26.00 + 01.26.02 2026-06-02 Moko Consulting hello@mokoconsulting.tech