From e597896e73fea1d22b84bf5d0e6430702e1bb1d9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Tue, 2 Jun 2026 15:33:14 -0500 Subject: [PATCH] feat: add PHP limit overrides and runtime extension checks - BackupEngine: set_time_limit(0), memory_limit 512M, ignore_user_abort, flush output buffers, disable zlib compression - RestoreEngine: same PHP limit overrides - FtpUploader: check ext-ftp before upload attempt - GoogleDriveUploader: check ext-curl before upload attempt - BackupEngine: check ext-zip and ext-mbstring at startup - Kickstart restore.php: check ext-pdo_mysql and ext-mbstring in preflight, show max_execution_time and memory_limit in check results Authored-by: Moko Consulting Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/Engine/BackupEngine.php | 85 +++++++++++++++++++ .../com_mokobackup/src/Engine/FtpUploader.php | 4 + .../src/Engine/GoogleDriveUploader.php | 4 + .../com_mokobackup/src/Engine/Kickstart.php | 10 +++ .../src/Engine/RestoreEngine.php | 10 +++ 5 files changed, 113 insertions(+) diff --git a/src/packages/com_mokobackup/src/Engine/BackupEngine.php b/src/packages/com_mokobackup/src/Engine/BackupEngine.php index 437cd0ac..49bedab4 100644 --- a/src/packages/com_mokobackup/src/Engine/BackupEngine.php +++ b/src/packages/com_mokobackup/src/Engine/BackupEngine.php @@ -30,6 +30,16 @@ class BackupEngine */ public function run(int $profileId, string $description, string $origin = 'backend'): array { + // 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 @@ -221,6 +231,81 @@ class BackupEngine } } + /** + * Override PHP execution limits for backup operations. + * Attempts multiple methods since some hosts restrict ini_set. + */ + private function overridePhpLimits(): void + { + // Remove execution time limit (0 = unlimited) + @set_time_limit(0); + @ini_set('max_execution_time', '0'); + + // Increase memory limit for large sites + $currentMemory = $this->parseBytes(ini_get('memory_limit')); + + if ($currentMemory > 0 && $currentMemory < 512 * 1024 * 1024) { + @ini_set('memory_limit', '512M'); + } + + // Disable output buffering to prevent memory buildup + while (@ob_end_clean()) { + // flush all output buffers + } + + // Prevent browser/proxy timeout by disabling compression + @ini_set('zlib.output_compression', 'Off'); + + // Ignore user abort so backup completes even if browser closes + @ignore_user_abort(true); + } + + /** + * Parse a PHP ini byte value (e.g. "128M") into bytes. + */ + private function parseBytes(string $value): int + { + $value = trim($value); + + if ($value === '-1' || $value === '') { + return -1; + } + + $unit = strtolower(substr($value, -1)); + $num = (int) $value; + + return match ($unit) { + 'g' => $num * 1024 * 1024 * 1024, + 'm' => $num * 1024 * 1024, + 'k' => $num * 1024, + default => $num, + }; + } + + /** + * Verify required PHP extensions are loaded. + * + * @return true|string True if all ok, or error message string + */ + private function checkRequiredExtensions(): true|string + { + $missing = []; + + if (!extension_loaded('zip')) { + $missing[] = 'ext-zip (required for archive creation)'; + } + + if (!extension_loaded('mbstring') && !function_exists('mb_strlen')) { + $missing[] = 'ext-mbstring (required for binary-safe operations)'; + } + + if (!empty($missing)) { + return 'Missing PHP extensions: ' . implode(', ', $missing); + } + + return true; + } + /** * Create the appropriate remote uploader based on the storage type. */ diff --git a/src/packages/com_mokobackup/src/Engine/FtpUploader.php b/src/packages/com_mokobackup/src/Engine/FtpUploader.php index f9c3b6b0..b370f34b 100644 --- a/src/packages/com_mokobackup/src/Engine/FtpUploader.php +++ b/src/packages/com_mokobackup/src/Engine/FtpUploader.php @@ -35,6 +35,10 @@ class FtpUploader implements RemoteUploaderInterface public function upload(string $localPath, string $remoteName): array { + if (!extension_loaded('ftp')) { + return ['success' => false, 'message' => 'PHP ext-ftp is required for FTP uploads. Enable it in php.ini.']; + } + if (empty($this->host)) { return ['success' => false, 'message' => 'FTP host is not configured']; } diff --git a/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php b/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php index 600362ff..1197ec6b 100644 --- a/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php +++ b/src/packages/com_mokobackup/src/Engine/GoogleDriveUploader.php @@ -37,6 +37,10 @@ class GoogleDriveUploader implements RemoteUploaderInterface public function upload(string $localPath, string $remoteName): array { + if (!extension_loaded('curl')) { + return ['success' => false, 'message' => 'PHP ext-curl is required for Google Drive uploads. Enable it in php.ini.']; + } + if (empty($this->clientId) || empty($this->refreshToken)) { return ['success' => false, 'message' => 'Google Drive credentials not configured']; } diff --git a/src/packages/com_mokobackup/src/Engine/Kickstart.php b/src/packages/com_mokobackup/src/Engine/Kickstart.php index 76024c44..75c165ce 100644 --- a/src/packages/com_mokobackup/src/Engine/Kickstart.php +++ b/src/packages/com_mokobackup/src/Engine/Kickstart.php @@ -111,11 +111,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action'])) { try { switch ($action) { case 'preflight': + // Override PHP limits for restore + @set_time_limit(0); + @ini_set('max_execution_time', '0'); + @ini_set('memory_limit', '512M'); + @ignore_user_abort(true); + $checks = []; $checks['php_version'] = ['value' => PHP_VERSION, 'ok' => version_compare(PHP_VERSION, '8.1', '>=')]; $checks['zip_ext'] = ['value' => extension_loaded('zip') ? 'Yes' : 'No', 'ok' => extension_loaded('zip')]; + $checks['pdo_mysql'] = ['value' => extension_loaded('pdo_mysql') ? 'Yes' : 'No', 'ok' => extension_loaded('pdo_mysql')]; + $checks['mbstring'] = ['value' => extension_loaded('mbstring') ? 'Yes' : 'No', 'ok' => extension_loaded('mbstring')]; $checks['backup_exists'] = ['value' => file_exists(BACKUP_FILE) ? 'Yes' : 'No', 'ok' => file_exists(BACKUP_FILE)]; $checks['writable'] = ['value' => is_writable(RESTORE_DIR) ? 'Yes' : 'No', 'ok' => is_writable(RESTORE_DIR)]; + $checks['max_execution_time'] = ['value' => ini_get('max_execution_time') ?: 'unlimited', 'ok' => true]; + $checks['memory_limit'] = ['value' => ini_get('memory_limit'), 'ok' => true]; if (file_exists(BACKUP_FILE)) { $checks['backup_size'] = ['value' => number_format(filesize(BACKUP_FILE) / 1048576, 2) . ' MB', 'ok' => true]; diff --git a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php index ec3baaa5..28b27269 100644 --- a/src/packages/com_mokobackup/src/Engine/RestoreEngine.php +++ b/src/packages/com_mokobackup/src/Engine/RestoreEngine.php @@ -41,6 +41,16 @@ class RestoreEngine */ public function restore(int $recordId, bool $restoreFiles = true, bool $restoreDb = true, bool $preserveConfig = true): array { + // Override PHP limits — restores can take a long time + @set_time_limit(0); + @ini_set('max_execution_time', '0'); + @ini_set('memory_limit', '512M'); + @ignore_user_abort(true); + + if (!extension_loaded('zip')) { + return ['success' => false, 'message' => 'PHP ext-zip is required for restore operations']; + } + $db = Factory::getDbo(); // Load backup record