1241 lines
45 KiB
PHP
1241 lines
45 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services;
|
||
|
|
|
||
|
|
use App\Models\Setting;
|
||
|
|
use GuzzleHttp\Client;
|
||
|
|
|
||
|
|
class UpdateService
|
||
|
|
{
|
||
|
|
private const GITHUB_REPO = 'Hosteroid/domain-monitor';
|
||
|
|
private const GITHUB_API_BASE = 'https://api.github.com';
|
||
|
|
private const CACHE_TTL_HOURS = 1;
|
||
|
|
|
||
|
|
private const PROTECTED_PATHS = [
|
||
|
|
'.env',
|
||
|
|
'.installed',
|
||
|
|
'vendor',
|
||
|
|
'logs',
|
||
|
|
'storage',
|
||
|
|
'domain-monitor-docker',
|
||
|
|
'.git',
|
||
|
|
'.gitignore',
|
||
|
|
'node_modules',
|
||
|
|
];
|
||
|
|
|
||
|
|
private Setting $settingModel;
|
||
|
|
private Logger $logger;
|
||
|
|
private Client $httpClient;
|
||
|
|
private string $rootPath;
|
||
|
|
|
||
|
|
public function __construct()
|
||
|
|
{
|
||
|
|
$this->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));
|
||
|
|
}
|
||
|
|
} 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');
|
||
|
|
|
||
|
|
$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
|
||
|
|
// ========================================================================
|
||
|
|
|
||
|
|
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-<short_sha>" (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;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|