edb202071c
Universal: Auto Version Bump / Version Bump (push) Successful in 9s
Validate backup prerequisites before creating any record, catching common issues early with clear messages instead of failing mid-backup. Pre-flight checks: - Required PHP extensions (zip, pdo, pdo_mysql, mbstring, curl) - Backup directory exists and is writable - Sufficient disk space (last backup size + 20% buffer, skipped if no previous backup exists) - No other backup already running for this profile - Excluded tables exist in database (warns on missing) - Remote storage credentials minimally configured (FTP/S3/GDrive) Errors block the backup; warnings are logged and displayed but allow the backup to proceed. Integrated into both BackupEngine::run() and SteppedBackupEngine::init() before any record is inserted. UI: AJAX init response includes warnings array, displayed in the stepped backup progress modal. Closes #67
289 lines
8.2 KiB
PHP
289 lines
8.2 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
|
|
*
|
|
* 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,
|
|
];
|
|
}
|
|
}
|