diff --git a/composer.json b/composer.json index ac55acfb57..d41060ccd8 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "geoip2/geoip2": "^3.0", "jenssegers/agent": "^2.6", "php-di/php-di": "^7.0", - "twig/twig": "^3.0" + "twig/twig": "^3.0", + "symfony/process": "^7.2" }, "require-dev": { "phpstan/phpstan": "1.6.9", diff --git a/core/classes/Queue/Task.php b/core/classes/Queue/Task.php index b89fa8b871..c5b69e6c97 100644 --- a/core/classes/Queue/Task.php +++ b/core/classes/Queue/Task.php @@ -236,7 +236,11 @@ public function getData(): array */ public function setOutput(array $output = []) { - $this->_output = $output; + if (isset($this->_output)) { + $this->_output = array_merge($this->_output, $output); + } else { + $this->_output = $output; + } } /** diff --git a/core/includes/updates/230.php b/core/includes/updates/230.php deleted file mode 100644 index b36c2309db..0000000000 --- a/core/includes/updates/230.php +++ /dev/null @@ -1,71 +0,0 @@ -runMigrations(); - - ConvertProfilePosts::schedule(); - - // Convert templatecache to use settings table - $this->_cache->setCache('templatecache'); - $default_template = $this->_cache->retrieve('default') ?: 'DefaultRevamp'; - $default_panel_template = $this->_cache->retrieve('panel_default') ?: 'Default'; - Settings::set('default_template', $default_template); - Settings::set('default_panel_template', $default_panel_template); - $this->_cache->eraseAll(); - - // Convert template_settings to use settings table - $this->_cache->setCache('template_settings'); - $darkMode = $this->_cache->retrieve('darkMode') ?: '0'; - $navbarColour = $this->_cache->retrieve('navbarColour') ?: 'white'; - Settings::set('dark_mode', $darkMode); - Settings::set('default_revamp_navbar_color', $navbarColour); - $this->_cache->eraseAll(); - - // Convert backgroundcache to use settings table - $this->_cache->setCache('backgroundcache'); - $logo_image = $this->_cache->retrieve('logo_image') ?: ''; - $banner_image = $this->_cache->retrieve('banner_image') ?: ''; - $og_image = $this->_cache->retrieve('og_image') ?: ''; - $favicon_image = $this->_cache->retrieve('favicon_image') ?: ''; - Settings::set('logo_image_path', $logo_image); - Settings::set('banner_image_path', $banner_image); - Settings::set('og_image_path', $og_image); - Settings::set('favicon_image_path', $favicon_image); - $this->_cache->eraseAll(); - - // Convert avatar_settings_cache to use settings table - $this->_cache->setCache('avatar_settings_cache'); - $custom_avatars = $this->_cache->retrieve('custom_avatars') ?? false; - $default_avatar_type = $this->_cache->retrieve('default_avatar_type') ?: 'minecraft'; - $default_avatar_image = $this->_cache->retrieve('default_avatar_image') ?: ''; - $default_avatar_source = $this->_cache->retrieve('avatar_source') ?: 'cravatar'; - if ($default_avatar_source === 'Nameless') { - $default_avatar_source = 'cravatar'; - } - $default_avatar_perspective = $this->_cache->retrieve('avatar_perspective') ?: 'face'; - Settings::set('custom_avatars', $custom_avatars); - Settings::set('default_avatar_type', $default_avatar_type); - Settings::set('default_avatar_image', $default_avatar_image); - Settings::set('default_avatar_source', $default_avatar_source); - Settings::set('default_avatar_perspective', $default_avatar_perspective); - $this->_cache->eraseAll(); - - // Convert OnlineUsersWidget to use settings table - $this->_cache->setCache('online_members'); - $use_nickname_show = $this->_cache->fetch('show_nickname_instead', 0); - $include_staff = $this->_cache->fetch('include_staff_in_users', 0); - Settings::set('online_users_widget_use_nicknames', $use_nickname_show); - Settings::set('online_users_widget_include_staff', $include_staff); - $this->_cache->eraseAll(); - - // Convert social_media to use settings table - $this->_cache->setCache('social_media'); - $discord_widget_theme = $this->_cache->retrieve('discord_widget_theme') ?: 'dark'; - Settings::set('discord_widget_theme', $discord_widget_theme, 'Discord Integration'); - $this->_cache->eraseAll(); - - $this->setVersion('2.3.0'); - } -}; diff --git a/core/migrations/20250614172305_convert_cache_uses_to_settings.php b/core/migrations/20250614172305_convert_cache_uses_to_settings.php new file mode 100644 index 0000000000..20b985171e --- /dev/null +++ b/core/migrations/20250614172305_convert_cache_uses_to_settings.php @@ -0,0 +1,81 @@ + 'nameless', + 'extension' => '.cache', + 'path' => ROOT_PATH . '/cache/' + ]); + + // Convert templatecache to use settings table + $cache->setCache('templatecache'); + $default_template = $cache->fetch('default', 'DefaultRevamp'); + $default_panel_template = $cache->fetch('panel_default', 'Default'); + Settings::set('default_template', $default_template); + Settings::set('default_panel_template', $default_panel_template); + + // Convert template_settings to use settings table + $cache->setCache('template_settings'); + $darkMode = $cache->fetch('darkMode', '0'); + $navbarColour = $cache->fetch('navbarColour', 'white'); + Settings::set('dark_mode', $darkMode); + Settings::set('default_revamp_navbar_color', $navbarColour); + + // Convert backgroundcache to use settings table + $cache->setCache('backgroundcache'); + $logo_image = $cache->fetch('logo_image', ''); + $banner_image = $cache->fetch('banner_image', ''); + $og_image = $cache->fetch('og_image', ''); + $favicon_image = $cache->fetch('favicon_image', ''); + Settings::set('logo_image_path', $logo_image); + Settings::set('banner_image_path', $banner_image); + Settings::set('og_image_path', $og_image); + Settings::set('favicon_image_path', $favicon_image); + + // Convert avatar_settings_cache to use settings table + $cache->setCache('avatar_settings_cache'); + $custom_avatars = $cache->fetch('custom_avatars', '0'); + $default_avatar_type = $cache->fetch('default_avatar_type', 'minecraft'); + $default_avatar_image = $cache->fetch('default_avatar_image', ''); + $default_avatar_source = $cache->fetch('avatar_source', 'cravatar'); + if ($default_avatar_source === 'Nameless') { + $default_avatar_source = 'cravatar'; + } + $default_avatar_perspective = $cache->fetch('avatar_perspective', 'face'); + Settings::set('custom_avatars', $custom_avatars); + Settings::set('default_avatar_type', $default_avatar_type); + Settings::set('default_avatar_image', $default_avatar_image); + Settings::set('default_avatar_source', $default_avatar_source); + Settings::set('default_avatar_perspective', $default_avatar_perspective); + + // Convert OnlineUsersWidget to use settings table + $cache->setCache('online_members'); + $use_nickname_show = $cache->fetch('show_nickname_instead', '0'); + $include_staff = $cache->fetch('include_staff_in_users', '0'); + Settings::set('online_users_widget_use_nicknames', $use_nickname_show); + Settings::set('online_users_widget_include_staff', $include_staff); + + // Convert social_media to use settings table + $cache->setCache('social_media'); + $discord_widget_theme = $cache->fetch('discord_widget_theme', 'dark'); + Settings::set('discord_widget_theme', $discord_widget_theme, 'Discord Integration'); + } +} diff --git a/core/migrations/20250614172349_schedule_convert_profile_posts_task.php b/core/migrations/20250614172349_schedule_convert_profile_posts_task.php new file mode 100644 index 0000000000..9ea01d6f04 --- /dev/null +++ b/core/migrations/20250614172349_schedule_convert_profile_posts_task.php @@ -0,0 +1,24 @@ +{$INSTRUCTIONS}

{$INSTRUCTIONS_VALUE}


- {$DOWNLOAD} {elseif isset($UPDATE_CHECK_ERROR)}
diff --git a/modules/Core/classes/Tasks/Upgrade.php b/modules/Core/classes/Tasks/Upgrade.php new file mode 100644 index 0000000000..6e62089cb8 --- /dev/null +++ b/modules/Core/classes/Tasks/Upgrade.php @@ -0,0 +1,181 @@ +acquireLock(); + + Settings::set('maintenance', '1'); + + $updateCheck = $this->validateUpdateAvailable(); + + $upgradeZipPath = $this->downloadUpgradePackage($updateCheck); + + $this->extractUpgradePackage($upgradeZipPath); + + $this->executeMigrations(); + + Settings::set('nameless_version', $updateCheck->versionTag()); + Settings::set('version_update', null); + + $cache = $this->_container->get(Cache::class); + $cache->setCache('update_check'); + $cache->store('update_check', null); + } catch (Exception $e) { + $this->setOutput(['error' => $e->getMessage()]); + + return Task::STATUS_FAILED; + } finally { + // Ensure the lock is released even if an error occurs + $this->releaseLock(); + + Settings::set('maintenance', '0'); + } + + return Task::STATUS_COMPLETED; + } + + private function acquireLock(): void + { + $lockFile = ROOT_PATH . '/cache/upgrade.lock'; + if (file_exists($lockFile)) { + throw new Exception('Upgrade is already running'); + } + + if (!file_put_contents($lockFile, 'locked')) { + throw new Exception('Failed to create lock file'); + } + } + + private function releaseLock(): void + { + $lockFile = ROOT_PATH . '/cache/upgrade.lock'; + if (file_exists($lockFile)) { + unlink($lockFile); + } + } + + private function validateUpdateAvailable(): UpdateCheck + { + $cache = $this->_container->get(Cache::class); + + $cache->setCache('update_check'); + $updateCheck = $cache->retrieve('update_check'); + + if (!$updateCheck) { + throw new Exception('No update found'); + } + + $this->setOutput(['update_check' => "Found update: {$updateCheck->versionTag()}"]); + return $updateCheck; + } + + private function downloadUpgradePackage(UpdateCheck $updateCheck): string + { + $upgradeZipPath = $this->getTempDirectory() . "/namelessmc-upgrade-{$updateCheck->versionTag()}.zip"; + + $downloadResponse = HttpClient::get($updateCheck->upgradeZipLink(), [ + 'sink' => $upgradeZipPath, + 'timeout' => self::DOWNLOAD_TIMEOUT, + ]); + + if ($downloadResponse->hasError()) { + throw new Exception("Error downloading upgrade zip: {$downloadResponse->getError()}"); + } + + $this->setOutput([ + 'zip_download' => "Downloaded upgrade zip to: {$upgradeZipPath}" + ]); + + $this->verifyChecksum($upgradeZipPath, $updateCheck); + + return $upgradeZipPath; + } + + public function extractUpgradePackage(string $upgradeZipPath): void + { + $zip = new ZipArchive(); + if ($zip->open($upgradeZipPath) !== true) { + throw new Exception("Failed to open upgrade zip: {$upgradeZipPath}"); + } + + if (!$zip->extractTo(ROOT_PATH)) { + throw new Exception("Failed to extract upgrade zip: {$upgradeZipPath}"); + } + + $zip->close(); + + // Remove the zip file after extraction + unlink($upgradeZipPath); + + $this->setOutput(['zip_extract' => 'Upgrade package extracted successfully']); + } + + private function verifyChecksum(string $upgradeZipPath, UpdateCheck $updateCheck): void + { + $expectedChecksum = $updateCheck->checksum(); + + // TODO: Remove before merging + if (empty($expectedChecksum)) { + $this->setOutput([ + 'checksum_verify' => 'No checksum provided for verification, skipping checksum check' + ]); + return; + } + + $actualChecksum = hash_file('sha256', $upgradeZipPath); + if ($actualChecksum === false) { + throw new Exception("Failed to calculate checksum for file: {$upgradeZipPath}"); + } + + if (hash_equals($expectedChecksum, $actualChecksum)) { + $this->setOutput([ + 'checksum_verify' => "Checksum verification passed (SHA256: {$actualChecksum})" + ]); + return; + } + + // Remove the corrupted file + unlink($upgradeZipPath); + + throw new Exception("Checksum verification failed: expected {$expectedChecksum}, got {$actualChecksum}"); + } + + private function executeMigrations(): void + { + $phinxWrapper = new Phinx\Wrapper\TextWrapper( + new Phinx\Console\PhinxApplication(), + [ + 'configuration' => ROOT_PATH . '/core/migrations/phinx.php', + ] + ); + + $output = $phinxWrapper->getMigrate(); + + if ($phinxWrapper->getExitCode() !== 0) { + throw new Exception("Migrations failed: {$output}"); + } + + $this->setOutput([ + 'migrations' => $output, + ]); + } + + private function getTempDirectory(): string + { + $tmpDir = ini_get('upload_tmp_dir'); + return $tmpDir ?: sys_get_temp_dir(); + } +} diff --git a/modules/Core/language/en_UK.json b/modules/Core/language/en_UK.json index ad7e579eaf..866c2daf29 100644 --- a/modules/Core/language/en_UK.json +++ b/modules/Core/language/en_UK.json @@ -313,7 +313,7 @@ "admin/include_staff_in_user_widget": "Include staff members in user widget?", "admin/ingame_group_maximum": "Please ensure your group name is a maximum of 64 characters long.", "admin/install": "Install", - "admin/install_confirm": "Please ensure you have downloaded the package and uploaded the contained files first!", + "admin/install_confirm": "This will enable redirect you to a page to view the progress of the update. Are you sure you want to continue?", "admin/install_language": "Install Language", "admin/installed_languages": "Any new languages have been installed successfully.", "admin/instructions": "Instructions", diff --git a/modules/Core/pages/panel/update.php b/modules/Core/pages/panel/update.php index 8bdd6a78b5..eaa52f0a3a 100644 --- a/modules/Core/pages/panel/update.php +++ b/modules/Core/pages/panel/update.php @@ -74,8 +74,6 @@ 'INSTRUCTIONS' => $language->get('admin', 'instructions'), 'INSTRUCTIONS_VALUE' => Output::getDecoded($update_check->instructions()), 'UPGRADE_LINK' => URL::build('/panel/upgrade'), - 'DOWNLOAD_LINK' => $update_check->upgradeZipLink(), - 'DOWNLOAD' => $language->get('admin', 'download'), 'INSTALL_CONFIRM' => $language->get('admin', 'install_confirm'), ]); } diff --git a/modules/Core/pages/panel/upgrade.php b/modules/Core/pages/panel/upgrade.php index adff3fbaf8..146db9a4df 100644 --- a/modules/Core/pages/panel/upgrade.php +++ b/modules/Core/pages/panel/upgrade.php @@ -16,21 +16,14 @@ Redirect::to(URL::build('/panel/update')); } -$cache = new Cache(['name' => 'nameless', 'extension' => '.cache', 'path' => ROOT_PATH . '/cache/']); +// Enqueue the update +$task = (new Upgrade())->fromNew( + Module::getIdFromName('Core'), + 'Upgrade NamelessMC', + null, + date('U'), +); -$version = DB::getInstance()->query('SELECT `value` FROM nl2_settings WHERE `name` = \'nameless_version\'')->first(); +Queue::schedule($task); -if ($version) { - // Perform the update - $upgradeScript = UpgradeScript::get($version->value); - if ($upgradeScript instanceof UpgradeScript) { - $upgradeScript->run(); - } - - $cache->setCache('update_check'); - if ($cache->isCached('update_check')) { - $cache->erase('update_check'); - } -} - -Redirect::to(URL::build('/panel/update')); +Redirect::to(URL::build('/panel/core/queue/&view=task&id=' . DB::getInstance()->lastId()));