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) <noreply@anthropic.com>
This commit is contained in:
Jonathan Miller
2026-06-02 15:33:14 -05:00
parent dd572c6f9e
commit e597896e73
5 changed files with 113 additions and 0 deletions
@@ -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.
*/
@@ -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'];
}
@@ -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'];
}
@@ -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];
@@ -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