f66100f74f
Joomla: Extension CI / Tests (PHP 8.2) (pull_request) Blocked by required conditions
Joomla: Extension CI / Tests (PHP 8.3) (pull_request) Blocked by required conditions
Joomla: Extension CI / PHPStan Analysis (pull_request) Blocked by required conditions
Joomla: Extension CI / Build RC Pre-Release (pull_request) Blocked by required conditions
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Report Issues (pull_request) Blocked by required conditions
Generic: Repo Health / Scripts governance (pull_request) Blocked by required conditions
Generic: Repo Health / Repository health (pull_request) Blocked by required conditions
Generic: Repo Health / Report Issues (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Failing after 2s
Joomla: Extension CI / Release Readiness Check (pull_request) Failing after 7s
Joomla: Extension CI / Lint & Validate (pull_request) Failing after 13s
Universal: PR Check / Secret Scan (pull_request) Successful in 9s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Failing after 1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Joomla: Metadata Validation / Validate Joomla Metadata (pull_request) Successful in 34s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 3s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 27s
SFTP support: - SftpUploader uses system scp/ssh binaries with key file auth - Private key stored as MEDIUMTEXT in profile table (sftp_key_data) - Key written to temp file (0600) at upload time, deleted after - Profile form: host, port, username, password, key textarea, passphrase, remote path — all with showon="remote_storage:sftp" - SQL migration for 7 new SFTP columns - Wired into BackupEngine, SteppedBackupEngine, PreflightCheck - API credential masking includes SFTP fields CLI restore options: - --files-only: restore files without touching database - --db-only: restore database without touching files - --no-preserve-config: overwrite configuration.php - --password: decryption password for encrypted archives
321 lines
9.2 KiB
PHP
321 lines
9.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
|
|
{
|
|
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,
|
|
];
|
|
}
|
|
}
|