* @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 { try { $db = Factory::getDbo(); } catch (\Exception $e) { $this->errors[] = 'Cannot connect to database: ' . $e->getMessage(); return $this->result(); } // Load profile try { $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_profiles')) ->where($db->quoteName('id') . ' = ' . (int) $profileId); $db->setQuery($query); $profile = $db->loadObject(); } catch (\Exception $e) { $this->errors[] = 'Cannot load profile: ' . $e->getMessage(); return $this->result(); } 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)) { $this->warnings[] = 'Backup directory contains unresolved placeholders: ' . $resolvedDir . ' — directory cannot be validated until backup runs'; return; } if (!is_dir($resolvedDir)) { // Try to create it if (!@mkdir($resolvedDir, 0755, true)) { $lastError = error_get_last(); $reason = $lastError['message'] ?? 'unknown reason'; $this->errors[] = 'Backup directory does not exist and cannot be created: ' . $resolvedDir . ' (' . $reason . ')'; 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 'sftp': if (empty($profile->sftp_host)) { $this->warnings[] = 'SFTP host is not configured — remote upload will fail'; } if (empty($profile->sftp_username)) { $this->warnings[] = 'SFTP username is not configured — remote upload will fail'; } if (empty($profile->sftp_key_data) && empty($profile->sftp_password)) { $this->warnings[] = 'SFTP requires either a private key or password — 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, ]; } }