settingModel = new Setting(); $this->logger = new Logger('updater'); $this->rootPath = realpath(__DIR__ . '/../../'); $this->httpClient = new Client([ 'timeout' => 30, 'headers' => [ 'Accept' => 'application/vnd.github.v3+json', 'User-Agent' => 'DomainMonitor/' . $this->settingModel->getAppVersion(), ], ]); } /** * Check for available updates based on the user's chosen update channel * Returns structured info about what's available */ public function checkForUpdate(bool $forceCheck = false): array { $channel = $this->settingModel->getValue('update_channel', 'stable'); $currentVersion = $this->settingModel->getAppVersion(); $localSha = $this->settingModel->getValue('installed_commit_sha', null); // Check cache (unless forced) if (!$forceCheck) { $lastCheck = $this->settingModel->getValue('last_update_check', null); if ($lastCheck && $this->isCacheValid($lastCheck)) { return $this->getCachedResult($currentVersion, $channel, $localSha); } } $this->logger->info('Checking for updates', [ 'channel' => $channel, 'current_version' => $currentVersion, 'local_sha' => $localSha ? substr($localSha, 0, 7) : 'unknown', ]); $result = [ 'available' => false, 'type' => null, 'current_version' => $currentVersion, 'channel' => $channel, 'error' => null, ]; try { // Always check for tagged releases $release = $this->fetchLatestRelease(); if ($release) { $latestVersion = ltrim($release['tag_name'], 'v'); $result['latest_version'] = $latestVersion; $result['release_notes'] = $release['body'] ?? ''; $result['release_url'] = $release['html_url'] ?? ''; $result['published_at'] = $release['published_at'] ?? null; $result['download_url'] = $release['zipball_url'] ?? null; if (version_compare($latestVersion, $currentVersion, '>')) { $result['available'] = true; $result['type'] = 'release'; } // Cache release info $this->settingModel->setValue('latest_available_version', $latestVersion); $this->settingModel->setValue('latest_release_notes', $release['body'] ?? ''); $this->settingModel->setValue('latest_release_url', $release['html_url'] ?? ''); $this->settingModel->setValue('latest_release_published_at', $release['published_at'] ?? ''); } // If on "latest" channel, also check for untagged commits if ($channel === 'latest' && $localSha) { $commits = $this->fetchCommitsSince($localSha); if ($commits !== null && !empty($commits)) { // If there's no new version release but there ARE new commits, it's a hotfix if (!$result['available']) { $result['available'] = true; $result['type'] = 'hotfix'; } $result['commits_behind'] = count($commits); $result['commit_messages'] = array_map(function ($c) { return [ 'sha' => substr($c['sha'], 0, 7), 'message' => $c['commit']['message'] ?? '', 'author' => $c['commit']['author']['name'] ?? 'Unknown', 'date' => $c['commit']['author']['date'] ?? null, ]; }, array_slice($commits, 0, 20)); // Limit to 20 most recent $result['remote_sha'] = $commits[0]['sha'] ?? null; // Cache commit info $this->settingModel->setValue('latest_remote_sha', $result['remote_sha'] ?? ''); $this->settingModel->setValue('commits_behind_count', count($commits)); } else { // No new commits — explicitly clear stale cache so the UI // doesn't keep showing "update available" after a hotfix was applied. $this->settingModel->setValue('commits_behind_count', '0'); $this->settingModel->setValue('latest_remote_sha', ''); } } elseif ($channel === 'latest' && !$localSha) { $result['commit_tracking_unavailable'] = true; } // Update last check timestamp $this->settingModel->setValue('last_update_check', date('Y-m-d H:i:s')); $this->logger->info('Update check completed', [ 'available' => $result['available'], 'type' => $result['type'], 'latest_version' => $result['latest_version'] ?? 'N/A', ]); } catch (\Exception $e) { $this->logger->error('Update check failed', [ 'error' => $e->getMessage(), ]); $result['error'] = 'Failed to check for updates: ' . $e->getMessage(); } return $result; } /** * Download and apply an update (release or hotfix) */ public function performUpdate(string $type = 'release'): array { $this->logger->startOperation('Application Update'); $result = [ 'success' => false, 'from_version' => $this->settingModel->getAppVersion(), 'files_updated' => 0, 'backup_path' => null, 'errors' => [], ]; try { // Step 1: Pre-flight checks $this->logger->info('Running pre-flight checks'); $preflight = $this->preflightChecks(); if (!$preflight['pass']) { $result['errors'] = $preflight['errors']; return $result; } // Step 2: Determine download URL $downloadUrl = $this->getDownloadUrl($type); if (!$downloadUrl) { $result['errors'][] = 'Could not determine download URL for update'; return $result; } // Step 3a: Create database backup $this->logger->info('Creating database backup'); $dbBackupResult = $this->createDatabaseBackup(); if ($dbBackupResult['success']) { $result['db_backup_path'] = $dbBackupResult['path']; $this->settingModel->setValue('update_db_backup_path', $dbBackupResult['path']); $this->logger->info('Database backup created', ['path' => $dbBackupResult['path'], 'method' => $dbBackupResult['method']]); } else { $this->logger->warning('Database backup skipped: ' . $dbBackupResult['reason']); $result['db_backup_warning'] = $dbBackupResult['reason']; } // Step 3b: Create file backup $this->logger->info('Creating file backup'); $backupPath = $this->createBackup(); $result['backup_path'] = $backupPath; $this->settingModel->setValue('update_backup_path', $backupPath); // Step 4: Download the archive $this->logger->info('Downloading update', ['url' => $downloadUrl]); $archivePath = $this->downloadArchive($downloadUrl); // Step 5: Extract to staging directory $this->logger->info('Extracting archive'); $stagingDir = $this->extractArchive($archivePath); // Step 5b: Verify extracted archive matches expected commit (integrity check) $this->verifyExtractedCommitSha($stagingDir, $type); // Step 5c: Check if composer dependencies changed (before we overwrite root) $composerChanged = $this->checkComposerChanged($stagingDir); // Step 6: Copy files (respecting protected paths) $this->logger->info('Applying update files'); $filesUpdated = $this->applyFiles($stagingDir); $result['files_updated'] = $filesUpdated; // Step 7: Run composer install if dependencies changed (skip if exec disabled, e.g. cPanel) $result['composer_manual_required'] = false; if ($composerChanged) { if (!$this->canRunShellCommands()) { $this->logger->warning('Composer dependencies changed but shell commands are disabled (e.g. exec in disable_functions). Run composer install manually.'); $result['composer_manual_required'] = true; } elseif (!$this->runComposerInstall()) { $result['composer_manual_required'] = true; } if ($result['composer_manual_required']) { $this->logger->info('Composer manual action required. If dependencies changed, run: composer install --no-dev (e.g. via SSH or cPanel Terminal).'); } } // Step 8: Update commit SHA tracking $this->updateCommitSha(); // Step 9: Clean up $this->cleanup($archivePath, $stagingDir); $result['success'] = true; // Report the version we actually applied (DB app_version only changes after migrations) $result['to_version'] = $type === 'release' ? ($this->settingModel->getValue('latest_available_version') ?: $this->settingModel->getAppVersion()) : ($this->settingModel->getValue('latest_remote_sha') ? substr($this->settingModel->getValue('latest_remote_sha'), 0, 7) : 'latest'); // Clear cached update state so the UI no longer shows a stale "update available" card $this->clearUpdateCache(); $this->logger->endOperation('Application Update', [ 'success' => true, 'files_updated' => $filesUpdated, 'composer_updated' => $composerChanged, ]); } catch (\Exception $e) { $this->logger->error('Update failed', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $result['errors'][] = $e->getMessage(); // Attempt rollback if (!empty($result['backup_path'])) { $this->logger->info('Attempting rollback'); try { $this->restoreBackup($result['backup_path']); $result['errors'][] = 'Update failed but rollback was successful'; } catch (\Exception $rollbackError) { $result['errors'][] = 'Rollback also failed: ' . $rollbackError->getMessage(); } } } return $result; } /** * Rollback to last backup */ public function rollback(): array { $backupPath = $this->settingModel->getValue('update_backup_path', null); if (!$backupPath || !file_exists($backupPath)) { return [ 'success' => false, 'error' => 'No backup available for rollback', ]; } try { $this->logger->startOperation('Rollback'); // Restore database first (if backup exists) $dbBackupPath = $this->settingModel->getValue('update_db_backup_path', null); if ($dbBackupPath && file_exists($dbBackupPath)) { $this->logger->info('Restoring database from backup', ['path' => $dbBackupPath]); $dbRestored = $this->restoreDatabaseBackup($dbBackupPath); if (!$dbRestored) { $this->logger->warning('Database restore failed or skipped. SQL file is still available for manual import.', ['path' => $dbBackupPath]); } } else { $this->logger->info('No database backup found, restoring files only'); } // Restore files $this->restoreBackup($backupPath); $this->logger->endOperation('Rollback', ['success' => true]); return ['success' => true, 'db_restored' => isset($dbRestored) ? $dbRestored : null]; } catch (\Exception $e) { $this->logger->error('Rollback failed', ['error' => $e->getMessage()]); return [ 'success' => false, 'error' => 'Rollback failed: ' . $e->getMessage(), ]; } } // ======================================================================== // Private: GitHub API methods // ======================================================================== /** * Fetch the latest release from GitHub */ private function fetchLatestRelease(): ?array { try { $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/releases/latest'; $response = $this->httpClient->get($url); return json_decode($response->getBody()->getContents(), true); } catch (\GuzzleHttp\Exception\ClientException $e) { if ($e->getResponse()->getStatusCode() === 404) { // No releases yet return null; } throw $e; } } /** * Fetch commits on main since a given SHA * Uses the compare API: /repos/{owner}/{repo}/compare/{base}...{head} */ private function fetchCommitsSince(string $sinceCommitSha): ?array { try { $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/compare/' . $sinceCommitSha . '...main'; $response = $this->httpClient->get($url); $data = json_decode($response->getBody()->getContents(), true); if (isset($data['status']) && $data['status'] === 'identical') { return []; } return $data['commits'] ?? []; } catch (\GuzzleHttp\Exception\ClientException $e) { if ($e->getResponse()->getStatusCode() === 404) { $this->logger->warning('Commit comparison failed - SHA may not exist on remote', [ 'sha' => substr($sinceCommitSha, 0, 7), ]); return null; } throw $e; } } /** * Get the latest commit SHA from the main branch */ private function fetchLatestCommitSha(): ?string { try { $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/commits/main'; $response = $this->httpClient->get($url); $data = json_decode($response->getBody()->getContents(), true); return $data['sha'] ?? null; } catch (\Exception $e) { $this->logger->warning('Failed to fetch latest commit SHA', [ 'error' => $e->getMessage(), ]); return null; } } // ======================================================================== // Private: Cache methods // ======================================================================== /** * Clear all cached update-check state so the UI no longer shows stale "update available" info. * Called after a successful update is applied. */ private function clearUpdateCache(): void { $this->settingModel->setValue('last_update_check', null); $this->settingModel->setValue('commits_behind_count', '0'); $this->settingModel->setValue('latest_remote_sha', ''); $this->settingModel->setValue('latest_release_notes', ''); $this->settingModel->setValue('latest_release_url', ''); $this->settingModel->setValue('latest_release_published_at', ''); // Note: latest_available_version is kept — it's used by the view to compare // against current_version. After migrations bump app_version, the comparison // will no longer show an update. Clearing it could cause issues if migrations // haven't run yet and the user reloads the page. } private function isCacheValid(string $lastCheckTimestamp): bool { $lastCheck = strtotime($lastCheckTimestamp); $ttlSeconds = self::CACHE_TTL_HOURS * 3600; return (time() - $lastCheck) < $ttlSeconds; } private function getCachedResult(string $currentVersion, string $channel, ?string $localSha): array { $latestVersion = $this->settingModel->getValue('latest_available_version', null); $result = [ 'available' => false, 'type' => null, 'current_version' => $currentVersion, 'channel' => $channel, 'cached' => true, 'last_check' => $this->settingModel->getValue('last_update_check'), 'error' => null, ]; if ($latestVersion) { $result['latest_version'] = $latestVersion; $result['release_notes'] = $this->settingModel->getValue('latest_release_notes', ''); $result['release_url'] = $this->settingModel->getValue('latest_release_url', ''); $result['published_at'] = $this->settingModel->getValue('latest_release_published_at', ''); if (version_compare($latestVersion, $currentVersion, '>')) { $result['available'] = true; $result['type'] = 'release'; } } // Check cached commit info for "latest" channel if ($channel === 'latest' && $localSha) { $commitsBehind = (int) $this->settingModel->getValue('commits_behind_count', 0); if ($commitsBehind > 0 && !$result['available']) { $result['available'] = true; $result['type'] = 'hotfix'; $result['commits_behind'] = $commitsBehind; $result['remote_sha'] = $this->settingModel->getValue('latest_remote_sha', ''); } } return $result; } // ======================================================================== // Private: Update process methods // ======================================================================== /** * Run pre-flight checks before updating */ private function preflightChecks(): array { $errors = []; // Check PHP extensions if (!extension_loaded('zip')) { $errors[] = 'PHP zip extension is required for updates'; } // Check write permissions on key directories $dirsToCheck = [ $this->rootPath . '/app', $this->rootPath . '/core', $this->rootPath . '/public', $this->rootPath . '/database', $this->rootPath . '/routes', ]; foreach ($dirsToCheck as $dir) { if (is_dir($dir) && !is_writable($dir)) { $errors[] = "Directory not writable: $dir"; } } // Check temp directory is writable $tempDir = sys_get_temp_dir(); if (!is_writable($tempDir)) { $errors[] = "System temp directory not writable: $tempDir"; } // Check available disk space (require at least 50MB free) $freeSpace = disk_free_space($this->rootPath); if ($freeSpace !== false && $freeSpace < 50 * 1024 * 1024) { $errors[] = 'Insufficient disk space. At least 50MB required'; } return [ 'pass' => empty($errors), 'errors' => $errors, ]; } /** * Determine the download URL based on update type */ private function getDownloadUrl(string $type): ?string { if ($type === 'release') { // Use the cached release download URL or fetch fresh $release = $this->fetchLatestRelease(); return $release['zipball_url'] ?? null; } // For hotfix updates, download the latest main branch as zip return self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/zipball/main'; } /** * Download archive from URL to temp file */ private function downloadArchive(string $url): string { $tempFile = tempnam(sys_get_temp_dir(), 'dm_update_') . '.zip'; $response = $this->httpClient->get($url, [ 'sink' => $tempFile, 'timeout' => 120, ]); $fileSize = filesize($tempFile); $sha256 = hash_file('sha256', $tempFile); $this->logger->info('Archive downloaded', [ 'size_bytes' => $fileSize, 'sha256' => $sha256, 'path' => $tempFile, ]); if ($fileSize < 1000) { unlink($tempFile); throw new \RuntimeException('Downloaded file is too small - likely an error response'); } return $tempFile; } /** * Extract zip archive to a staging directory */ private function extractArchive(string $archivePath): string { $stagingDir = sys_get_temp_dir() . '/dm_staging_' . uniqid(); mkdir($stagingDir, 0755, true); $zip = new \ZipArchive(); $openResult = $zip->open($archivePath); if ($openResult !== true) { throw new \RuntimeException("Failed to open zip archive (error code: $openResult)"); } $zip->extractTo($stagingDir); $zip->close(); // GitHub zipballs have a top-level directory like "Owner-Repo-SHA/" // Find it and return the actual content directory $entries = scandir($stagingDir); $topDir = null; foreach ($entries as $entry) { if ($entry !== '.' && $entry !== '..' && is_dir($stagingDir . '/' . $entry)) { $topDir = $stagingDir . '/' . $entry; break; } } if (!$topDir) { throw new \RuntimeException('Unexpected archive structure - no top-level directory found'); } $this->logger->info('Archive extracted', ['staging_dir' => $topDir]); return $topDir; } /** * Get the expected short commit SHA (7 chars) for the update we are applying. * Used to verify the downloaded zipball matches the expected ref. */ private function getExpectedShortSha(string $type): ?string { if ($type === 'hotfix') { $fullSha = $this->fetchLatestCommitSha(); return $fullSha ? substr($fullSha, 0, 7) : null; } if ($type === 'release') { $release = $this->fetchLatestRelease(); if (empty($release['tag_name'])) { return null; } $tag = $release['tag_name']; // e.g. v1.1.3 try { $url = self::GITHUB_API_BASE . '/repos/' . self::GITHUB_REPO . '/commits/' . $tag; $response = $this->httpClient->get($url); $data = json_decode($response->getBody()->getContents(), true); $fullSha = $data['sha'] ?? null; return $fullSha ? substr($fullSha, 0, 7) : null; } catch (\Exception $e) { $this->logger->warning('Could not fetch commit SHA for release tag', [ 'tag' => $tag, 'error' => $e->getMessage(), ]); return null; } } return null; } /** * Extract the short commit SHA from GitHub zipball top-level folder name. * Format is "Owner-Repo-" (e.g. Hosteroid-domain-monitor-abc1234). */ private function getShortShaFromFolderName(string $stagingDirPath): ?string { $folderName = basename($stagingDirPath); $parts = explode('-', $folderName); $last = end($parts); // GitHub uses 7-char short SHA; could also be 40-char full SHA in some cases if (preg_match('/^[a-f0-9]{7,40}$/i', $last)) { return strlen($last) >= 7 ? substr($last, 0, 7) : $last; } return null; } /** * Verify that the extracted archive's folder name matches the expected commit SHA. * This ensures we applied the correct ref (tag or main) and detects corrupted/wrong downloads. */ private function verifyExtractedCommitSha(string $stagingDir, string $type): void { $expectedShort = $this->getExpectedShortSha($type); if ($expectedShort === null) { $this->logger->warning('Skipping commit SHA verification (could not get expected SHA)'); return; } $actualShort = $this->getShortShaFromFolderName($stagingDir); if ($actualShort === null) { throw new \RuntimeException( 'Integrity check failed: could not read commit SHA from archive folder name. ' . 'The download may be corrupted or from an unexpected source.' ); } if (strcasecmp($actualShort, $expectedShort) !== 0) { throw new \RuntimeException( "Integrity check failed: archive commit SHA does not match. " . "Expected: {$expectedShort}, got: {$actualShort}. " . "The download may be corrupted or from a different ref." ); } $this->logger->info('Commit SHA verified', [ 'expected' => $expectedShort, 'actual' => $actualShort, 'type' => $type, ]); } /** * Copy files from staging to the application root, respecting protected paths */ private function applyFiles(string $stagingDir): int { $count = 0; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($stagingDir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { $relativePath = str_replace($stagingDir . DIRECTORY_SEPARATOR, '', $item->getPathname()); $relativePath = str_replace('\\', '/', $relativePath); // Normalize for Windows // Skip protected paths if ($this->isProtected($relativePath)) { continue; } $targetPath = $this->rootPath . '/' . $relativePath; if ($item->isDir()) { if (!is_dir($targetPath)) { mkdir($targetPath, 0755, true); } } else { // Ensure parent directory exists $parentDir = dirname($targetPath); if (!is_dir($parentDir)) { mkdir($parentDir, 0755, true); } copy($item->getPathname(), $targetPath); $count++; } } $this->logger->info('Files applied', ['count' => $count]); return $count; } /** * Check if a relative path is protected from overwriting */ private function isProtected(string $relativePath): bool { foreach (self::PROTECTED_PATHS as $protected) { if ($relativePath === $protected || strpos($relativePath, $protected . '/') === 0) { return true; } } return false; } /** * Check if composer.json or composer.lock changed */ private function checkComposerChanged(string $stagingDir): bool { $currentLock = $this->rootPath . '/composer.lock'; $newLock = $stagingDir . '/composer.lock'; if (!file_exists($currentLock) || !file_exists($newLock)) { // If lock file doesn't exist in either place, check composer.json $currentJson = $this->rootPath . '/composer.json'; $newJson = $stagingDir . '/composer.json'; if (file_exists($currentJson) && file_exists($newJson)) { return md5_file($currentJson) !== md5_file($newJson); } return false; } return md5_file($currentLock) !== md5_file($newLock); } /** * Check if PHP is allowed to run shell commands (exec, etc.). * On cPanel / shared hosting, disable_functions often includes exec. */ private function canRunShellCommands(): bool { $disabled = ini_get('disable_functions'); if ($disabled === false || $disabled === '') { return true; } $list = array_map('trim', explode(',', strtolower($disabled))); return !in_array('exec', $list, true); } /** * Run composer install --no-dev. * Returns true on success, false if skipped or failed (caller may set composer_manual_required). */ private function runComposerInstall(): bool { $composerPath = $this->findComposer(); $command = "$composerPath install --no-dev --optimize-autoloader --no-interaction 2>&1"; $this->logger->info('Running composer install', ['command' => $command, 'cwd' => $this->rootPath]); $output = []; $returnCode = 0; $oldCwd = getcwd(); try { if (!@chdir($this->rootPath)) { $this->logger->warning('Could not change to project directory for composer', [ 'path' => $this->rootPath, ]); return false; } exec($command, $output, $returnCode); } finally { @chdir($oldCwd); } $outputStr = implode("\n", $output); if ($returnCode !== 0) { $this->logger->error('Composer install failed (run manually if needed)', [ 'return_code' => $returnCode, 'output' => $outputStr, ]); return false; } $this->logger->info('Composer install completed', ['output' => $outputStr]); return true; } /** * Find the composer executable */ private function findComposer(): string { // Check for local composer.phar first if (file_exists($this->rootPath . '/composer.phar')) { return 'php ' . $this->rootPath . '/composer.phar'; } // Check if composer is in PATH $command = PHP_OS_FAMILY === 'Windows' ? 'where composer 2>NUL' : 'which composer 2>/dev/null'; $output = []; exec($command, $output); if (!empty($output[0])) { return trim($output[0]); } // Fallback return 'composer'; } /** * Find a system binary (e.g. mysqldump, mysql) in common paths */ private function findBinary(string $name): ?string { // Check PATH first $command = PHP_OS_FAMILY === 'Windows' ? "where {$name} 2>NUL" : "which {$name} 2>/dev/null"; $output = []; @exec($command, $output); if (!empty($output[0]) && is_executable(trim($output[0]))) { return trim($output[0]); } // Common locations on Linux/cPanel hosts $commonPaths = [ "/usr/bin/{$name}", "/usr/local/bin/{$name}", "/usr/local/mysql/bin/{$name}", "/opt/cpanel/composer/bin/{$name}", "/usr/sbin/{$name}", ]; foreach ($commonPaths as $path) { if (file_exists($path) && is_executable($path)) { return $path; } } return null; } // ======================================================================== // Private: Backup and rollback methods // ======================================================================== /** * Create a full database backup (.sql file) before updating. * Tries mysqldump first; falls back to a pure-PDO dump of all tables. * Returns ['success' => bool, 'path' => string|null, 'method' => string, 'reason' => string] */ private function createDatabaseBackup(): array { $backupDir = $this->rootPath . '/backups'; if (!is_dir($backupDir)) { mkdir($backupDir, 0775, true); } $host = $_ENV['DB_HOST'] ?? 'localhost'; $port = $_ENV['DB_PORT'] ?? '3306'; $database = $_ENV['DB_DATABASE'] ?? ''; $username = $_ENV['DB_USERNAME'] ?? ''; $password = $_ENV['DB_PASSWORD'] ?? ''; if (empty($database) || empty($username)) { return ['success' => false, 'path' => null, 'method' => 'none', 'reason' => 'Database credentials not available']; } $version = $this->settingModel->getAppVersion(); $timestamp = date('Y-m-d_His'); $sqlFile = $backupDir . "/db_backup_v{$version}_{$timestamp}.sql"; // Try mysqldump first (fastest, most reliable) if ($this->canRunShellCommands()) { $mysqldumpPath = $this->findBinary('mysqldump'); if ($mysqldumpPath) { $cmd = sprintf( '%s --host=%s --port=%s --user=%s --password=%s --single-transaction --routines --triggers --add-drop-table %s > %s 2>&1', escapeshellarg($mysqldumpPath), escapeshellarg($host), escapeshellarg($port), escapeshellarg($username), escapeshellarg($password), escapeshellarg($database), escapeshellarg($sqlFile) ); exec($cmd, $output, $exitCode); if ($exitCode === 0 && file_exists($sqlFile) && filesize($sqlFile) > 0) { return ['success' => true, 'path' => $sqlFile, 'method' => 'mysqldump', 'reason' => '']; } // mysqldump failed, clean up and fall through to PDO if (file_exists($sqlFile)) { @unlink($sqlFile); } } } // Fallback: pure PDO dump (works on cPanel/shared hosts without exec) try { $pdo = \Core\Database::getConnection(); $handle = fopen($sqlFile, 'w'); if (!$handle) { return ['success' => false, 'path' => null, 'method' => 'pdo', 'reason' => 'Could not create SQL file']; } fwrite($handle, "-- Domain Monitor Database Backup\n"); fwrite($handle, "-- Date: " . date('Y-m-d H:i:s') . "\n"); fwrite($handle, "-- Database: {$database}\n"); fwrite($handle, "-- Method: PDO dump (fallback)\n\n"); fwrite($handle, "SET FOREIGN_KEY_CHECKS=0;\n\n"); // Get all tables $tables = $pdo->query("SHOW TABLES")->fetchAll(\PDO::FETCH_COLUMN); foreach ($tables as $table) { // DROP + CREATE fwrite($handle, "DROP TABLE IF EXISTS `{$table}`;\n"); $createStmt = $pdo->query("SHOW CREATE TABLE `{$table}`")->fetch(\PDO::FETCH_ASSOC); fwrite($handle, $createStmt['Create Table'] . ";\n\n"); // Dump rows in batches $count = (int)$pdo->query("SELECT COUNT(*) FROM `{$table}`")->fetchColumn(); $batchSize = 500; for ($offset = 0; $offset < $count; $offset += $batchSize) { $rows = $pdo->query("SELECT * FROM `{$table}` LIMIT {$batchSize} OFFSET {$offset}")->fetchAll(\PDO::FETCH_ASSOC); foreach ($rows as $row) { $values = array_map(function ($val) use ($pdo) { if ($val === null) return 'NULL'; return $pdo->quote($val); }, $row); $cols = '`' . implode('`, `', array_keys($row)) . '`'; fwrite($handle, "INSERT INTO `{$table}` ({$cols}) VALUES (" . implode(', ', $values) . ");\n"); } } fwrite($handle, "\n"); } fwrite($handle, "SET FOREIGN_KEY_CHECKS=1;\n"); fclose($handle); if (filesize($sqlFile) > 0) { return ['success' => true, 'path' => $sqlFile, 'method' => 'pdo', 'reason' => '']; } @unlink($sqlFile); return ['success' => false, 'path' => null, 'method' => 'pdo', 'reason' => 'PDO dump produced empty file']; } catch (\Exception $e) { if (isset($handle) && is_resource($handle)) { fclose($handle); } if (file_exists($sqlFile)) { @unlink($sqlFile); } return ['success' => false, 'path' => null, 'method' => 'pdo', 'reason' => 'PDO dump failed: ' . $e->getMessage()]; } } /** * Restore a database backup from a .sql file (used during rollback) */ private function restoreDatabaseBackup(string $sqlFile): bool { if (!file_exists($sqlFile)) { $this->logger->warning('Database backup file not found for restore', ['path' => $sqlFile]); return false; } // Try mysql CLI first if ($this->canRunShellCommands()) { $mysqlPath = $this->findBinary('mysql'); if ($mysqlPath) { $host = $_ENV['DB_HOST'] ?? 'localhost'; $port = $_ENV['DB_PORT'] ?? '3306'; $database = $_ENV['DB_DATABASE'] ?? ''; $username = $_ENV['DB_USERNAME'] ?? ''; $password = $_ENV['DB_PASSWORD'] ?? ''; $cmd = sprintf( '%s --host=%s --port=%s --user=%s --password=%s %s < %s 2>&1', escapeshellarg($mysqlPath), escapeshellarg($host), escapeshellarg($port), escapeshellarg($username), escapeshellarg($password), escapeshellarg($database), escapeshellarg($sqlFile) ); exec($cmd, $output, $exitCode); if ($exitCode === 0) { $this->logger->info('Database restored via mysql CLI', ['path' => $sqlFile]); return true; } } } // Fallback: execute SQL via PDO (statement by statement) try { $pdo = \Core\Database::getConnection(); $sql = file_get_contents($sqlFile); if (empty($sql)) { return false; } $pdo->exec("SET FOREIGN_KEY_CHECKS=0"); // Split on semicolons followed by newline (to avoid breaking on values containing semicolons) $statements = preg_split('/;\s*\n/', $sql); foreach ($statements as $stmt) { $stmt = trim($stmt); if (empty($stmt) || strpos($stmt, '--') === 0) continue; $pdo->exec($stmt); } $pdo->exec("SET FOREIGN_KEY_CHECKS=1"); $this->logger->info('Database restored via PDO', ['path' => $sqlFile]); return true; } catch (\Exception $e) { $this->logger->error('Database restore via PDO failed', ['error' => $e->getMessage()]); return false; } } /** * Create a zip backup of current application files */ private function createBackup(): string { $backupDir = $this->rootPath . '/backups'; if (!is_dir($backupDir)) { mkdir($backupDir, 0775, true); } // Ensure the directory is writable if (!is_writable($backupDir)) { @chmod($backupDir, 0775); } // ZipArchive::close() writes a temp file; point TMPDIR to a writable location // so it works on hosts where the system /tmp is not writable by the web user $originalTmpDir = getenv('TMPDIR') ?: null; putenv('TMPDIR=' . $backupDir); $version = $this->settingModel->getAppVersion(); $timestamp = date('Y-m-d_His'); $backupFile = $backupDir . "/backup_v{$version}_{$timestamp}.zip"; $zip = new \ZipArchive(); if ($zip->open($backupFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { // Restore TMPDIR before throwing if ($originalTmpDir !== null) { putenv('TMPDIR=' . $originalTmpDir); } else { putenv('TMPDIR'); } throw new \RuntimeException("Failed to create backup archive: $backupFile"); } // Back up key application directories $dirsToBackup = ['app', 'core', 'public', 'database', 'routes', 'cron']; $filesToBackup = ['composer.json', 'composer.lock']; foreach ($dirsToBackup as $dir) { $fullDir = $this->rootPath . '/' . $dir; if (is_dir($fullDir)) { $this->addDirectoryToZip($zip, $fullDir, $dir); } } foreach ($filesToBackup as $file) { $fullFile = $this->rootPath . '/' . $file; if (file_exists($fullFile)) { $zip->addFile($fullFile, $file); } } $zip->close(); // Restore original TMPDIR if ($originalTmpDir !== null) { putenv('TMPDIR=' . $originalTmpDir); } else { putenv('TMPDIR'); } $this->logger->info('Backup created', [ 'path' => $backupFile, 'size_bytes' => filesize($backupFile), ]); return $backupFile; } /** * Recursively add a directory to a zip archive */ private function addDirectoryToZip(\ZipArchive $zip, string $dir, string $zipPath): void { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $item) { $relativePath = $zipPath . '/' . str_replace( [$dir . DIRECTORY_SEPARATOR, $dir . '/'], '', $item->getPathname() ); $relativePath = str_replace('\\', '/', $relativePath); // Zip entries use forward slashes if ($item->isDir()) { $zip->addEmptyDir($relativePath); } else { $zip->addFile($item->getPathname(), $relativePath); } } } /** * Restore from a backup zip archive */ private function restoreBackup(string $backupPath): void { if (!file_exists($backupPath)) { throw new \RuntimeException("Backup file not found: $backupPath"); } $zip = new \ZipArchive(); if ($zip->open($backupPath) !== true) { throw new \RuntimeException("Failed to open backup archive: $backupPath"); } $zip->extractTo($this->rootPath); $zip->close(); $this->logger->info('Backup restored', ['path' => $backupPath]); } // ======================================================================== // Private: Utility methods // ======================================================================== /** * Update the stored commit SHA to the latest */ private function updateCommitSha(): void { $latestSha = $this->fetchLatestCommitSha(); if ($latestSha) { $this->settingModel->setValue('installed_commit_sha', $latestSha); $this->logger->info('Updated installed commit SHA', [ 'sha' => substr($latestSha, 0, 7), ]); } } /** * Clean up temporary files */ private function cleanup(string $archivePath, string $stagingDir): void { // Remove archive if (file_exists($archivePath)) { unlink($archivePath); } // Remove staging directory if (is_dir($stagingDir)) { $this->removeDirectory($stagingDir); } // Also clean parent staging dir if it exists $parentStaging = dirname($stagingDir); if (strpos(basename($parentStaging), 'dm_staging_') === 0 && is_dir($parentStaging)) { $this->removeDirectory($parentStaging); } } /** * Recursively remove a directory */ private function removeDirectory(string $dir): void { if (!is_dir($dir)) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ($items as $item) { if ($item->isDir()) { rmdir($item->getPathname()); } else { unlink($item->getPathname()); } } rmdir($dir); } /** * Get update channel label for display */ public static function getChannelLabel(string $channel): string { return match ($channel) { 'stable' => 'Stable (Releases only)', 'latest' => 'Latest (Releases + hotfixes)', default => ucfirst($channel), }; } /** * Check if there are pending database migrations */ public function hasPendingMigrations(): bool { try { $pdo = \Core\Database::getConnection(); // Check if migrations table exists try { $stmt = $pdo->query("SELECT COUNT(*) FROM migrations"); } catch (\Exception $e) { return false; } $executed = []; $stmt = $pdo->query("SELECT migration FROM migrations"); $executed = $stmt->fetchAll(\PDO::FETCH_COLUMN); // Scan migrations directory $migrationsDir = $this->rootPath . '/database/migrations'; $files = glob($migrationsDir . '/*.sql'); $allMigrations = array_map('basename', $files); // Filter out the consolidated schema $allMigrations = array_filter($allMigrations, function ($m) { return strpos($m, '000_') !== 0; }); $pending = array_diff($allMigrations, $executed); return !empty($pending); } catch (\Exception $e) { return false; } } }