diff --git a/_build/data/transport.core.accesspolicies.php b/_build/data/transport.core.accesspolicies.php index f63910e0fa3..3ca49411890 100644 --- a/_build/data/transport.core.accesspolicies.php +++ b/_build/data/transport.core.accesspolicies.php @@ -23,7 +23,7 @@ function jsonifyPermissions(array $permissions = []) { $corePermissions = [ modAccessPolicy::POLICY_RESOURCE => ['add_children', 'create', 'copy', 'delete', 'list', 'load', 'move', 'publish', 'remove', 'save', 'steal_lock', 'undelete', 'unpublish', 'view'], - modAccessPolicy::POLICY_ADMINISTRATOR => ['about', 'access_permissions', 'actions', 'change_password', 'change_profile', 'charsets', 'class_map', 'components', 'content_types', 'countries', 'create', 'credits', 'customize_forms', 'dashboards', 'database', 'database_truncate', 'delete_category', 'delete_chunk', 'delete_context', 'delete_document', 'delete_eventlog', 'delete_plugin', 'delete_propertyset', 'delete_role', 'delete_snippet', 'delete_static_resource', 'delete_symlink', 'delete_template', 'delete_tv', 'delete_user', 'delete_weblink', 'directory_chmod', 'directory_create', 'directory_list', 'directory_remove', 'directory_update', 'edit_category', 'edit_chunk', 'edit_context', 'edit_document', 'edit_locked', 'edit_plugin', 'edit_propertyset', 'edit_role', 'edit_snippet', 'edit_static_resource', 'edit_symlink', 'edit_template', 'edit_tv', 'edit_user', 'edit_weblink', 'element_tree', 'empty_cache', 'error_log_erase', 'error_log_view', 'events', 'export_static', 'file_create', 'file_list', 'file_manager', 'file_remove', 'file_tree', 'file_unpack', 'file_update', 'file_upload', 'file_view', 'flush_sessions', 'frames', 'help', 'home', 'language', 'languages', 'lexicons', 'list', 'load', 'logout', 'mgr_log_view', 'mgr_log_erase', 'menu_reports', 'menu_security', 'menu_site', 'menu_support', 'menu_system', 'menu_tools', 'menu_trash', 'menu_user', 'menus', 'messages', 'namespaces', 'new_category', 'new_chunk', 'new_context', 'new_document', 'new_document_in_root', 'new_plugin', 'new_propertyset', 'new_role', 'new_snippet', 'new_static_resource', 'new_symlink', 'new_template', 'new_tv', 'new_user', 'new_weblink', 'packages', 'policy_delete', 'policy_edit', 'policy_new', 'policy_save', 'policy_template_delete', 'policy_template_edit', 'policy_template_new', 'policy_template_save', 'policy_template_view', 'policy_view', 'property_sets', 'providers', 'publish_document', 'purge_deleted', 'remove', 'remove_locks', 'resource_duplicate', 'resource_quick_create', 'resource_quick_update', 'resource_tree', 'resourcegroup_delete', 'resourcegroup_edit', 'resourcegroup_new', 'resourcegroup_resource_edit', 'resourcegroup_resource_list', 'resourcegroup_save', 'resourcegroup_view', 'save', 'save_category', 'save_chunk', 'save_context', 'save_document', 'save_plugin', 'save_propertyset', 'save_role', 'save_snippet', 'save_template', 'save_tv', 'save_user', 'search', 'set_sudo', 'settings', 'source_delete', 'source_edit', 'source_save', 'source_view', 'sources', 'steal_locks', 'tree_show_element_ids', 'tree_show_resource_ids', 'undelete_document', 'unlock_element_properties', 'unpublish_document', 'usergroup_delete', 'usergroup_edit', 'usergroup_new', 'usergroup_save', 'usergroup_user_edit', 'usergroup_user_list', 'usergroup_view', 'view', 'view_category', 'view_chunk', 'view_context', 'view_document', 'view_element', 'view_eventlog', 'view_offline', 'view_plugin', 'view_propertyset', 'view_role', 'view_snippet', 'view_sysinfo', 'view_template', 'view_tv', 'view_unpublished', 'view_user', 'workspaces'], + modAccessPolicy::POLICY_ADMINISTRATOR => ['about', 'access_permissions', 'actions', 'change_password', 'change_profile', 'charsets', 'class_map', 'components', 'content_types', 'countries', 'create', 'credits', 'customize_forms', 'dashboards', 'database', 'database_truncate', 'delete_category', 'delete_chunk', 'delete_context', 'delete_document', 'delete_eventlog', 'delete_plugin', 'delete_propertyset', 'delete_role', 'delete_snippet', 'delete_static_resource', 'delete_symlink', 'delete_template', 'delete_tv', 'delete_user', 'delete_weblink', 'directory_chmod', 'directory_create', 'directory_list', 'directory_remove', 'directory_update', 'edit_category', 'edit_chunk', 'edit_context', 'edit_document', 'edit_locked', 'edit_plugin', 'edit_propertyset', 'edit_role', 'edit_snippet', 'edit_static_resource', 'edit_symlink', 'edit_template', 'edit_tv', 'edit_user', 'edit_weblink', 'element_tree', 'empty_cache', 'error_log_erase', 'error_log_view', 'events', 'export_static', 'file_create', 'file_list', 'file_manager', 'file_remove', 'file_tree', 'file_unpack', 'file_update', 'file_upload', 'file_view', 'flush_sessions', 'frames', 'help', 'home', 'language', 'languages', 'lexicons', 'list', 'load', 'logout', 'mgr_log_view', 'mgr_log_erase', 'menu_reports', 'menu_security', 'menu_site', 'menu_support', 'menu_system', 'menu_tools', 'menu_trash', 'menu_user', 'menus', 'messages', 'namespaces', 'new_category', 'new_chunk', 'new_context', 'new_document', 'new_document_in_root', 'new_plugin', 'new_propertyset', 'new_role', 'new_snippet', 'new_static_resource', 'new_symlink', 'new_template', 'new_tv', 'new_user', 'new_weblink', 'packages', 'policy_delete', 'policy_edit', 'policy_new', 'policy_save', 'policy_template_delete', 'policy_template_edit', 'policy_template_new', 'policy_template_save', 'policy_template_view', 'policy_view', 'property_sets', 'providers', 'publish_document', 'purge_deleted', 'remove', 'remove_locks', 'resource_duplicate', 'resource_quick_create', 'resource_quick_update', 'resource_tree', 'resourcegroup_delete', 'resourcegroup_edit', 'resourcegroup_new', 'resourcegroup_resource_edit', 'resourcegroup_resource_list', 'resourcegroup_save', 'resourcegroup_view', 'save', 'save_category', 'save_chunk', 'save_context', 'save_document', 'save_plugin', 'save_propertyset', 'save_role', 'save_snippet', 'save_template', 'save_tv', 'save_user', 'search', 'set_sudo', 'settings', 'upgrade_core', 'source_delete', 'source_edit', 'source_save', 'source_view', 'sources', 'steal_locks', 'tree_show_element_ids', 'tree_show_resource_ids', 'undelete_document', 'unlock_element_properties', 'unpublish_document', 'usergroup_delete', 'usergroup_edit', 'usergroup_new', 'usergroup_save', 'usergroup_user_edit', 'usergroup_user_list', 'usergroup_view', 'view', 'view_category', 'view_chunk', 'view_context', 'view_document', 'view_element', 'view_eventlog', 'view_offline', 'view_plugin', 'view_propertyset', 'view_role', 'view_snippet', 'view_sysinfo', 'view_template', 'view_tv', 'view_unpublished', 'view_user', 'workspaces'], modAccessPolicy::POLICY_LOAD_ONLY => ['load'], modAccessPolicy::POLICY_LOAD_LIST_VIEW => ['load', 'list', 'view'], modAccessPolicy::POLICY_OBJECT => ['load', 'list', 'view', 'save', 'remove'], @@ -31,7 +31,7 @@ function jsonifyPermissions(array $permissions = []) { modAccessPolicy::POLICY_CONTENT_EDITOR => ['change_profile', 'class_map', 'countries', 'delete_document', 'delete_static_resource', 'delete_symlink', 'delete_weblink', 'edit_document', 'edit_static_resource', 'edit_symlink', 'edit_weblink', 'frames', 'help', 'home', 'language', 'list', 'load', 'logout', 'menu_reports', 'menu_site', 'menu_support', 'menu_tools', 'menu_user', 'new_document', 'new_static_resource', 'new_symlink', 'new_weblink', 'resource_duplicate', 'resource_tree', 'save_document', 'source_view', 'tree_show_resource_ids', 'view', 'view_document', 'view_template'], modAccessPolicy::POLICY_MEDIA_SOURCE_ADMIN => ['create', 'copy', 'load', 'list', 'save', 'remove', 'view'], modAccessPolicy::POLICY_MEDIA_SOURCE_USER => ['load', 'list', 'view'], - modAccessPolicy::POLICY_DEVELOPER => ['about', 'change_password', 'change_profile', 'charsets', 'class_map', 'components', 'content_types', 'countries', 'create', 'credits', 'customize_forms', 'dashboards', 'database', 'delete_category', 'delete_chunk', 'delete_context', 'delete_document', 'delete_eventlog', 'delete_plugin', 'delete_propertyset', 'delete_role', 'delete_snippet', 'delete_template', 'delete_tv', 'delete_user', 'directory_chmod', 'directory_create', 'directory_list', 'directory_remove', 'directory_update', 'edit_category', 'edit_chunk', 'edit_context', 'edit_document', 'edit_locked', 'edit_plugin', 'edit_propertyset', 'edit_role', 'edit_snippet', 'edit_static_resource', 'edit_symlink', 'edit_template', 'edit_tv', 'edit_user', 'edit_weblink', 'element_tree', 'empty_cache', 'error_log_erase', 'error_log_view', 'export_static', 'file_create', 'file_list', 'file_manager', 'file_remove', 'file_tree', 'file_unpack', 'file_update', 'file_upload', 'file_view', 'frames', 'help', 'home', 'language', 'languages', 'lexicons', 'list', 'load', 'logout', 'mgr_log_view', 'mgr_log_erase', 'menu_reports', 'menu_site', 'menu_support', 'menu_system', 'menu_tools', 'menu_user', 'menus', 'messages', 'namespaces', 'new_category', 'new_chunk', 'new_context', 'new_document', 'new_document_in_root', 'new_plugin', 'new_propertyset', 'new_role', 'new_snippet', 'new_static_resource', 'new_symlink', 'new_template', 'new_tv', 'new_user', 'new_weblink', 'packages', 'property_sets', 'providers', 'publish_document', 'purge_deleted', 'remove', 'resource_duplicate', 'resource_quick_create', 'resource_quick_update', 'resource_tree', 'save', 'save_category', 'save_chunk', 'save_context', 'save_document', 'save_plugin', 'save_propertyset', 'save_snippet', 'save_template', 'save_tv', 'save_user', 'search', 'settings', 'source_delete', 'source_edit', 'source_save', 'source_view', 'sources', 'tree_show_element_ids', 'tree_show_resource_ids', 'undelete_document', 'unlock_element_properties', 'unpublish_document', 'view', 'view_category', 'view_chunk', 'view_context', 'view_document', 'view_element', 'view_eventlog', 'view_offline', 'view_plugin', 'view_propertyset', 'view_role', 'view_snippet', 'view_sysinfo', 'view_template', 'view_tv', 'view_unpublished', 'view_user', 'workspaces'], + modAccessPolicy::POLICY_DEVELOPER => ['about', 'change_password', 'change_profile', 'charsets', 'class_map', 'components', 'content_types', 'countries', 'create', 'credits', 'customize_forms', 'dashboards', 'database', 'delete_category', 'delete_chunk', 'delete_context', 'delete_document', 'delete_eventlog', 'delete_plugin', 'delete_propertyset', 'delete_role', 'delete_snippet', 'delete_template', 'delete_tv', 'delete_user', 'directory_chmod', 'directory_create', 'directory_list', 'directory_remove', 'directory_update', 'edit_category', 'edit_chunk', 'edit_context', 'edit_document', 'edit_locked', 'edit_plugin', 'edit_propertyset', 'edit_role', 'edit_snippet', 'edit_static_resource', 'edit_symlink', 'edit_template', 'edit_tv', 'edit_user', 'edit_weblink', 'element_tree', 'empty_cache', 'error_log_erase', 'error_log_view', 'export_static', 'file_create', 'file_list', 'file_manager', 'file_remove', 'file_tree', 'file_unpack', 'file_update', 'file_upload', 'file_view', 'frames', 'help', 'home', 'language', 'languages', 'lexicons', 'list', 'load', 'logout', 'mgr_log_view', 'mgr_log_erase', 'menu_reports', 'menu_site', 'menu_support', 'menu_system', 'menu_tools', 'menu_user', 'menus', 'messages', 'namespaces', 'new_category', 'new_chunk', 'new_context', 'new_document', 'new_document_in_root', 'new_plugin', 'new_propertyset', 'new_role', 'new_snippet', 'new_static_resource', 'new_symlink', 'new_template', 'new_tv', 'new_user', 'new_weblink', 'packages', 'property_sets', 'providers', 'publish_document', 'purge_deleted', 'remove', 'resource_duplicate', 'resource_quick_create', 'resource_quick_update', 'resource_tree', 'save', 'save_category', 'save_chunk', 'save_context', 'save_document', 'save_plugin', 'save_propertyset', 'save_snippet', 'save_template', 'save_tv', 'save_user', 'search', 'settings', 'upgrade_core', 'source_delete', 'source_edit', 'source_save', 'source_view', 'sources', 'tree_show_element_ids', 'tree_show_resource_ids', 'undelete_document', 'unlock_element_properties', 'unpublish_document', 'view', 'view_category', 'view_chunk', 'view_context', 'view_document', 'view_element', 'view_eventlog', 'view_offline', 'view_plugin', 'view_propertyset', 'view_role', 'view_snippet', 'view_sysinfo', 'view_template', 'view_tv', 'view_unpublished', 'view_user', 'workspaces'], modAccessPolicy::POLICY_CONTEXT => ['load', 'list', 'view', 'save', 'remove', 'copy', 'view_unpublished'], modAccessPolicy::POLICY_HIDDEN_NAMESPACE => ['load' => false, 'list' => false, 'view' => true], ]; diff --git a/_build/data/transport.core.system_settings.php b/_build/data/transport.core.system_settings.php index 47ffc186c76..774ff8401b2 100644 --- a/_build/data/transport.core.system_settings.php +++ b/_build/data/transport.core.system_settings.php @@ -83,6 +83,24 @@ 'area' => 'system', 'editedon' => null, ], '', true, true); +$settings['core_upgrade_temp_dir'] = $xpdo->newObject(modSystemSetting::class); +$settings['core_upgrade_temp_dir']->fromArray([ + 'key' => 'core_upgrade_temp_dir', + 'value' => '', + 'xtype' => 'textfield', + 'namespace' => 'core', + 'area' => 'system', + 'editedon' => null, +], '', true, true); +$settings['core_upgrade_allowed_groups'] = $xpdo->newObject(modSystemSetting::class); +$settings['core_upgrade_allowed_groups']->fromArray([ + 'key' => 'core_upgrade_allowed_groups', + 'value' => 'Administrator', + 'xtype' => 'textfield', + 'namespace' => 'core', + 'area' => 'system', + 'editedon' => null, +], '', true, true); $settings['auto_menuindex'] = $xpdo->newObject(modSystemSetting::class); $settings['auto_menuindex']->fromArray([ 'key' => 'auto_menuindex', diff --git a/_build/test/Tests/Processors/SoftwareUpdate/UpgradeCoreTest.php b/_build/test/Tests/Processors/SoftwareUpdate/UpgradeCoreTest.php new file mode 100644 index 00000000000..b0cd0ab2503 --- /dev/null +++ b/_build/test/Tests/Processors/SoftwareUpdate/UpgradeCoreTest.php @@ -0,0 +1,103 @@ +modx->runProcessor(UpgradeCore::class, [ + 'downloadId' => '', + ]); + $this->assertInstanceOf(ProcessorResponse::class, $result); + $this->assertTrue($result->isError()); + } + + /** + * Test that UpgradeCore fails with invalid downloadId format (not a UUID). + */ + public function testUpgradeCoreFailsWithInvalidDownloadId() + { + $result = $this->modx->runProcessor(UpgradeCore::class, [ + 'downloadId' => 'not-a-valid-uuid', + ]); + $this->assertInstanceOf(ProcessorResponse::class, $result); + $this->assertTrue($result->isError()); + } + + /** + * Test that UpgradeCore fails with malformed downloadId. + */ + public function testUpgradeCoreFailsWithMalformedDownloadId() + { + $result = $this->modx->runProcessor(UpgradeCore::class, [ + 'downloadId' => 'abc', + ]); + $this->assertInstanceOf(ProcessorResponse::class, $result); + $this->assertTrue($result->isError()); + } + + /** + * Test that UpgradeCore fails when user lacks upgrade_core permission. + */ + public function testUpgradeCoreFailsWhenUserLacksPermission() + { + $originalUser = $this->modx->user; + $originalAllowedGroups = $this->modx->getOption('core_upgrade_allowed_groups', null, 'Administrator'); + + $restrictedUser = $this->modx->newObject(\MODX\Revolution\modUser::class); + $restrictedUser->set('id', 999999); + $restrictedUser->set('username', 'restricted_test_user'); + $this->modx->user = $restrictedUser; + + $this->modx->setOption('core_upgrade_allowed_groups', ''); + + $result = $this->modx->runProcessor(UpgradeCore::class, [ + 'downloadId' => '11111111-1111-1111-1111-111111111111', + ]); + + $this->modx->user = $originalUser; + $this->modx->setOption('core_upgrade_allowed_groups', $originalAllowedGroups); + + $this->assertInstanceOf(ProcessorResponse::class, $result); + $this->assertTrue($result->isError()); + } + + /** + * Test that UpgradeCore fails when GetFile returns no zip URL (retrieve error path). + */ + public function testUpgradeCoreFailsOnRetrieveError() + { + $result = $this->modx->runProcessor(UpgradeCore::class, [ + 'downloadId' => '00000000-0000-0000-0000-000000000000', + ]); + $this->assertInstanceOf(ProcessorResponse::class, $result); + $this->assertTrue($result->isError()); + } +} diff --git a/core/lexicon/en/dashboard.inc.php b/core/lexicon/en/dashboard.inc.php index 73ac0a44746..98ab1857c58 100644 --- a/core/lexicon/en/dashboard.inc.php +++ b/core/lexicon/en/dashboard.inc.php @@ -31,6 +31,9 @@ $_lang['updates_update'] = 'Update'; $_lang['updates_ok'] = 'Up to date'; $_lang['updates_extras'] = 'Extras'; +$_lang['updates_upgrade_modx'] = 'Upgrade MODX'; +$_lang['updates_upgrading'] = 'Upgrading MODX...'; +$_lang['updates_badge'] = 'updates'; $_lang['quicklinks'] = 'Quicklinks'; $_lang['security_notices'] = 'Security Notices'; diff --git a/core/lexicon/en/permissions.inc.php b/core/lexicon/en/permissions.inc.php index 43d2ae252d5..cdb5c230799 100644 --- a/core/lexicon/en/permissions.inc.php +++ b/core/lexicon/en/permissions.inc.php @@ -157,6 +157,7 @@ $_lang['perm.search_desc'] = 'To use the Search page.'; $_lang['perm.set_sudo_desc'] = 'To make any User sudo.'; $_lang['perm.settings_desc'] = 'To view and edit any System Settings. WARNING: secrets such as API keys are commonly stored in System Settings. User may change uploadable file types, allowing execution of arbitrary code.'; +$_lang['perm.upgrade_core_desc'] = 'To upgrade MODX core from the Manager dashboard. Allows downloading and installing core updates.'; $_lang['perm.events_desc'] = 'To view any System Events.'; $_lang['perm.source_delete_desc'] = 'To delete a Media Source.'; $_lang['perm.source_edit_desc'] = 'To edit a Media Source.'; diff --git a/core/lexicon/en/setting.inc.php b/core/lexicon/en/setting.inc.php index b98e375f93d..bbe055dbc80 100644 --- a/core/lexicon/en/setting.inc.php +++ b/core/lexicon/en/setting.inc.php @@ -849,3 +849,19 @@ $_lang['setting_static_elements_html_extension'] = 'Static elements html extension'; $_lang['setting_static_elements_html_extension_desc'] = 'The extension for files used by static elements with HTML content.'; + +$_lang['invalid_download_id'] = 'Invalid download ID.'; +$_lang['software_update_err_retrieve'] = 'Could not retrieve upgrade package URL.'; +$_lang['software_update_err_temp_dir'] = 'Could not create or write to temporary directory.'; +$_lang['software_update_err_download'] = 'Could not download upgrade package.'; +$_lang['software_update_err_checksum'] = 'Downloaded package checksum verification failed.'; +$_lang['software_update_err_extract'] = 'Could not extract upgrade package.'; +$_lang['software_update_err_archive_structure'] = 'Upgrade archive has unexpected structure.'; +$_lang['software_update_err_copy'] = 'Could not copy upgrade files.'; +$_lang['software_update_err_prepare_setup'] = 'Could not prepare setup (unlock or flush sessions).'; +$_lang['software_update_err_busy'] = 'Another core upgrade is already running. Try again in a moment.'; + +$_lang['setting_core_upgrade_temp_dir'] = 'Core upgrade temp directory'; +$_lang['setting_core_upgrade_temp_dir_desc'] = 'Base directory for per-run upgrade work folders. Leave empty for default {core_path}cache/upgrade/. Only the unique run folder is deleted after an upgrade.'; +$_lang['setting_core_upgrade_allowed_groups'] = 'Core upgrade allowed groups'; +$_lang['setting_core_upgrade_allowed_groups_desc'] = 'Optional comma-separated user groups. Users still need the upgrade_core permission; if this list is set they must also belong to one of these groups. Default: Administrator.'; diff --git a/core/src/Revolution/Processors/SoftwareUpdate/GetFile.php b/core/src/Revolution/Processors/SoftwareUpdate/GetFile.php index 9fe489d09bf..dc644e981bb 100644 --- a/core/src/Revolution/Processors/SoftwareUpdate/GetFile.php +++ b/core/src/Revolution/Processors/SoftwareUpdate/GetFile.php @@ -46,11 +46,14 @@ public function process() if ($fileData) { $fileData = json_decode($fileData, true); - if (!empty($fileData['zip_url']) && strpos($fileData['zip_url'], 'http') === 0) { + if (!empty($fileData['zip_url']) && strpos($fileData['zip_url'], 'https://') === 0) { $name = basename($fileData['zip_url']); $responseData['filename'] = $name; $responseData['zip'] = $fileData['zip_url']; $responseData['status'] = $response->getStatusCode(); + if (!empty($fileData['sha256']) && is_string($fileData['sha256'])) { + $responseData['sha256'] = $fileData['sha256']; + } } } return $this->success('', $responseData); diff --git a/core/src/Revolution/Processors/SoftwareUpdate/UpgradeCore.php b/core/src/Revolution/Processors/SoftwareUpdate/UpgradeCore.php new file mode 100644 index 00000000000..4b0efad2373 --- /dev/null +++ b/core/src/Revolution/Processors/SoftwareUpdate/UpgradeCore.php @@ -0,0 +1,534 @@ +modx->getOption('core_upgrade_allowed_groups', null, '')); + if ($allowedGroups === '') { + return true; + } + + $groups = array_filter(array_map('trim', explode(',', $allowedGroups))); + foreach ($groups as $group) { + if ($this->modx->user->isMember($group)) { + return true; + } + } + + return false; + } + + public function process() + { + $downloadId = $this->getProperty('downloadId'); + if (empty($downloadId) || !$this->isValidDownloadId($downloadId)) { + return $this->failure($this->modx->lexicon('invalid_download_id')); + } + + $metadata = $this->getZipMetadata($downloadId); + if ($metadata === null) { + return $this->failure($this->modx->lexicon('software_update_err_retrieve')); + } + + if (!$this->prepareWorkDirectory()) { + return $this->failure($this->modx->lexicon('software_update_err_temp_dir')); + } + + if (!$this->acquireLock()) { + $this->cleanup(); + return $this->failure($this->modx->lexicon('software_update_err_busy')); + } + + try { + $zipPath = $this->downloadZip($metadata['zip']); + if ($zipPath === null) { + return $this->failure($this->modx->lexicon('software_update_err_download')); + } + + if (!$this->verifyChecksum($zipPath, $metadata['sha256'])) { + return $this->failure($this->modx->lexicon('software_update_err_checksum')); + } + + if (!$this->extractZip($zipPath)) { + return $this->failure($this->modx->lexicon('software_update_err_extract')); + } + + $this->archiveRoot = $this->findArchiveRoot(); + if ($this->archiveRoot === null) { + return $this->failure($this->modx->lexicon('software_update_err_archive_structure')); + } + + if (!$this->copyFiles()) { + return $this->failure($this->modx->lexicon('software_update_err_copy')); + } + + if (!$this->prepareSetup()) { + return $this->failure($this->modx->lexicon('software_update_err_prepare_setup')); + } + + return $this->success('', ['redirect_url' => $this->buildSetupRedirectUrl()]); + } finally { + $this->cleanup(); + $this->releaseLock(); + } + } + + private function isValidDownloadId(string $id): bool + { + return (bool) preg_match('/^[a-f0-9\-]{36}$/i', $id); + } + + /** + * @return array{zip: string, sha256: string}|null + */ + private function getZipMetadata(string $downloadId): ?array + { + $response = $this->modx->runProcessor(GetFile::class, ['downloadId' => $downloadId]); + if ($response->isError()) { + return null; + } + $data = $response->getObject(); + if ( + empty($data['zip']) + || !is_string($data['zip']) + || strpos($data['zip'], 'https://') !== 0 + || empty($data['sha256']) + || !is_string($data['sha256']) + || !preg_match('/^[a-f0-9]{64}$/i', $data['sha256']) + ) { + return null; + } + + return [ + 'zip' => $data['zip'], + 'sha256' => strtolower($data['sha256']), + ]; + } + + private function prepareWorkDirectory(): bool + { + $defaultTemp = $this->modx->getOption('core_path') . 'cache/upgrade/'; + $this->tempBase = rtrim( + (string) $this->modx->getOption('core_upgrade_temp_dir', null, $defaultTemp), + DIRECTORY_SEPARATOR + ) . DIRECTORY_SEPARATOR; + + if (!is_dir($this->tempBase)) { + $this->modx->getCacheManager(); + if (!$this->modx->cacheManager->writeTree($this->tempBase)) { + return false; + } + } + + $this->workDir = $this->tempBase . 'run-' . bin2hex(random_bytes(8)) . DIRECTORY_SEPARATOR; + if (!mkdir($this->workDir, 0700, true) && !is_dir($this->workDir)) { + return false; + } + + $this->extractDir = $this->workDir . 'extract' . DIRECTORY_SEPARATOR; + return true; + } + + private function acquireLock(): bool + { + $lockPath = $this->tempBase . 'upgrade.lock'; + $handle = @fopen($lockPath, 'c'); + if ($handle === false) { + return false; + } + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return false; + } + $this->lockHandle = $handle; + return true; + } + + private function releaseLock(): void + { + if ($this->lockHandle === null) { + return; + } + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + } + + private function buildSetupRedirectUrl(): string + { + $siteUrl = rtrim((string) $this->modx->getOption('site_url', null, MODX_SITE_URL), '/') . '/'; + return $siteUrl . 'setup/index.php'; + } + + private function downloadZip(string $zipUrl): ?string + { + $this->initApiClient(); + $request = $this->apiFactory->createRequest('GET', $zipUrl); + try { + $response = $this->apiClient->sendRequest($request); + } catch (ClientExceptionInterface $e) { + $this->modx->log(modX::LOG_LEVEL_ERROR, $e->getMessage()); + return null; + } + if ($response->getStatusCode() !== 200) { + return null; + } + + $filename = basename((string) parse_url($zipUrl, PHP_URL_PATH)) ?: 'modx-upgrade.zip'; + $filename = str_replace(['/', '\\', "\0"], '', $filename); + $zipPath = $this->workDir . $filename; + $out = @fopen($zipPath, 'wb'); + if ($out === false) { + return null; + } + + $body = $response->getBody(); + $written = 0; + while (!$body->eof()) { + $chunk = $body->read(8192); + if ($chunk === '') { + break; + } + $written += strlen($chunk); + if ($written > self::MAX_ZIP_BYTES) { + fclose($out); + @unlink($zipPath); + return null; + } + if (fwrite($out, $chunk) === false) { + fclose($out); + @unlink($zipPath); + return null; + } + } + fclose($out); + + return $zipPath; + } + + private function verifyChecksum(string $filePath, string $expectedSha256): bool + { + $actual = @hash_file('sha256', $filePath); + return $actual !== false && hash_equals($expectedSha256, $actual); + } + + private function extractZip(string $zipPath): bool + { + if (!class_exists(\ZipArchive::class)) { + return false; + } + + if (is_dir($this->extractDir)) { + $this->removeDirectory($this->extractDir); + } + if (!mkdir($this->extractDir, 0700, true) && !is_dir($this->extractDir)) { + return false; + } + + $zip = new \ZipArchive(); + if ($zip->open($zipPath) !== true) { + return false; + } + $result = $this->extractZipArchiveSafe($zip); + $zip->close(); + + return $result; + } + + /** + * Extracts ZipArchive entries with path traversal and symlink protection. + */ + private function extractZipArchiveSafe(\ZipArchive $zip): bool + { + $baseDir = realpath($this->extractDir); + if ($baseDir === false) { + return false; + } + $prefix = $baseDir . DIRECTORY_SEPARATOR; + + for ($i = 0; $i < $zip->numFiles; $i++) { + $name = $zip->getNameIndex($i); + if ($name === false || $name === '' || strpos($name, "\0") !== false) { + return false; + } + + $normalized = str_replace('\\', '/', $name); + if ($normalized[0] === '/' || preg_match('#(^|/)\.\.(?:/|$)#', $normalized)) { + return false; + } + + $isDir = substr($normalized, -1) === '/'; + $relative = str_replace('/', DIRECTORY_SEPARATOR, rtrim($normalized, '/')); + $targetPath = $baseDir . DIRECTORY_SEPARATOR . $relative; + + $parent = dirname($targetPath); + if (!is_dir($parent) && !mkdir($parent, 0700, true) && !is_dir($parent)) { + return false; + } + $parentReal = realpath($parent); + $outsideBase = $parentReal !== $baseDir + && strpos($parentReal . DIRECTORY_SEPARATOR, $prefix) !== 0; + if ($parentReal === false || $outsideBase) { + return false; + } + + $resolved = $parentReal . DIRECTORY_SEPARATOR . basename($targetPath); + if ($resolved !== $baseDir && strpos($resolved, $prefix) !== 0) { + return false; + } + + if ($isDir) { + if (!is_dir($resolved) && !mkdir($resolved, 0700, true)) { + return false; + } + continue; + } + + $stat = $zip->statIndex($i); + if (is_array($stat) && isset($stat['external_attr'])) { + // Unix symlink: high byte of external attributes is file type 0120000 + $type = ($stat['external_attr'] >> 16) & 0170000; + if ($type === 0120000) { + return false; + } + } + + $content = $zip->getFromIndex($i); + if ($content === false || file_put_contents($resolved, $content) === false) { + return false; + } + } + + return true; + } + + private function findArchiveRoot(): ?string + { + $dirs = array_filter(glob($this->extractDir . '*', GLOB_ONLYDIR) ?: [], 'is_dir'); + foreach ($dirs as $dir) { + $corePath = $dir . DIRECTORY_SEPARATOR . 'core'; + $managerPath = $dir . DIRECTORY_SEPARATOR . 'manager'; + if (is_dir($corePath) && is_dir($managerPath)) { + return rtrim($dir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + } + } + if (is_dir($this->extractDir . 'core') && is_dir($this->extractDir . 'manager')) { + return $this->extractDir; + } + return null; + } + + private function copyFiles(): bool + { + $basePath = rtrim($this->modx->getOption('base_path'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $corePath = rtrim($this->modx->getOption('core_path'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $managerPath = rtrim($this->modx->getOption('manager_path'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $connectorsPath = rtrim($this->modx->getOption('connectors_path'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $assetsPath = rtrim($this->modx->getOption('assets_path'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + + $pairs = [ + $this->archiveRoot . 'setup' => $basePath . 'setup', + $this->archiveRoot . 'core' => $corePath, + $this->archiveRoot . 'manager' => $managerPath, + $this->archiveRoot . 'connectors' => $connectorsPath, + ]; + if (is_dir($this->archiveRoot . 'assets')) { + $pairs[$this->archiveRoot . 'assets'] = $assetsPath; + } + + $excludeFromCore = ['config' . DIRECTORY_SEPARATOR . 'config.inc.php']; + + foreach ($pairs as $source => $destination) { + if (!is_dir($source)) { + continue; + } + $exclude = ($source === $this->archiveRoot . 'core') ? $excludeFromCore : []; + if (!$this->recurseCopy($source, $destination, $exclude)) { + return false; + } + } + + foreach (['index.php', 'ht.access'] as $file) { + $src = $this->archiveRoot . $file; + if (is_file($src) && !is_link($src) && !copy($src, $basePath . $file)) { + return false; + } + } + + $sep = DIRECTORY_SEPARATOR; + $setupConfigCore = $this->archiveRoot . 'setup' . $sep . 'includes' . $sep . 'config.core.php'; + if (is_file($setupConfigCore) && !is_link($setupConfigCore)) { + $dstConfigCore = $basePath . 'setup' . $sep . 'includes' . $sep . 'config.core.php'; + if (!copy($setupConfigCore, $dstConfigCore)) { + return false; + } + } + + return true; + } + + private function isPathExcluded(string $relative, array $exclude): bool + { + foreach ($exclude as $ex) { + $prefix = $ex . DIRECTORY_SEPARATOR; + if ($relative === $ex || strpos($relative, $prefix) === 0) { + return true; + } + } + return false; + } + + private function recurseCopy(string $source, string $destination, array $exclude = []): bool + { + if (!is_dir($destination) && !$this->ensureDirectory($destination)) { + return false; + } + $source = rtrim($source, DIRECTORY_SEPARATOR); + $len = strlen($source) + 1; + $dir = new \RecursiveDirectoryIterator($source, \RecursiveDirectoryIterator::SKIP_DOTS); + $iter = new \RecursiveIteratorIterator($dir, \RecursiveIteratorIterator::SELF_FIRST); + foreach ($iter as $item) { + $subPath = substr($item->getPathname(), $len); + $relative = str_replace('/', DIRECTORY_SEPARATOR, $subPath); + if (strpos($relative, '..') !== false || $this->isPathExcluded($relative, $exclude)) { + continue; + } + $destPath = $destination . DIRECTORY_SEPARATOR . $relative; + if ($item->isLink()) { + continue; + } + if ($item->isDir()) { + if (!$this->ensureDirectory($destPath)) { + return false; + } + } elseif (!$this->copyItem($item->getPathname(), $destPath)) { + return false; + } + } + return true; + } + + private function ensureDirectory(string $path): bool + { + return is_dir($path) || (mkdir($path, 0755, true) && is_dir($path)); + } + + private function copyItem(string $source, string $destPath): bool + { + if (is_link($source)) { + return true; + } + $destDir = dirname($destPath); + if (!$this->ensureDirectory($destDir)) { + return false; + } + return copy($source, $destPath); + } + + private function prepareSetup(): bool + { + $basePath = rtrim($this->modx->getOption('base_path'), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; + $lockedFile = $basePath . 'setup' . DIRECTORY_SEPARATOR . '.locked'; + if (file_exists($lockedFile)) { + @unlink($lockedFile); + } + return modSessionHandler::flushSessions($this->modx); + } + + private function cleanup(): void + { + if ($this->workDir === '' || !is_dir($this->workDir)) { + return; + } + + $baseReal = realpath($this->tempBase); + $workReal = realpath($this->workDir); + if ($baseReal === false || $workReal === false) { + return; + } + $prefix = $baseReal . DIRECTORY_SEPARATOR; + if ($workReal === $baseReal || strpos($workReal . DIRECTORY_SEPARATOR, $prefix) !== 0) { + $this->modx->log( + modX::LOG_LEVEL_ERROR, + 'Refusing to clean unexpected upgrade work directory: ' . $this->workDir + ); + return; + } + + $this->removeDirectory($workReal); + } + + private function removeDirectory(string $dir): void + { + $dir = rtrim($dir, DIRECTORY_SEPARATOR); + 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); + } +} diff --git a/core/src/Revolution/modManagerController.php b/core/src/Revolution/modManagerController.php index 43530cc8a46..e24371812c6 100644 --- a/core/src/Revolution/modManagerController.php +++ b/core/src/Revolution/modManagerController.php @@ -139,6 +139,7 @@ public function prepareLanguage() $this->modx->lexicon->load('action'); $languageTopics = $this->getLanguageTopics(); $languageTopics[] = 'trash'; + $languageTopics[] = 'dashboard'; foreach ($languageTopics as $topic) { $this->modx->lexicon->load($topic); } diff --git a/manager/controllers/default/header.php b/manager/controllers/default/header.php index 43e4ead5f11..024e47e26e8 100644 --- a/manager/controllers/default/header.php +++ b/manager/controllers/default/header.php @@ -1,4 +1,5 @@ $this->getUserImage(), ]; + $updateCount = $this->getUpdatesCount(); + $placeholders['updates_total'] = $updateCount['total']; + $placeholders['updates_has_updates'] = $updateCount['total'] > 0; + $this->controller->setPlaceholders($placeholders); } + /** + * Get update counts from cache (same as dashboard updates widget) for navbar badge. + * + * @return array{modx: int, extras: int, total: int} + */ + protected function getUpdatesCount() + { + $key = 'mgr/providers/updates/modx-core'; + $options = [ + xPDO::OPT_CACHE_KEY => $this->modx->cacheManager->getOption('cache_packages_key', null, 'packages'), + xPDO::OPT_CACHE_HANDLER => $this->modx->cacheManager->getOption( + 'cache_packages_handler', + null, + $this->modx->cacheManager->getOption(xPDO::OPT_CACHE_HANDLER) + ), + ]; + $data = $this->modx->cacheManager->get($key, $options); + if (!is_array($data)) { + return ['modx' => 0, 'extras' => 0, 'total' => 0]; + } + $modx = (int) (!empty($data['modx']['updateable'])); + $extras = (int) (!empty($data['extras']['updateable']) ? $data['extras']['updateable'] : 0); + return [ + 'modx' => $modx, + 'extras' => $extras, + 'total' => $modx + $extras, + ]; + } + /** * Retrieve/compute the user picture profile * diff --git a/manager/templates/default/dashboard/updates.tpl b/manager/templates/default/dashboard/updates.tpl index 440dac179a1..30755d79da8 100644 --- a/manager/templates/default/dashboard/updates.tpl +++ b/manager/templates/default/dashboard/updates.tpl @@ -9,42 +9,54 @@ - - MODX - {if $modx.updateable} - {$modx.latest.version} - - - {$_lang.download} - - - {else} - {$_lang.updates_ok} - - {/if} - - - {if $extras.updateable} + {if $extras.updateable} + {$_lang.updates_extras} {if $extras.updateable > 10}10+{else}{$extras.updateable}{/if} + {if $extras.names} +
+ {foreach $extras.names as $pkgName name="pkgList"} + {if $pkgList@index < 10}{$pkgName|escape}{if $pkgList@index < 9}, {/if}{/if} + {/foreach} + {if $extras.updateable > 10} ...{/if} +
+ {/if} {$_lang.updates_available} {$_lang.updates_update} - {else} + + {else} + {$_lang.updates_extras} {$_lang.updates_ok} + + {/if} + + MODX + {if $modx.updateable} + {$modx.latest.version} + + + {$_lang.updates_upgrade_modx} + + + {$_lang.download} + + + {else} + {$_lang.updates_ok} + {/if} @@ -54,46 +66,58 @@ {literal} - {/literal} \ No newline at end of file + {/literal} diff --git a/manager/templates/default/header.tpl b/manager/templates/default/header.tpl index 5f96797a981..db6d86d03ed 100644 --- a/manager/templates/default/header.tpl +++ b/manager/templates/default/header.tpl @@ -117,6 +117,14 @@
  • {$_version.full_version|strip_tags|escape}
  • + {if $updates_has_updates} +
  • + + + {$updates_total} {$_lang.updates_badge} + +
  • + {/if}