* @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, $db); 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; } } if (!empty($profile->ntfy_topic) && !extension_loaded('curl')) { $this->warnings[] = 'ext-curl is not loaded — 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') . ' IN (' . implode(',', array_map([$db, 'quote'], ['complete', 'warning'])) . ')') ->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)'; } } private const STALE_TIMEOUT_MINUTES = 30; /** * Check if another backup is already running for this profile. * * Backups running longer than STALE_TIMEOUT_MINUTES are automatically * marked as failed so they don't permanently block future runs. */ private function checkRunningBackup(object $profile, object $db): void { $query = $db->getQuery(true) ->select($db->quoteName(['id', 'backupstart', 'absolute_path'])) ->from($db->quoteName('#__mokosuitebackup_records')) ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) ->where($db->quoteName('status') . ' = ' . $db->quote('running')); $db->setQuery($query); $rows = $db->loadObjectList(); if (empty($rows)) { return; } $cutoff = time() - (self::STALE_TIMEOUT_MINUTES * 60); $stillAlive = 0; foreach ($rows as $row) { $started = strtotime($row->backupstart); if ($started !== false && $started < $cutoff) { $update = $db->getQuery(true) ->update($db->quoteName('#__mokosuitebackup_records')) ->set($db->quoteName('status') . ' = ' . $db->quote('fail')) ->set($db->quoteName('backupend') . ' = ' . $db->quote(date('Y-m-d H:i:s'))) ->where($db->quoteName('id') . ' = ' . (int) $row->id); $db->setQuery($update); $db->execute(); if (!empty($row->absolute_path) && is_file($row->absolute_path)) { @unlink($row->absolute_path); } $this->warnings[] = 'Auto-cancelled stalled backup #' . $row->id . ' (started ' . $row->backupstart . ', exceeded ' . self::STALE_TIMEOUT_MINUTES . ' min timeout)'; } else { $stillAlive++; } } if ($stillAlive > 0) { $this->errors[] = 'Another backup is already running for profile: ' . $profile->title . ' — wait for it to finish or use Cancel Stalled from the Backup Records toolbar'; } } /** * 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 destination credentials are minimally configured. * Does not test the actual connection (too slow for preflight). */ private function checkRemoteCredentials(object $profile, object $db): void { $query = $db->getQuery(true) ->select('*') ->from($db->quoteName('#__mokosuitebackup_remotes')) ->where($db->quoteName('profile_id') . ' = ' . (int) $profile->id) ->where($db->quoteName('enabled') . ' = 1'); $db->setQuery($query); $remotes = $db->loadObjectList(); if (empty($remotes)) { return; } foreach ($remotes as $remote) { $params = json_decode($remote->params, true) ?: []; $label = $remote->title ?: ('Remote #' . $remote->id); switch ($remote->type) { case 'ftp': if (empty($params['host'])) { $this->warnings[] = $label . ': FTP host is not configured — upload will fail'; } if (empty($params['username'])) { $this->warnings[] = $label . ': FTP username is not configured — upload will fail'; } break; case 's3': if (empty($params['bucket'])) { $this->warnings[] = $label . ': S3 bucket is not configured — upload will fail'; } if (empty($params['access_key']) || empty($params['secret_key'])) { $this->warnings[] = $label . ': S3 credentials are not configured — upload will fail'; } break; case 'sftp': if (empty($params['host'])) { $this->warnings[] = $label . ': SFTP host is not configured — upload will fail'; } if (empty($params['username'])) { $this->warnings[] = $label . ': SFTP username is not configured — upload will fail'; } if (empty($params['key_data']) && empty($params['password'])) { $this->warnings[] = $label . ': SFTP requires either a private key or password — upload will fail'; } break; case 'google_drive': if (empty($params['client_id']) || empty($params['client_secret'])) { $this->warnings[] = $label . ': Google Drive OAuth credentials are not configured — upload will fail'; } if (empty($params['refresh_token'])) { $this->warnings[] = $label . ': Google Drive refresh token is missing — upload will fail'; } break; } } } /** * Build the result array. */ private function result(): array { return [ 'pass' => empty($this->errors), 'errors' => $this->errors, 'warnings' => $this->warnings, ]; } }