Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,49 @@
<?php
namespace Drupal\update\Access;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Routing\Access\AccessInterface;
use Drupal\Core\Site\Settings;
/**
* Determines whether allow authorized operations is set.
*
* @deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. There is no
* replacement.
*
* @see https://www.drupal.org/node/3458658
*/
class UpdateManagerAccessCheck implements AccessInterface {
/**
* Settings Service.
*
* @var \Drupal\Core\Site\Settings
*/
protected $settings;
/**
* Constructs an UpdateManagerAccessCheck object.
*
* @param \Drupal\Core\Site\Settings $settings
* The read-only settings container.
*/
public function __construct(Settings $settings) {
$this->settings = $settings;
}
/**
* Checks access.
*
* @return \Drupal\Core\Access\AccessResultInterface
* The access result.
*/
public function access() {
@trigger_error('The ' . __METHOD__ . ' method is deprecated in drupal:11.1.0 and is removed from drupal:12.0.0. There is no replacement. See https://www.drupal.org/node/3458658', E_USER_DEPRECATED);
// Uncacheable because the access result depends on a Settings key-value
// pair, and can therefore change at any time.
return AccessResult::allowedIf($this->settings->get('allow_authorize_operations', TRUE))->setCacheMaxAge(0);
}
}

View File

@ -0,0 +1,89 @@
<?php
namespace Drupal\update\Controller;
use Drupal\Core\Batch\BatchBuilder;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Render\RendererInterface;
use Drupal\update\UpdateFetcherInterface;
use Drupal\update\UpdateManagerInterface;
/**
* Controller routines for update routes.
*/
class UpdateController extends ControllerBase {
/**
* Update manager service.
*
* @var \Drupal\update\UpdateManagerInterface
*/
protected $updateManager;
/**
* The renderer.
*
* @var \Drupal\Core\Render\RendererInterface
*/
protected $renderer;
/**
* Constructs update status data.
*
* @param \Drupal\update\UpdateManagerInterface $update_manager
* Update Manager Service.
* @param \Drupal\Core\Render\RendererInterface $renderer
* The renderer.
*/
public function __construct(UpdateManagerInterface $update_manager, RendererInterface $renderer) {
$this->updateManager = $update_manager;
$this->renderer = $renderer;
}
/**
* Returns a page about the update status of projects.
*
* @return array
* A build array with the update status of projects.
*/
public function updateStatus() {
$build = [
'#theme' => 'update_report',
];
if ($available = update_get_available(TRUE)) {
$this->moduleHandler()->loadInclude('update', 'compare.inc');
$build['#data'] = update_calculate_project_data($available);
// @todo Consider using 'fetch_failures' from the 'update' collection
// in the key_value_expire service for this?
$fetch_failed = FALSE;
foreach ($build['#data'] as $project) {
if ($project['status'] === UpdateFetcherInterface::NOT_FETCHED) {
$fetch_failed = TRUE;
break;
}
}
if ($fetch_failed) {
$message = ['#theme' => 'update_fetch_error_message'];
$this->messenger()->addError($this->renderer->renderInIsolation($message));
}
}
return $build;
}
/**
* Manually checks the update status without the use of cron.
*/
public function updateStatusManually() {
$this->updateManager->refreshUpdateData();
$batch_builder = (new BatchBuilder())
->setTitle($this->t('Checking available update data'))
->addOperation([$this->updateManager, 'fetchDataBatch'], [])
->setProgressMessage($this->t('Trying to check available update data ...'))
->setErrorMessage($this->t('Error checking available update data.'))
->setFinishCallback('update_fetch_data_finished');
batch_set($batch_builder->toArray());
return batch_process('admin/reports/updates');
}
}

View File

@ -0,0 +1,326 @@
<?php
namespace Drupal\update\Hook;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\update\UpdateManagerInterface;
use Drupal\Core\Url;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Hook\Attribute\Hook;
/**
* Hook implementations for update.
*/
class UpdateHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help($route_name, RouteMatchInterface $route_match): ?string {
switch ($route_name) {
case 'help.page.update':
$output = '';
$output .= '<h2>' . $this->t('About') . '</h2>';
$output .= '<p>' . $this->t('The Update Status module periodically checks for new versions of your site\'s software (including contributed modules and themes), and alerts administrators to available updates. The Update Status system is also used by some other modules to manage updates and downloads; for example, the Interface Translation module uses the Update Status to download translations from the localization server. Note that whenever the Update Status system is used, anonymous usage statistics are sent to Drupal.org. If desired, you may uninstall the Update Status module from the <a href=":modules">Extend page</a>; if you do so, functionality that depends on the Update Status system will not work. For more information, see the <a href=":update">online documentation for the Update Status module</a>.', [
':update' => 'https://www.drupal.org/documentation/modules/update',
':modules' => Url::fromRoute('system.modules_list')->toString(),
]) . '</p>';
$output .= '<h2>' . $this->t('Uses') . '</h2>';
$output .= '<dl>';
$output .= '<dt>' . $this->t('Checking for available updates') . '</dt>';
$output .= '<dd>' . $this->t('The <a href=":update-report">Available updates report</a> displays core, contributed modules, and themes for which there are new releases available for download. On the report page, you can also check manually for updates. You can configure the frequency of update checks, which are performed during cron runs, and whether notifications are sent on the <a href=":update-settings">Update Status settings page</a>.', [
':update-report' => Url::fromRoute('update.status')->toString(),
':update-settings' => Url::fromRoute('update.settings')->toString(),
]) . '</dd>';
$output .= '</dl>';
return $output;
case 'update.status':
return '<p>' . $this->t('Here you can find information about available updates for your installed modules and themes. Note that each module or theme is part of a "project", which may or may not have the same name, and might include multiple modules or themes within it.') . '</p>';
case 'system.modules_list':
return '<p>' . $this->t('Regularly review <a href=":updates">available updates</a> and update as required to maintain a secure and current site. Always run the <a href=":update-php">update script</a> each time you update software.', [
':update-php' => Url::fromRoute('system.db_update')->toString(),
':updates' => Url::fromRoute('update.status')->toString(),
]) . '</p>';
}
return NULL;
}
/**
* Implements hook_page_top().
*/
#[Hook('page_top')]
public function pageTop(): void {
/** @var \Drupal\Core\Routing\AdminContext $admin_context */
$admin_context = \Drupal::service('router.admin_context');
$route_match = \Drupal::routeMatch();
if ($admin_context->isAdminRoute($route_match->getRouteObject()) && \Drupal::currentUser()->hasPermission('view update notifications')) {
$route_name = \Drupal::routeMatch()->getRouteName();
switch ($route_name) {
// These pages don't need additional nagging.
case 'update.status':
case 'update.settings':
case 'system.status':
case 'system.theme_install':
case 'system.batch_page.html':
return;
// If we are on the appearance or modules list, display a detailed
// report
// of the update status.
case 'system.themes_page':
case 'system.modules_list':
$verbose = TRUE;
break;
}
// This loadInclude() is to ensure that the install API is available.
// Since we're loading an include of type 'install', this will also
// include core/includes/install.inc for us, which is where the
// REQUIREMENTS* constants are currently defined.
// @todo Remove this once those constants live in a better place.
// @see https://www.drupal.org/project/drupal/issues/2909480
// @see https://www.drupal.org/project/drupal/issues/3410938
\Drupal::moduleHandler()->loadInclude('update', 'install');
$status = \Drupal::moduleHandler()->invoke('update', 'runtime_requirements');
foreach (['core', 'contrib'] as $report_type) {
$type = 'update_' . $report_type;
// hook_requirements() supports render arrays therefore we need to
// render them before using
// \Drupal\Core\Messenger\MessengerInterface::addStatus().
if (isset($status[$type]['description']) && is_array($status[$type]['description'])) {
$status[$type]['description'] = \Drupal::service('renderer')->renderInIsolation($status[$type]['description']);
}
if (!empty($verbose)) {
if (isset($status[$type]['severity'])) {
if ($status[$type]['severity'] === RequirementSeverity::Error) {
\Drupal::messenger()->addError($status[$type]['description']);
}
elseif ($status[$type]['severity'] === RequirementSeverity::Warning) {
\Drupal::messenger()->addWarning($status[$type]['description']);
}
}
}
else {
if (isset($status[$type]) && isset($status[$type]['reason']) && $status[$type]['reason'] === UpdateManagerInterface::NOT_SECURE) {
\Drupal::messenger()->addError($status[$type]['description']);
}
}
}
}
}
/**
* Implements hook_theme().
*/
#[Hook('theme')]
public function theme() : array {
return [
'update_last_check' => [
'variables' => [
'last' => 0,
],
],
'update_report' => [
'variables' => [
'data' => NULL,
],
'file' => 'update.report.inc',
],
'update_project_status' => [
'variables' => [
'project' => [],
],
'file' => 'update.report.inc',
],
// We are using template instead of '#type' => 'table' here to keep markup
// out of preprocess and allow for easier changes to markup.
'update_version' => [
'variables' => [
'version' => NULL,
'title' => NULL,
'attributes' => [],
],
'file' => 'update.report.inc',
],
'update_fetch_error_message' => [
'file' => 'update.report.inc',
'render element' => 'element',
'variables' => [
'error_message' => [],
],
],
];
}
/**
* Implements hook_cron().
*/
#[Hook('cron')]
public function cron(): void {
$update_config = \Drupal::config('update.settings');
$frequency = $update_config->get('check.interval_days');
$interval = 60 * 60 * 24 * $frequency;
$last_check = \Drupal::state()->get('update.last_check', 0);
$request_time = \Drupal::time()->getRequestTime();
if ($request_time - $last_check > $interval) {
// If the configured update interval has elapsed, we want to invalidate
// the data for all projects, attempt to re-fetch, and trigger any
// configured notifications about the new status.
update_refresh();
update_fetch_data();
}
else {
// Otherwise, see if any individual projects are now stale or still
// missing data, and if so, try to fetch the data.
update_get_available(TRUE);
}
$last_email_notice = \Drupal::state()->get('update.last_email_notification', 0);
if ($request_time - $last_email_notice > $interval) {
// If configured time between notifications elapsed, send email about
// updates possibly available.
\Drupal::moduleHandler()->loadInclude('update', 'inc', 'update.fetch');
_update_cron_notify();
}
}
/**
* Implements hook_themes_installed().
*
* If themes are installed, we invalidate the information of available
* updates.
*/
#[Hook('themes_installed')]
public function themesInstalled($themes): void {
// Clear all Update Status module data.
update_storage_clear();
}
/**
* Implements hook_themes_uninstalled().
*
* If themes are uninstalled, we invalidate the information of available
* updates.
*/
#[Hook('themes_uninstalled')]
public function themesUninstalled($themes): void {
// Clear all Update Status module data.
update_storage_clear();
}
/**
* Implements hook_modules_installed().
*
* If modules are installed, we invalidate the information of available
* updates.
*/
#[Hook('modules_installed')]
public function modulesInstalled($modules): void {
// Clear all Update Status module data.
update_storage_clear();
}
/**
* Implements hook_modules_uninstalled().
*
* If modules are uninstalled, we invalidate the information of available
* updates.
*/
#[Hook('modules_uninstalled')]
public function modulesUninstalled($modules): void {
// Clear all Update Status module data.
update_storage_clear();
}
/**
* Implements hook_mail().
*
* Constructs the email notification message when the site is out of date.
*
* @see \Drupal\Core\Mail\MailManagerInterface::mail()
* @see _update_cron_notify()
* @see _update_message_text()
* @see \Drupal\update\UpdateManagerInterface
*/
#[Hook('mail')]
public function mail($key, &$message, $params): void {
$langcode = $message['langcode'];
$language = \Drupal::languageManager()->getLanguage($langcode);
$message['subject'] .= $this->t('New release(s) available for @site_name', ['@site_name' => \Drupal::config('system.site')->get('name')], ['langcode' => $langcode]);
foreach ($params as $msg_type => $msg_reason) {
$message['body'][] = _update_message_text($msg_type, $msg_reason, $langcode);
}
$message['body'][] = $this->t('See the available updates page for more information:', [], ['langcode' => $langcode]) . "\n" . Url::fromRoute('update.status', [], ['absolute' => TRUE, 'language' => $language])->toString();
$settings_url = Url::fromRoute('update.settings', [], ['absolute' => TRUE])->toString();
if (\Drupal::config('update.settings')->get('notification.threshold') == 'all') {
$message['body'][] = $this->t('Your site is currently configured to send these emails when any updates are available. To get notified only for security updates, @url.', ['@url' => $settings_url]);
}
else {
$message['body'][] = $this->t('Your site is currently configured to send these emails only when security updates are available. To get notified for any available updates, @url.', ['@url' => $settings_url]);
}
}
/**
* Implements hook_verify_update_archive().
*
* First, we ensure that the archive isn't a copy of Drupal core, which the
* update manager does not yet support. See
* https://www.drupal.org/node/606592.
*
* Then, we make sure that at least one module included in the archive file
* has an .info.yml file which claims that the code is compatible with the
* current version of Drupal core.
*
* @see \Drupal\Core\Extension\ExtensionDiscovery
*/
#[Hook('verify_update_archive')]
public function verifyUpdateArchive($project, $archive_file, $directory): array {
$errors = [];
// Make sure this isn't a tarball of Drupal core.
if (file_exists("{$directory}/{$project}/index.php") && file_exists("{$directory}/{$project}/core/install.php") && file_exists("{$directory}/{$project}/core/includes/bootstrap.inc") && file_exists("{$directory}/{$project}/core/modules/node/node.module") && file_exists("{$directory}/{$project}/core/modules/system/system.module")) {
return [
'no-core' => $this->t('Automatic updating of Drupal core is not supported. See the <a href=":update-guide">Updating Drupal guide</a> for information on how to update Drupal core manually.', [
':update-guide' => 'https://www.drupal.org/docs/updating-drupal',
]),
];
}
// Parse all the .info.yml files and make sure at least one is compatible
// with this version of Drupal core. If one is compatible, then the project
// as a whole is considered compatible (since, for example, the project may
// ship with some out-of-date modules that are not necessary for its overall
// functionality).
$compatible_project = FALSE;
$incompatible = [];
/** @var \Drupal\Core\File\FileSystemInterface $file_system */
$file_system = \Drupal::service('file_system');
$files = $file_system->scanDirectory("{$directory}/{$project}", '/.*\.info.yml$/', ['key' => 'name', 'min_depth' => 0]);
foreach ($files as $file) {
// Get the .info.yml file for the module or theme this file belongs to.
$info = \Drupal::service('info_parser')->parse($file->uri);
// If the module or theme is incompatible with Drupal core, set an error.
if ($info['core_incompatible']) {
$incompatible[] = !empty($info['name']) ? $info['name'] : $this->t('Unknown');
}
else {
$compatible_project = TRUE;
break;
}
}
if (empty($files)) {
$errors[] = $this->t('%archive_file does not contain any .info.yml files.', ['%archive_file' => $file_system->basename($archive_file)]);
}
elseif (!$compatible_project) {
$errors[] = \Drupal::translation()->formatPlural(count($incompatible), '%archive_file contains a version of %names that is not compatible with Drupal @version.', '%archive_file contains versions of modules or themes that are not compatible with Drupal @version: %names', [
'@version' => \Drupal::VERSION,
'%archive_file' => $file_system->basename($archive_file),
'%names' => implode(', ', $incompatible),
]);
}
return $errors;
}
}

View File

@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace Drupal\update\Hook;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Link;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
use Drupal\update\ProjectSecurityData;
use Drupal\update\ProjectSecurityRequirement;
use Drupal\update\UpdateFetcherInterface;
use Drupal\update\UpdateManagerInterface;
/**
* Requirements for the update module.
*/
class UpdateRequirements {
use StringTranslationTrait;
public function __construct(
protected readonly ModuleHandlerInterface $moduleHandler,
) {}
/**
* Implements hook_runtime_requirements().
*
* Describes the status of the site regarding available updates. If
* there is no update data, only one record will be returned, indicating that
* the status of core can't be determined. If data is available, there will
* be two records: one for core, and another for all of contrib (assuming
* there are any contributed modules or themes installed on the site). In
* addition to the fields expected by hook_requirements ('value', 'severity',
* and optionally 'description'), this array will contain a 'reason'
* attribute, which is an integer constant to indicate why the given status
* is being returned (UPDATE_NOT_SECURE, UPDATE_NOT_CURRENT, or
* UPDATE_UNKNOWN). This is used for generating the appropriate email
* notification messages during update_cron(), and might be useful for other
* modules that invoke update_runtime_requirements() to find out if the site
* is up to date or not.
*
* @see _update_message_text()
* @see _update_cron_notify()
* @see \Drupal\update\UpdateManagerInterface
*/
#[Hook('runtime_requirements')]
public function runtime(): array {
$requirements = [];
if ($available = update_get_available(FALSE)) {
$this->moduleHandler->loadInclude('update', 'inc', 'update.compare');
$data = update_calculate_project_data($available);
// First, populate the requirements for core:
$requirements['update_core'] = $this->requirementCheck($data['drupal'], 'core');
if (!empty($available['drupal']['releases'])) {
$security_data = ProjectSecurityData::createFromProjectDataAndReleases($data['drupal'], $available['drupal']['releases'])->getCoverageInfo();
if ($core_coverage_requirement = ProjectSecurityRequirement::createFromProjectDataAndSecurityCoverageInfo($data['drupal'], $security_data)->getRequirement()) {
$requirements['coverage_core'] = $core_coverage_requirement;
}
}
// We don't want to check drupal a second time.
unset($data['drupal']);
if (!empty($data)) {
// Now, sort our $data array based on each project's status. The
// status constants are numbered in the right order of precedence, so
// we just need to make sure the projects are sorted in ascending
// order of status, and we can look at the first project we find.
uasort($data, '_update_project_status_sort');
$first_project = reset($data);
$requirements['update_contrib'] = $this->requirementCheck($first_project, 'contrib');
}
}
else {
$requirements['update_core']['title'] = $this->t('Drupal core update status');
$requirements['update_core']['value'] = $this->t('No update data available');
$requirements['update_core']['severity'] = RequirementSeverity::Warning;
$requirements['update_core']['reason'] = UpdateFetcherInterface::UNKNOWN;
$requirements['update_core']['description'] = _update_no_data();
}
return $requirements;
}
/**
* Fills in the requirements array.
*
* This is shared for both core and contrib to generate the right elements in
* the array for hook_runtime_requirements().
*
* @param array $project
* Array of information about the project we're testing as returned by
* update_calculate_project_data().
* @param string $type
* What kind of project this is ('core' or 'contrib').
*
* @return array
* An array to be included in the nested $requirements array.
*
* @see hook_requirements()
* @see update_requirements()
* @see update_calculate_project_data()
*/
protected function requirementCheck($project, $type): array {
$requirement = [];
if ($type == 'core') {
$requirement['title'] = $this->t('Drupal core update status');
}
else {
$requirement['title'] = $this->t('Module and theme update status');
}
$status = $project['status'];
if ($status != UpdateManagerInterface::CURRENT) {
$requirement['reason'] = $status;
$requirement['severity'] = RequirementSeverity::Error;
// When updates are available, append the available updates link to the
// message from _update_message_text(), and format the two translated
// strings together in a single paragraph.
$requirement['description'][] = ['#markup' => _update_message_text($type, $status)];
if (!in_array($status, [UpdateFetcherInterface::UNKNOWN, UpdateFetcherInterface::NOT_CHECKED, UpdateFetcherInterface::NOT_FETCHED, UpdateFetcherInterface::FETCH_PENDING])) {
$requirement['description'][] = ['#prefix' => ' ', '#markup' => $this->t('See the <a href=":available_updates">available updates</a> page for more information.', [':available_updates' => Url::fromRoute('update.status')->toString()])];
}
}
switch ($status) {
case UpdateManagerInterface::NOT_SECURE:
$requirement_label = $this->t('Not secure!');
break;
case UpdateManagerInterface::REVOKED:
$requirement_label = $this->t('Revoked!');
break;
case UpdateManagerInterface::NOT_SUPPORTED:
$requirement_label = $this->t('Unsupported release');
break;
case UpdateManagerInterface::NOT_CURRENT:
$requirement_label = $this->t('Out of date');
$requirement['severity'] = RequirementSeverity::Warning;
break;
case UpdateFetcherInterface::UNKNOWN:
case UpdateFetcherInterface::NOT_CHECKED:
case UpdateFetcherInterface::NOT_FETCHED:
case UpdateFetcherInterface::FETCH_PENDING:
$requirement_label = $project['reason'] ?? $this->t('Can not determine status');
$requirement['severity'] = RequirementSeverity::Warning;
break;
default:
$requirement_label = $this->t('Up to date');
}
if ($status != UpdateManagerInterface::CURRENT && $type == 'core' && isset($project['recommended'])) {
$requirement_label .= ' ' . $this->t('(version @version available)', ['@version' => $project['recommended']]);
}
$requirement['value'] = Link::fromTextAndUrl($requirement_label, Url::fromRoute('update.status'))->toString();
return $requirement;
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace Drupal\update\Plugin\migrate\source;
use Drupal\migrate_drupal\Plugin\migrate\source\Variable;
/**
* Drupal 6/7 Update settings source from database.
*
* For available configuration keys, refer to the parent classes.
*
* @see \Drupal\migrate_drupal\Plugin\migrate\source\Variable
* @see \Drupal\migrate\Plugin\migrate\source\SqlBase
* @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase
*
* @MigrateSource(
* id = "update_settings",
* source_module = "update"
* )
*/
class UpdateSettings extends Variable {
/**
* {@inheritdoc}
*/
protected function values() {
$values = parent::values();
if (empty($values['update_fetch_url']) || str_contains($values['update_fetch_url'], 'http://updates.drupal.org/release-history')) {
$values['update_fetch_url'] = 'https://updates.drupal.org/release-history';
}
return $values;
}
}

View File

@ -0,0 +1,245 @@
<?php
namespace Drupal\update;
use Composer\Semver\Semver;
use Composer\Semver\VersionParser;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Utility class to set core compatibility messages for project releases.
*
* @internal
* This class implements logic used by update_calculate_project_status(). It
* should not be called directly.
*/
final class ProjectCoreCompatibility {
use StringTranslationTrait;
/**
* The currently installed version of Drupal core.
*
* @var string
*/
protected $existingCoreVersion;
/**
* Cache of core versions that are available for updates.
*
* @var string[]
*/
protected $possibleCoreUpdateVersions;
/**
* Cache of core compatibility messages per core version constraint.
*
* Keys are core version constraint strings, values are human-readable
* messages about the versions of core that version constraint maps to.
*
* This list is cached since many project releases will use the same core
* compatibility constraint.
*
* @var string[]
*/
protected $compatibilityMessages = [];
/**
* Constructs a ProjectCoreCompatibility object.
*
* @param array $core_data
* The project data for Drupal core as returned by
* \Drupal\update\UpdateManagerInterface::getProjects() and then processed
* by update_process_project_info() and
* update_calculate_project_update_status().
* @param array $core_releases
* The Drupal core available releases.
* @param array $supported_branches
* An array for supported branches as returned by drupal.org update XML.
*
* @see \Drupal\update\UpdateManagerInterface::getProjects()
* @see update_process_project_info()
* @see update_calculate_project_update_status()
*/
public function __construct(array $core_data, array $core_releases, array $supported_branches) {
if (isset($core_data['existing_version'])) {
$this->existingCoreVersion = $core_data['existing_version'];
$this->possibleCoreUpdateVersions = $this->getPossibleCoreUpdateVersions($core_releases, $supported_branches);
}
}
/**
* Gets the core versions that should be considered for compatibility ranges.
*
* @param array $core_releases
* The Drupal core available releases.
* @param array $supported_branches
* An array for supported branches as returned by drupal.org update XML.
*
* @return string[]
* The core version numbers that are possible to update the site to.
*/
protected function getPossibleCoreUpdateVersions(array $core_releases, array $supported_branches) {
if (!isset($core_releases[$this->existingCoreVersion])) {
// If we can't determine the existing version of core then we can't
// calculate the core compatibility of a given release based on core
// versions after the existing version.
return [];
}
$supported_versions = array_filter(array_keys($core_releases), function ($version) use ($supported_branches) {
foreach ($supported_branches as $supported_branch) {
if (strpos($version, $supported_branch) === 0) {
return TRUE;
}
}
return FALSE;
});
$possible_core_update_versions = Semver::satisfiedBy($supported_versions, '>= ' . $this->existingCoreVersion);
$possible_core_update_versions = Semver::sort($possible_core_update_versions);
$possible_core_update_versions = array_filter($possible_core_update_versions, function ($version) {
return VersionParser::parseStability($version) === 'stable';
});
return $possible_core_update_versions;
}
/**
* Sets core compatibility messages for project releases.
*
* @param array &$project_data
* The project data as returned by
* \Drupal\update\UpdateManagerInterface::getProjects() and then processed
* by update_process_project_info() and
* update_calculate_project_update_status(). If set, the following keys are
* used in this method:
* - recommended (string): A project version number.
* - latest_version (string): A project version number.
* - also (string[]): Project version numbers.
* - releases (array[]): An array where the keys are project version numbers
* and the values are arrays of project release information.
* - security updates (array[]): An array of project release information.
*
* @see \Drupal\update\UpdateManagerInterface::getProjects()
* @see update_process_project_info()
* @see update_calculate_project_update_status()
*/
public function setReleaseMessage(array &$project_data) {
if (empty($this->possibleCoreUpdateVersions)) {
return;
}
// Get the various releases that will need to have core compatibility
// messages added to them.
$releases_to_set = [];
$versions = [];
if (!empty($project_data['recommended'])) {
$versions[] = $project_data['recommended'];
}
if (!empty($project_data['latest_version'])) {
$versions[] = $project_data['latest_version'];
}
if (!empty($project_data['also'])) {
$versions = array_merge($versions, $project_data['also']);
}
foreach ($versions as $version) {
if (isset($project_data['releases'][$version])) {
$releases_to_set[] = &$project_data['releases'][$version];
}
}
if (!empty($project_data['security updates'])) {
foreach ($project_data['security updates'] as &$security_update) {
$releases_to_set[] = &$security_update;
}
}
foreach ($releases_to_set as &$release) {
if (!empty($release['core_compatibility'])) {
$release['core_compatible'] = $this->isCoreCompatible($release['core_compatibility']);
$release['core_compatibility_message'] = $this->createMessageFromCoreCompatibility($release['core_compatibility']);
}
}
}
/**
* Determines if a release is compatible with the currently installed core.
*
* @param string $core_compatibility_constraint
* A semantic version constraint.
*
* @return bool
* TRUE if the given constraint is satisfied by the currently installed
* version of Drupal core, otherwise FALSE.
*/
protected function isCoreCompatible($core_compatibility_constraint) {
try {
return Semver::satisfies($this->existingCoreVersion, $core_compatibility_constraint);
}
catch (\Exception) {
return FALSE;
}
}
/**
* Creates core a compatibility message from a semantic version constraint.
*
* @param string $core_compatibility_constraint
* A semantic version constraint.
*
* @return string
* The core compatibility message.
*/
protected function createMessageFromCoreCompatibility($core_compatibility_constraint) {
if (!isset($this->compatibilityMessages[$core_compatibility_constraint])) {
$core_compatibility_ranges = $this->getCompatibilityRanges($core_compatibility_constraint);
$range_messages = [];
foreach ($core_compatibility_ranges as $core_compatibility_range) {
if (count($core_compatibility_range) === 2) {
$range_messages[] = $this->t('@low_version_number to @high_version_number', ['@low_version_number' => $core_compatibility_range[0], '@high_version_number' => $core_compatibility_range[1]]);
}
else {
$range_messages[] = $core_compatibility_range[0];
}
}
$this->compatibilityMessages[$core_compatibility_constraint] = $this->t('Requires Drupal core:') . ' ' . implode(', ', $range_messages);
}
return $this->compatibilityMessages[$core_compatibility_constraint];
}
/**
* Gets the compatibility ranges for a semantic version constraint.
*
* @param string $core_compatibility_constraint
* A semantic version constraint.
*
* @return array[]
* An array compatibility ranges. If a range array has 2 elements then this
* denotes a range of compatibility between and including the 2 versions. If
* the range has 1 element then it denotes compatibility with a single
* version.
*/
protected function getCompatibilityRanges($core_compatibility_constraint) {
$compatibility_ranges = [];
foreach ($this->possibleCoreUpdateVersions as $possible_core_update_version) {
if (Semver::satisfies($possible_core_update_version, $core_compatibility_constraint)) {
if (empty($range)) {
$range[] = $possible_core_update_version;
}
else {
$range[1] = $possible_core_update_version;
}
}
else {
// If core version does not satisfy the constraint and there is a non
// empty range, add it to the list of ranges.
if (!empty($range)) {
$compatibility_ranges[] = $range;
// Start a new range.
$range = [];
}
}
}
if (!empty($range)) {
$compatibility_ranges[] = $range;
}
return $compatibility_ranges;
}
}

View File

@ -0,0 +1,288 @@
<?php
namespace Drupal\update;
use Symfony\Component\Validator\Constraints\Choice;
use Symfony\Component\Validator\Constraints\Collection;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Optional;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Component\Validator\Validation;
/**
* Provides a project release value object.
*/
final class ProjectRelease {
/**
* Whether the release is compatible with the site's Drupal core version.
*
* @var bool
*/
private $coreCompatible;
/**
* The core compatibility message or NULL if not set.
*
* @var string|null
*/
private $coreCompatibilityMessage;
/**
* The download URL or NULL if none is available.
*
* @var string|null
*/
private $downloadUrl;
/**
* The URL for the release.
*
* @var string
*/
private $releaseUrl;
/**
* The release types or NULL if not set.
*
* @var string[]|null
*/
private $releaseTypes;
/**
* Whether the release is published.
*
* @var bool
*/
private $published;
/**
* The release version.
*
* @var string
*/
private $version;
/**
* The release date as a Unix timestamp or NULL if no date was set.
*
* @var int|null
*/
private $date;
/**
* Constructs a ProjectRelease object.
*
* @param bool $published
* Whether the release is published.
* @param string $version
* The release version.
* @param string $release_url
* The URL for the release.
* @param string[]|null $release_types
* The release types or NULL if not set.
* @param bool|null $core_compatible
* Whether the release is compatible with the site's version of Drupal core.
* @param string|null $core_compatibility_message
* The core compatibility message or NULL if not set.
* @param string|null $download_url
* The download URL or NULL if not available.
* @param int|null $date
* The release date in Unix timestamp format.
*/
private function __construct(bool $published, string $version, string $release_url, ?array $release_types, ?bool $core_compatible, ?string $core_compatibility_message, ?string $download_url, ?int $date) {
$this->published = $published;
$this->version = $version;
$this->releaseUrl = $release_url;
$this->releaseTypes = $release_types;
$this->coreCompatible = $core_compatible;
$this->coreCompatibilityMessage = $core_compatibility_message;
$this->downloadUrl = $download_url;
$this->date = $date;
}
/**
* Creates a ProjectRelease instance from an array.
*
* @param array $release_data
* The project release data as returned by update_get_available().
*
* @return \Drupal\update\ProjectRelease
* The ProjectRelease instance.
*
* @throws \UnexpectedValueException
* Thrown if project release data is not valid.
*
* @see \update_get_available()
*/
public static function createFromArray(array $release_data): ProjectRelease {
static::validateReleaseData($release_data);
return new ProjectRelease(
$release_data['status'] === 'published',
$release_data['version'],
$release_data['release_link'],
$release_data['terms']['Release type'] ?? NULL,
$release_data['core_compatible'] ?? NULL,
$release_data['core_compatibility_message'] ?? NULL,
$release_data['download_link'] ?? NULL,
$release_data['date'] ?? NULL
);
}
/**
* Validates the project release data.
*
* @param array $data
* The project release data.
*
* @throws \UnexpectedValueException
* Thrown if project release data is not valid.
*/
private static function validateReleaseData(array $data): void {
$not_blank_constraints = [
new Type('string'),
new NotBlank(),
];
$collection_constraint = new Collection([
'fields' => [
'version' => $not_blank_constraints,
'date' => new Optional([new Type('numeric')]),
'core_compatible' => new Optional([new Type('boolean')]),
'core_compatibility_message' => new Optional($not_blank_constraints),
'status' => new Choice(['published', 'unpublished']),
'download_link' => new Optional($not_blank_constraints),
'release_link' => $not_blank_constraints,
'terms' => new Optional([
new Type('array'),
new Collection([
'Release type' => new Optional([
new Type('array'),
]),
]),
]),
],
'allowExtraFields' => TRUE,
]);
$violations = Validation::createValidator()->validate($data, $collection_constraint);
if (count($violations)) {
foreach ($violations as $violation) {
$violation_messages[] = "Field " . $violation->getPropertyPath() . ": " . $violation->getMessage();
}
throw new \UnexpectedValueException('Malformed release data: ' . implode(",\n", $violation_messages));
}
}
/**
* Gets the project version.
*
* @return string
* The project version.
*/
public function getVersion(): string {
return $this->version;
}
/**
* Gets the release date if set.
*
* @return int|null
* The date of the release or null if no date is available.
*/
public function getDate(): ?int {
return $this->date;
}
/**
* Determines if the release is a security release.
*
* @return bool
* TRUE if the release is security release, or FALSE otherwise.
*/
public function isSecurityRelease(): bool {
return $this->isReleaseType('Security update');
}
/**
* Determines if the release is unsupported.
*
* @return bool
* TRUE if the release is unsupported, or FALSE otherwise.
*/
public function isUnsupported(): bool {
return $this->isReleaseType('Unsupported');
}
/**
* Determines if the release is insecure.
*
* @return bool
* TRUE if the release is insecure, or FALSE otherwise.
*/
public function isInsecure(): bool {
return $this->isReleaseType('Insecure');
}
/**
* Determines if the release is matches a type.
*
* @param string $type
* The release type.
*
* @return bool
* TRUE if the release matches the type, or FALSE otherwise.
*/
private function isReleaseType(string $type): bool {
return $this->releaseTypes && in_array($type, $this->releaseTypes, TRUE);
}
/**
* Determines if the release is published.
*
* @return bool
* TRUE if the release is published, or FALSE otherwise.
*/
public function isPublished(): bool {
return $this->published;
}
/**
* Determines whether release is compatible the site's version of Drupal core.
*
* @return bool|null
* Whether the release is compatible or NULL if no data is set.
*/
public function isCoreCompatible(): ?bool {
return $this->coreCompatible;
}
/**
* Gets the core compatibility message for the site's version of Drupal core.
*
* @return string|null
* The core compatibility message or NULL if none is available.
*/
public function getCoreCompatibilityMessage(): ?string {
return $this->coreCompatibilityMessage;
}
/**
* Gets the download URL of the release.
*
* @return string|null
* The download URL or NULL if none is available.
*/
public function getDownloadUrl(): ?string {
return $this->downloadUrl;
}
/**
* Gets the URL of the release.
*
* @return string
* The URL of the release.
*/
public function getReleaseUrl(): string {
return $this->releaseUrl;
}
}

View File

@ -0,0 +1,260 @@
<?php
namespace Drupal\update;
use Drupal\Core\Extension\ExtensionVersion;
use Drupal\Core\Utility\Error;
/**
* Calculates a project's security coverage information.
*
* @internal
* This class implements logic to determine security coverage for Drupal core
* according to Drupal core security policy. It should not be called directly.
*/
final class ProjectSecurityData {
/**
* The number of minor versions of Drupal core that receive security coverage.
*
* For example, if this value is 2 and the existing version is 9.0.1, the
* 9.0.x branch will receive security coverage until the release of version
* 9.2.0.
*
* @todo In https://www.drupal.org/node/2998285 determine if we want this
* policy to be expressed in the updates.drupal.org feed, instead of relying
* on a hard-coded constant.
*
* @see https://www.drupal.org/core/release-cycle-overview
*/
const CORE_MINORS_WITH_SECURITY_COVERAGE = 2;
/**
* Define constants for versions with security coverage end dates.
*
* Two types of constants are supported:
* - SECURITY_COVERAGE_END_DATE_[VERSION_MAJOR]_[VERSION_MINOR]: A date in
* 'Y-m-d' or 'Y-m' format.
* - SECURITY_COVERAGE_ENDING_WARN_DATE_[VERSION_MAJOR]_[VERSION_MINOR]: A
* date in 'Y-m-d' format.
*
* @see \Drupal\update\ProjectSecurityRequirement::getDateEndRequirement()
*/
const SECURITY_COVERAGE_END_DATE_9_4 = '2023-06-21';
const SECURITY_COVERAGE_ENDING_WARN_DATE_9_4 = '2022-12-14';
const SECURITY_COVERAGE_END_DATE_9_5 = '2023-11';
const SECURITY_COVERAGE_ENDING_WARN_DATE_9_5 = '2023-05-14';
/**
* The existing (currently installed) version of the project.
*
* Because this class only handles the Drupal core project, values will be
* semantic version numbers such as 8.8.0, 8.8.0-alpha1, or 9.0.0.
*
* @var string|null
*/
protected $existingVersion;
/**
* Releases as returned by update_get_available().
*
* @var array
*
* @see update_get_available()
*/
protected $releases;
/**
* Constructs a ProjectSecurityData object.
*
* @param string $existing_version
* The existing (currently installed) version of the project.
* @param array $releases
* Project releases as returned by update_get_available().
*/
private function __construct($existing_version = NULL, array $releases = []) {
$this->existingVersion = $existing_version;
$this->releases = $releases;
}
/**
* Creates a ProjectSecurityData object from project data and releases.
*
* @param array $project_data
* Project data from Drupal\update\UpdateManagerInterface::getProjects() and
* processed by update_process_project_info().
* @param array $releases
* Project releases as returned by update_get_available().
*
* @return static
*/
public static function createFromProjectDataAndReleases(array $project_data, array $releases) {
if (!($project_data['project_type'] === 'core' && $project_data['name'] === 'drupal')) {
// Only Drupal core has an explicit coverage range.
return new static();
}
return new static($project_data['existing_version'], $releases);
}
/**
* Gets the security coverage information for a project.
*
* Currently only Drupal core is supported.
*
* @return array
* The security coverage information, or an empty array if no security
* information is available for the project. If security coverage is based
* on release of a specific version, the array will have the following
* keys:
* - security_coverage_end_version (string): The minor version the existing
* version will receive security coverage until.
* - additional_minors_coverage (int): The number of additional minor
* versions the existing version will receive security coverage.
* If the security coverage is based on a specific date, the array will have
* the following keys:
* - security_coverage_end_date (string): The month or date security
* coverage will end for the existing version. It can be in either
* 'YYYY-MM' or 'YYYY-MM-DD' format.
* - (optional) security_coverage_ending_warn_date (string): The date, in
* the format 'YYYY-MM-DD', after which a warning should be displayed
* about upgrading to another version.
*/
public function getCoverageInfo() {
if (empty($this->releases[$this->existingVersion])) {
// If the existing version does not have a release, we cannot get the
// security coverage information.
return [];
}
$info = [];
$existing_release_version = ExtensionVersion::createFromVersionString($this->existingVersion);
// Check if the installed version has a specific end date defined.
$version_suffix = $existing_release_version->getMajorVersion() . '_' . $this->getSemanticMinorVersion($this->existingVersion);
if (defined("self::SECURITY_COVERAGE_END_DATE_$version_suffix")) {
$info['security_coverage_end_date'] = constant("self::SECURITY_COVERAGE_END_DATE_$version_suffix");
$info['security_coverage_ending_warn_date'] =
defined("self::SECURITY_COVERAGE_ENDING_WARN_DATE_$version_suffix")
? constant("self::SECURITY_COVERAGE_ENDING_WARN_DATE_$version_suffix")
: NULL;
}
elseif ($security_coverage_until_version = $this->getSecurityCoverageUntilVersion()) {
$info['security_coverage_end_version'] = $security_coverage_until_version;
$info['additional_minors_coverage'] = $this->getAdditionalSecurityCoveredMinors($security_coverage_until_version);
}
return $info;
}
/**
* Gets the release the current minor will receive security coverage until.
*
* For the sake of example, assume that the currently installed version of
* Drupal is 8.7.11 and that static::CORE_MINORS_WITH_SECURITY_COVERAGE is 2.
* When Drupal 8.9.0 is released, the supported minor versions will be 8.8
* and 8.9. At that point, Drupal 8.7 will no longer have security coverage.
* Therefore, this function would return "8.9.0".
*
* @todo In https://www.drupal.org/node/2998285 determine how we will know
* what the final minor release of a particular major version will be. This
* method should not return a version beyond that minor.
*
* @return string|null
* The version the existing version will receive security coverage until or
* NULL if this cannot be determined.
*/
private function getSecurityCoverageUntilVersion() {
$existing_release_version = ExtensionVersion::createFromVersionString($this->existingVersion);
if (!empty($existing_release_version->getVersionExtra())) {
// Only full releases receive security coverage.
return NULL;
}
return $existing_release_version->getMajorVersion() . '.'
. ($this->getSemanticMinorVersion($this->existingVersion) + static::CORE_MINORS_WITH_SECURITY_COVERAGE)
. '.0';
}
/**
* Gets the number of additional minor releases with security coverage.
*
* This function compares the currently installed (existing) version of
* the project with two things:
* - The latest available official release of that project.
* - The target minor release where security coverage for the current release
* should expire. This target release is determined by
* getSecurityCoverageUntilVersion().
*
* For the sake of example, assume that the currently installed version of
* Drupal is 8.7.11 and that static::CORE_MINORS_WITH_SECURITY_COVERAGE is 2.
*
* Before the release of Drupal 8.8.0, this function would return 2.
*
* After the release of Drupal 8.8.0 and before the release of 8.9.0, this
* function would return 1 to indicate that the next minor version release
* will end security coverage for 8.7.
*
* When Drupal 8.9.0 is released, this function would return 0 to indicate
* that security coverage is over for 8.7.
*
* If the currently installed version is 9.0.0, and there is no 9.1.0 release
* yet, the function would return 2. Once 9.1.0 is out, it would return 1.
* When 9.2.0 is released, it would again return 0.
*
* Note: callers should not test this function's return value with empty()
* since 0 is a valid return value that has different meaning than NULL.
*
* @param string $security_covered_version
* The version until which the existing version receives security coverage.
*
* @return int|null
* The number of additional minor releases that receive security coverage,
* or NULL if this cannot be determined.
*
* @see \Drupal\update\ProjectSecurityData\getSecurityCoverageUntilVersion()
*/
private function getAdditionalSecurityCoveredMinors($security_covered_version) {
$security_covered_version_major = ExtensionVersion::createFromVersionString($security_covered_version)->getMajorVersion();
$security_covered_version_minor = $this->getSemanticMinorVersion($security_covered_version);
foreach ($this->releases as $release_info) {
try {
$release = ProjectRelease::createFromArray($release_info);
}
catch (\UnexpectedValueException $exception) {
// Ignore releases that are in an invalid format. Although this is
// highly unlikely we should still process releases in the correct
// format.
Error::logException(\Drupal::logger('update'), $exception, 'Invalid project format: @release', ['@release' => print_r($release_info, TRUE)]);
continue;
}
$release_version = ExtensionVersion::createFromVersionString($release->getVersion());
if ($release_version->getMajorVersion() === $security_covered_version_major && $release->isPublished() && !$release_version->getVersionExtra()) {
// The releases are ordered with the most recent releases first.
// Therefore, if we have found a published, official release with the
// same major version as $security_covered_version, then this release
// can be used to determine the latest minor.
$latest_minor = $this->getSemanticMinorVersion($release->getVersion());
break;
}
}
// If $latest_minor is set, we know that $security_covered_version_minor and
// $latest_minor have the same major version. Therefore, we can subtract to
// determine the number of additional minor releases with security coverage.
return isset($latest_minor) ? $security_covered_version_minor - $latest_minor : NULL;
}
/**
* Gets the minor version for a semantic version string.
*
* @param string $version
* The semantic version string.
*
* @return int
* The minor version as an integer.
*/
private function getSemanticMinorVersion($version) {
return (int) (explode('.', $version)[1]);
}
}

View File

@ -0,0 +1,283 @@
<?php
namespace Drupal\update;
use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Url;
/**
* Class for generating a project's security requirement.
*
* @see update_requirements()
*
* @internal
* This class implements logic to determine security coverage for Drupal core
* according to Drupal core security policy. It should not be called directly.
*/
final class ProjectSecurityRequirement {
use StringTranslationTrait;
/**
* The project title.
*
* @var string|null
*/
protected $projectTitle;
/**
* Security coverage information for the project.
*
* @var array
*
* @see \Drupal\update\ProjectSecurityData::getCoverageInfo()
*/
private $securityCoverageInfo;
/**
* The next version after the installed version in the format [MAJOR].[MINOR].
*
* @var string|null
*/
private $nextMajorMinorVersion;
/**
* The existing (currently installed) version in the format [MAJOR].[MINOR].
*
* @var string|null
*/
private $existingMajorMinorVersion;
/**
* Constructs a ProjectSecurityRequirement object.
*
* @param string|null $project_title
* The project title.
* @param array $security_coverage_info
* Security coverage information as set by
* \Drupal\update\ProjectSecurityData::getCoverageInfo().
* @param string|null $existing_major_minor_version
* The existing (currently installed) version in the format [MAJOR].[MINOR].
* @param string|null $next_major_minor_version
* The next version after the installed version in the format
* [MAJOR].[MINOR].
*/
private function __construct($project_title = NULL, array $security_coverage_info = [], $existing_major_minor_version = NULL, $next_major_minor_version = NULL) {
$this->projectTitle = $project_title;
$this->securityCoverageInfo = $security_coverage_info;
$this->existingMajorMinorVersion = $existing_major_minor_version;
$this->nextMajorMinorVersion = $next_major_minor_version;
}
/**
* Creates a ProjectSecurityRequirement object from project data.
*
* @param array $project_data
* Project data from Drupal\update\UpdateManagerInterface::getProjects().
* The 'security_coverage_info' key should be set by
* calling \Drupal\update\ProjectSecurityData::getCoverageInfo() before
* calling this method. The following keys are used in this method:
* - existing_version (string): The version of the project that is installed
* on the site.
* - project_type (string): The type of project.
* - name (string): The project machine name.
* - title (string): The project title.
* @param array $security_coverage_info
* The security coverage information as returned by
* \Drupal\update\ProjectSecurityData::getCoverageInfo().
*
* @return static
*
* @see \Drupal\update\UpdateManagerInterface::getProjects()
* @see \Drupal\update\ProjectSecurityData::getCoverageInfo()
* @see update_process_project_info()
*/
public static function createFromProjectDataAndSecurityCoverageInfo(array $project_data, array $security_coverage_info) {
if ($project_data['project_type'] !== 'core' || $project_data['name'] !== 'drupal' || empty($security_coverage_info)) {
return new static();
}
if (isset($project_data['existing_version'])) {
[$major, $minor] = explode('.', $project_data['existing_version']);
$existing_version = "$major.$minor";
$next_version = "$major." . ((int) $minor + 1);
return new static($project_data['title'], $security_coverage_info, $existing_version, $next_version);
}
return new static($project_data['title'], $security_coverage_info);
}
/**
* Gets the security coverage requirement, if any.
*
* @return array
* Requirements array as specified by hook_requirements(), or an empty array
* if no requirements can be determined.
*/
public function getRequirement() {
if (isset($this->securityCoverageInfo['security_coverage_end_version'])) {
$requirement = $this->getVersionEndRequirement();
}
elseif (isset($this->securityCoverageInfo['security_coverage_end_date'])) {
$requirement = $this->getDateEndRequirement();
}
else {
return [];
}
$requirement['title'] = $this->t('Drupal core security coverage');
return $requirement;
}
/**
* Gets the requirements based on security coverage until a specific version.
*
* @return array
* Requirements array as specified by hook_requirements().
*/
private function getVersionEndRequirement() {
$requirement = [];
if ($security_coverage_message = $this->getVersionEndCoverageMessage()) {
$requirement['description'] = $security_coverage_message;
if ($this->securityCoverageInfo['additional_minors_coverage'] > 0) {
$requirement['value'] = $this->t(
'Covered until @end_version',
['@end_version' => $this->securityCoverageInfo['security_coverage_end_version']]
);
$requirement['severity'] = $this->securityCoverageInfo['additional_minors_coverage'] > 1 ? RequirementSeverity::Info : RequirementSeverity::Warning;
}
else {
$requirement['value'] = $this->t('Coverage has ended');
$requirement['severity'] = RequirementSeverity::Error;
}
}
return $requirement;
}
/**
* Gets the message for additional minor version security coverage.
*
* @return array[]
* A render array containing security coverage message.
*
* @see \Drupal\update\ProjectSecurityData::getCoverageInfo()
*/
private function getVersionEndCoverageMessage() {
if ($this->securityCoverageInfo['additional_minors_coverage'] > 0) {
// If the installed minor version will receive security coverage until
// newer minor versions are released, inform the user.
if ($this->securityCoverageInfo['additional_minors_coverage'] === 1) {
// If the installed minor version will only receive security coverage
// for 1 newer minor core version, encourage the site owner to update
// soon.
$message['coverage_message'] = [
'#markup' => $this->t(
'<a href=":update_status_report">Update to @next_minor or higher</a> soon to continue receiving security updates.',
[
':update_status_report' => Url::fromRoute('update.status')->toString(),
'@next_minor' => $this->nextMajorMinorVersion,
]
),
'#suffix' => ' ',
];
}
}
else {
// Because the current minor version no longer has security coverage,
// advise the site owner to update.
$message['coverage_message'] = [
'#markup' => $this->getVersionNoSecurityCoverageMessage(),
'#suffix' => ' ',
];
}
$message['release_cycle_link'] = [
'#markup' => $this->getReleaseCycleLink(),
];
return $message;
}
/**
* Gets the security coverage requirement based on an end date.
*
* @return array
* Requirements array as specified by hook_requirements().
*/
private function getDateEndRequirement() {
$requirement = [];
/** @var \Drupal\Component\Datetime\Time $time */
$time = \Drupal::service('datetime.time');
/** @var \Drupal\Core\Datetime\DateFormatterInterface $date_formatter */
$date_formatter = \Drupal::service('date.formatter');
// 'security_coverage_end_date' will either be in format 'Y-m-d' or 'Y-m'.
if (substr_count($this->securityCoverageInfo['security_coverage_end_date'], '-') === 2) {
$date_format = 'Y-m-d';
$full_security_coverage_end_date = $this->securityCoverageInfo['security_coverage_end_date'];
}
else {
$date_format = 'Y-m';
// If the date does not include a day, use '15'. When calling
// \DateTime::createFromFormat() the current day will be used if one is
// not provided. This may cause the month to be wrong at the beginning or
// end of the month. '15' will never be displayed because we are using the
// 'Y-m' format.
$full_security_coverage_end_date = $this->securityCoverageInfo['security_coverage_end_date'] . '-15';
}
$comparable_request_date = $date_formatter->format($time->getRequestTime(), 'custom', $date_format);
if ($this->securityCoverageInfo['security_coverage_end_date'] <= $comparable_request_date) {
// Security coverage is over.
$requirement['value'] = $this->t('Coverage has ended');
$requirement['severity'] = RequirementSeverity::Error;
$requirement['description']['coverage_message'] = [
'#markup' => $this->getVersionNoSecurityCoverageMessage(),
'#suffix' => ' ',
];
}
else {
$security_coverage_end_timestamp = \DateTime::createFromFormat('Y-m-d', $full_security_coverage_end_date)->getTimestamp();
$output_date_format = $date_format === 'Y-m-d' ? 'Y-M-d' : 'Y-M';
$formatted_end_date = $date_formatter
->format($security_coverage_end_timestamp, 'custom', $output_date_format);
$translation_arguments = ['@date' => $formatted_end_date];
$requirement['value'] = $this->t('Covered until @date', $translation_arguments);
$requirement['severity'] = RequirementSeverity::Info;
// 'security_coverage_ending_warn_date' will always be in the format
// 'Y-m-d'.
$request_date = $date_formatter->format($time->getRequestTime(), 'custom', 'Y-m-d');
if (!empty($this->securityCoverageInfo['security_coverage_ending_warn_date']) && $this->securityCoverageInfo['security_coverage_ending_warn_date'] <= $request_date) {
$requirement['description']['coverage_message'] = [
'#markup' => $this->t('Update to a supported version soon to continue receiving security updates.'),
'#suffix' => ' ',
];
$requirement['severity'] = RequirementSeverity::Warning;
}
}
$requirement['description']['release_cycle_link'] = ['#markup' => $this->getReleaseCycleLink()];
return $requirement;
}
/**
* Gets the formatted message for a project with no security coverage.
*
* @return string
* The message for a version with no security coverage.
*/
private function getVersionNoSecurityCoverageMessage() {
return $this->t(
'<a href=":update_status_report">Update to a supported minor</a> as soon as possible to continue receiving security updates.',
[':update_status_report' => Url::fromRoute('update.status')->toString()]
);
}
/**
* Gets a link the release cycle page on drupal.org.
*
* @return string
* A link to the release cycle page on drupal.org.
*/
private function getReleaseCycleLink() {
return $this->t(
'Visit the <a href=":url">release cycle overview</a> for more information on supported releases.',
[':url' => 'https://www.drupal.org/core/release-cycle-overview']
);
}
}

View File

@ -0,0 +1,160 @@
<?php
namespace Drupal\update;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Site\Settings;
use Drupal\Core\Utility\Error;
use GuzzleHttp\ClientInterface;
use Psr\Http\Client\ClientExceptionInterface;
use Psr\Log\LoggerInterface;
/**
* Fetches project information from remote locations.
*/
class UpdateFetcher implements UpdateFetcherInterface {
use DependencySerializationTrait;
/**
* URL to check for updates, if a given project doesn't define its own.
*/
const UPDATE_DEFAULT_URL = 'https://updates.drupal.org/release-history';
/**
* The fetch URL configured in the update settings.
*
* @var string
*/
protected $fetchUrl;
/**
* The update settings.
*
* @var \Drupal\Core\Config\Config
*/
protected $updateSettings;
/**
* The HTTP client to fetch the feed data with.
*
* @var \GuzzleHttp\ClientInterface
*/
protected $httpClient;
/**
* Whether to use HTTP fallback if HTTPS fails.
*
* @var bool
*/
protected $withHttpFallback;
/**
* Constructs an UpdateFetcher.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \GuzzleHttp\ClientInterface $http_client
* A Guzzle client object.
* @param \Drupal\Core\Site\Settings $settings
* The settings instance.
* @param \Psr\Log\LoggerInterface $logger
* The logger.
*/
public function __construct(ConfigFactoryInterface $config_factory, ClientInterface $http_client, Settings $settings, protected LoggerInterface $logger) {
$this->fetchUrl = $config_factory->get('update.settings')->get('fetch.url');
$this->httpClient = $http_client;
$this->updateSettings = $config_factory->get('update.settings');
$this->withHttpFallback = $settings->get('update_fetch_with_http_fallback', FALSE);
}
/**
* {@inheritdoc}
*/
public function fetchProjectData(array $project, $site_key = '') {
$url = $this->buildFetchUrl($project, $site_key);
return $this->doRequest($url, ['headers' => ['Accept' => 'text/xml']], $this->withHttpFallback);
}
/**
* Applies a GET request with a possible HTTP fallback.
*
* This method falls back to HTTP in case there was some certificate
* problem.
*
* @param string $url
* The URL.
* @param array $options
* The guzzle client options.
* @param bool $with_http_fallback
* Should the function fall back to HTTP.
*
* @return string
* The body of the HTTP(S) request, or an empty string on failure.
*/
protected function doRequest(string $url, array $options, bool $with_http_fallback): string {
$data = '';
try {
$data = (string) $this->httpClient
->get($url, ['headers' => ['Accept' => 'text/xml']])
->getBody();
}
catch (ClientExceptionInterface $exception) {
Error::logException($this->logger, $exception);
if ($with_http_fallback && !str_contains($url, "http://")) {
$url = str_replace('https://', 'http://', $url);
return $this->doRequest($url, $options, FALSE);
}
}
return $data;
}
/**
* {@inheritdoc}
*/
public function buildFetchUrl(array $project, $site_key = '') {
$name = $project['name'];
$url = $this->getFetchBaseUrl($project);
$url .= '/' . $name . '/current';
// Only append usage information if we have a site key and the project is
// installed. We do not want to record usage statistics for uninstalled
// projects.
if (!empty($site_key) && !str_contains($project['project_type'], 'disabled')) {
// Append the site key.
$url .= str_contains($url, '?') ? '&' : '?';
$url .= 'site_key=';
$url .= rawurlencode($site_key);
// Append the version.
if (!empty($project['info']['version'])) {
$url .= '&version=';
$url .= rawurlencode($project['info']['version']);
}
// Append the list of modules or themes installed.
$list = array_keys($project['includes']);
$url .= '&list=';
$url .= rawurlencode(implode(',', $list));
}
return $url;
}
/**
* {@inheritdoc}
*/
public function getFetchBaseUrl($project) {
if (isset($project['info']['project status url'])) {
$url = $project['info']['project status url'];
}
else {
$url = $this->fetchUrl;
if (empty($url)) {
$url = static::UPDATE_DEFAULT_URL;
}
}
return $url;
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace Drupal\update;
/**
* Fetches project information from remote locations.
*/
interface UpdateFetcherInterface {
/**
* Project's status cannot be checked.
*/
const NOT_CHECKED = -1;
/**
* No available update data was found for project.
*/
const UNKNOWN = -2;
/**
* There was a failure fetching available update data for this project.
*/
const NOT_FETCHED = -3;
/**
* We need to (re)fetch available update data for this project.
*/
const FETCH_PENDING = -4;
/**
* Returns the base of the URL to fetch available update data for a project.
*
* @param array $project
* The array of project information from
* \Drupal\update\UpdateManager::getProjects().
*
* @return string
* The base of the URL used for fetching available update data. This does
* not include the path elements to specify a particular project, version,
* site_key, etc.
*/
public function getFetchBaseUrl($project);
/**
* Retrieves the project information.
*
* @param array $project
* The array of project information from
* \Drupal\update\UpdateManager::getProjects().
* @param string $site_key
* (optional) The anonymous site key hash. Defaults to an empty string.
*
* @return string
* The project information fetched as string. Empty string upon failure.
*/
public function fetchProjectData(array $project, $site_key = '');
/**
* Generates the URL to fetch information about project updates.
*
* This figures out the right URL to use, based on the project's .info.yml
* file and the global defaults. Appends optional query arguments when the
* site is configured to report usage stats.
*
* @param array $project
* The array of project information from
* \Drupal\update\UpdateManager::getProjects().
* @param string $site_key
* (optional) The anonymous site key hash. Defaults to an empty string.
*
* @return string
* The URL for fetching information about updates to the specified project.
*
* @see \Drupal\update\UpdateProcessor::fetchData()
* @see \Drupal\update\UpdateProcessor::processFetchTask()
* @see \Drupal\update\UpdateManager::getProjects()
*/
public function buildFetchUrl(array $project, $site_key = '');
}

View File

@ -0,0 +1,242 @@
<?php
namespace Drupal\update;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\DependencyInjection\DependencySerializationTrait;
use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Extension\ThemeExtensionList;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\Utility\ProjectInfo;
/**
* Default implementation of UpdateManagerInterface.
*/
class UpdateManager implements UpdateManagerInterface {
use DependencySerializationTrait;
use StringTranslationTrait;
/**
* The update settings.
*
* @var \Drupal\Core\Config\Config
*/
protected $updateSettings;
/**
* Module Handler Service.
*
* @var \Drupal\Core\Extension\ModuleHandlerInterface
*/
protected $moduleHandler;
/**
* Update Processor Service.
*
* @var \Drupal\update\UpdateProcessorInterface
*/
protected $updateProcessor;
/**
* An array of installed projects.
*
* @var array
*/
protected $projects;
/**
* The key/value store.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
*/
protected $keyValueStore;
/**
* Update available releases key/value store.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
*/
protected $availableReleasesTempStore;
/**
* The theme handler.
*
* @var \Drupal\Core\Extension\ThemeHandlerInterface
*/
protected $themeHandler;
/**
* The module extension list.
*
* @var \Drupal\Core\Extension\ModuleExtensionList
*/
protected $moduleExtensionList;
/**
* The theme extension list.
*
* @var \Drupal\Core\Extension\ThemeExtensionList
*/
protected ThemeExtensionList $themeExtensionList;
/**
* Constructs an UpdateManager.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
* The Module Handler service.
* @param \Drupal\update\UpdateProcessorInterface $update_processor
* The Update Processor service.
* @param \Drupal\Core\StringTranslation\TranslationInterface $translation
* The translation service.
* @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_expirable_factory
* The expirable key/value factory.
* @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler
* The theme handler.
* @param \Drupal\Core\Extension\ModuleExtensionList $extension_list_module
* The module extension list.
* @param \Drupal\Core\Extension\ThemeExtensionList $extension_list_theme
* The theme extension list.
*/
public function __construct(ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, UpdateProcessorInterface $update_processor, TranslationInterface $translation, KeyValueFactoryInterface $key_value_expirable_factory, ThemeHandlerInterface $theme_handler, ModuleExtensionList $extension_list_module, ThemeExtensionList $extension_list_theme) {
$this->updateSettings = $config_factory->get('update.settings');
$this->moduleHandler = $module_handler;
$this->updateProcessor = $update_processor;
$this->stringTranslation = $translation;
$this->keyValueStore = $key_value_expirable_factory->get('update');
$this->themeHandler = $theme_handler;
$this->availableReleasesTempStore = $key_value_expirable_factory->get('update_available_releases');
$this->projects = [];
$this->moduleExtensionList = $extension_list_module;
$this->themeExtensionList = $extension_list_theme;
}
/**
* {@inheritdoc}
*/
public function refreshUpdateData() {
// Since we're fetching new available update data, we want to clear
// of both the projects we care about, and the current update status of the
// site. We do *not* want to clear the cache of available releases just yet,
// since that data (even if it's stale) can be useful during
// \Drupal\update\UpdateManager::getProjects(); for example, to modules
// that implement hook_system_info_alter() such as cvs_deploy.
$this->keyValueStore->delete('update_project_projects');
$this->keyValueStore->delete('update_project_data');
$projects = $this->getProjects();
// Now that we have the list of projects, we should also clear the available
// release data, since even if we fail to fetch new data, we need to clear
// out the stale data at this point.
$this->availableReleasesTempStore->deleteAll();
foreach ($projects as $project) {
$this->updateProcessor->createFetchTask($project);
}
}
/**
* {@inheritdoc}
*/
public function getProjects() {
if (empty($this->projects)) {
// Retrieve the projects from storage, if present.
$this->projects = $this->projectStorage('update_project_projects');
if (empty($this->projects)) {
// Still empty, so we have to rebuild.
$module_data = $this->moduleExtensionList->reset()->getList();
$theme_data = $this->themeExtensionList->reset()->getList();
$project_info = new ProjectInfo();
$project_info->processInfoList($this->projects, $module_data, 'module', TRUE);
$project_info->processInfoList($this->projects, $theme_data, 'theme', TRUE);
if ($this->updateSettings->get('check.disabled_extensions')) {
$project_info->processInfoList($this->projects, $module_data, 'module', FALSE);
$project_info->processInfoList($this->projects, $theme_data, 'theme', FALSE);
}
// Allow other modules to alter projects before fetching and comparing.
$this->moduleHandler->alter('update_projects', $this->projects);
// Store the site's project data for at most 1 hour.
$this->keyValueStore->setWithExpire('update_project_projects', $this->projects, 3600);
}
}
return $this->projects;
}
/**
* {@inheritdoc}
*/
public function projectStorage($key) {
$projects = [];
// On certain paths, we should clear the data and recompute the projects for
// update status of the site to avoid presenting stale information.
$route_names = [
'system.modules_list',
'system.theme_install',
'update.status',
'update.settings',
'system.status',
'update.manual_status',
'system.themes_page',
];
if (in_array(\Drupal::routeMatch()->getRouteName(), $route_names)) {
$this->keyValueStore->delete($key);
}
else {
$projects = $this->keyValueStore->get($key, []);
}
return $projects;
}
/**
* {@inheritdoc}
*/
public function fetchDataBatch(&$context) {
if (empty($context['sandbox']['max'])) {
$context['finished'] = 0;
$context['sandbox']['max'] = $this->updateProcessor->numberOfQueueItems();
$context['sandbox']['progress'] = 0;
$context['message'] = $this->t('Checking available update data ...');
$context['results']['updated'] = 0;
$context['results']['failures'] = 0;
$context['results']['processed'] = 0;
}
// Grab another item from the fetch queue.
for ($i = 0; $i < 5; $i++) {
if ($item = $this->updateProcessor->claimQueueItem()) {
if ($this->updateProcessor->processFetchTask($item->data)) {
$context['results']['updated']++;
$context['message'] = $this->t('Checked available update data for %title.', ['%title' => $item->data['info']['name']]);
}
else {
$context['message'] = $this->t('Failed to check available update data for %title.', ['%title' => $item->data['info']['name']]);
$context['results']['failures']++;
}
$context['sandbox']['progress']++;
$context['results']['processed']++;
$context['finished'] = $context['sandbox']['progress'] / $context['sandbox']['max'];
$this->updateProcessor->deleteQueueItem($item);
}
else {
// If the queue is currently empty, we're done. It's possible that
// another thread might have added new fetch tasks while we were
// processing this batch. In that case, the usual 'finished' math could
// get confused, since we'd end up processing more tasks that we thought
// we had when we started and initialized 'max' with numberOfItems(). By
// forcing 'finished' to be exactly 1 here, we ensure that batch
// processing is terminated.
$context['finished'] = 1;
return;
}
}
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace Drupal\update;
/**
* Manages project update information.
*/
interface UpdateManagerInterface {
/**
* Project is missing security update(s).
*/
const NOT_SECURE = 1;
/**
* Current release has been unpublished and is no longer available.
*/
const REVOKED = 2;
/**
* Current release is no longer supported by the project maintainer.
*/
const NOT_SUPPORTED = 3;
/**
* Project has a new release available, but it is not a security release.
*/
const NOT_CURRENT = 4;
/**
* Project is up to date.
*/
const CURRENT = 5;
/**
* Fetches an array of installed projects.
*
* This is only responsible for generating an array of projects (taking into
* account projects that include more than one module or theme). Other
* information like the specific version and install type (official release,
* dev snapshot, etc) is handled later in update_process_project_info() since
* that logic is only required when preparing the status report, not for
* fetching the available release data.
*
* This array is fairly expensive to construct, since it involves a lot of
* disk I/O, so we store the results. However, since this is not the data
* about available updates fetched from the network, it is acceptable to
* invalidate it somewhat quickly. If we keep this data for very long, site
* administrators are more likely to see incorrect results if they upgrade to
* a newer version of a module or theme but do not visit certain pages that
* automatically clear this data.
*
* @return array
* An associative array of currently installed projects keyed by the
* machine-readable project short name. Each project contains:
* - name: The machine-readable project short name.
* - info: An array with values from the main .info.yml file for this
* project.
* - name: The human-readable name of the project.
* - package: The package that the project is grouped under.
* - version: The version of the project.
* - project: The Drupal.org project name.
* - datestamp: The date stamp of the project's main .info.yml file.
* - _info_file_ctime: The maximum file change time for all of the
* .info.yml
* files included in this project.
* - datestamp: The date stamp when the project was released, if known.
* - includes: An associative array containing all projects included with
* this project, keyed by the machine-readable short name with the
* human-readable name as value.
* - project_type: The type of project. Allowed values are 'module' and
* 'theme'.
* - project_status: This indicates if the project is installed and will
* always be TRUE, as the function only returns installed projects.
*
* @see update_process_project_info()
* @see update_calculate_project_data()
* @see \Drupal\update\UpdateManager::projectStorage()
*/
public function getProjects();
/**
* Processes a step in batch for fetching available update data.
*
* Before calling this method, call
* UpdateManagerInterface::refreshUpdateData() to clear existing update data
* and initiate re-fetching.
*
* @param array $context
* Reference to an array used for Batch API storage.
*
* @see \Drupal\update\UpdateManagerInterface::refreshUpdateData()
*/
public function fetchDataBatch(&$context);
/**
* Clears out all the available update data and initiates re-fetching.
*/
public function refreshUpdateData();
/**
* Retrieves update storage data or empties it.
*
* Two very expensive arrays computed by this module are the list of all
* installed modules and themes (and .info.yml data, project associations,
* etc), and the current status of the site relative to the currently
* available releases. These two arrays are stored and used whenever possible.
* The data is cleared whenever the administrator visits the status report,
* available updates report, or the module or theme administration pages,
* since we should always recompute the most current values on any of those
* pages.
*
* Note: while both of these arrays are expensive to compute (in terms of disk
* I/O and some fairly heavy CPU processing), neither of these is the actual
* data about available updates that we have to fetch over the network from
* updates.drupal.org. That information is stored in the
* 'update_available_releases' collection -- it needs to persist longer than 1
* hour and never get invalidated just by visiting a page on the site.
*
* @param string $key
* The key of data to return. Valid options are 'update_project_data' and
* 'update_project_projects'.
*
* @return array
* The stored value of the $projects array generated by
* update_calculate_project_data() or
* \Drupal\update\UpdateManager::getProjects(), or an empty array when the
* storage is cleared.
* array when the storage is cleared.
*/
public function projectStorage($key);
}

View File

@ -0,0 +1,291 @@
<?php
namespace Drupal\update;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Utility\Crypt;
use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
use Drupal\Core\State\StateInterface;
use Drupal\Core\PrivateKey;
use Drupal\Core\Queue\QueueFactory;
/**
* Process project update information.
*/
class UpdateProcessor implements UpdateProcessorInterface {
/**
* The update settings.
*
* @var \Drupal\Core\Config\Config
*/
protected $updateSettings;
/**
* The UpdateFetcher service.
*
* @var \Drupal\update\UpdateFetcherInterface
*/
protected $updateFetcher;
/**
* The update fetch queue.
*
* @var \Drupal\Core\Queue\QueueInterface
*/
protected $fetchQueue;
/**
* Update key/value store.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
*/
protected $tempStore;
/**
* Update Fetch Task Store.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
*/
protected $fetchTaskStore;
/**
* Update available releases store.
*
* @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
*/
protected $availableReleasesTempStore;
/**
* Array of release history URLs that we have failed to fetch.
*
* @var array
*/
protected $failed;
/**
* The state service.
*
* @var \Drupal\Core\State\StateInterface
*/
protected $stateStore;
/**
* The private key.
*
* @var \Drupal\Core\PrivateKey
*/
protected $privateKey;
/**
* The queue for fetching release history data.
*/
protected array $fetchTasks;
/**
* Constructs an UpdateProcessor.
*
* @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
* The config factory.
* @param \Drupal\Core\Queue\QueueFactory $queue_factory
* The queue factory.
* @param \Drupal\update\UpdateFetcherInterface $update_fetcher
* The update fetcher service.
* @param \Drupal\Core\State\StateInterface $state_store
* The state service.
* @param \Drupal\Core\PrivateKey $private_key
* The private key factory service.
* @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_factory
* The key/value factory.
* @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $key_value_expirable_factory
* The expirable key/value factory.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The time service.
*/
public function __construct(
ConfigFactoryInterface $config_factory,
QueueFactory $queue_factory,
UpdateFetcherInterface $update_fetcher,
StateInterface $state_store,
PrivateKey $private_key,
KeyValueFactoryInterface $key_value_factory,
KeyValueExpirableFactoryInterface $key_value_expirable_factory,
protected TimeInterface $time,
) {
$this->updateFetcher = $update_fetcher;
$this->updateSettings = $config_factory->get('update.settings');
$this->fetchQueue = $queue_factory->get('update_fetch_tasks');
$this->tempStore = $key_value_expirable_factory->get('update');
$this->fetchTaskStore = $key_value_factory->get('update_fetch_task');
$this->availableReleasesTempStore = $key_value_expirable_factory->get('update_available_releases');
$this->stateStore = $state_store;
$this->privateKey = $private_key;
$this->fetchTasks = [];
$this->failed = [];
}
/**
* {@inheritdoc}
*/
public function createFetchTask($project) {
if (empty($this->fetchTasks)) {
$this->fetchTasks = $this->fetchTaskStore->getAll();
}
if (empty($this->fetchTasks[$project['name']])) {
$this->fetchQueue->createItem($project);
$this->fetchTaskStore->set($project['name'], $project);
$this->fetchTasks[$project['name']] = $this->time->getRequestTime();
}
}
/**
* {@inheritdoc}
*/
public function fetchData() {
$end = time() + $this->updateSettings->get('fetch.timeout');
if ($this->fetchQueue->numberOfItems()) {
// Delete any stored project data as that needs refreshing when
// update_calculate_project_data() is called.
$this->tempStore->delete('update_project_data');
}
while (time() < $end && ($item = $this->fetchQueue->claimItem())) {
$this->processFetchTask($item->data);
$this->fetchQueue->deleteItem($item);
}
}
/**
* {@inheritdoc}
*/
public function processFetchTask($project) {
global $base_url;
// This can be in the middle of a long-running batch.
$request_time_difference = $this->time->getCurrentTime() - $this->time->getRequestTime();
if (empty($this->failed)) {
// If we have valid data about release history XML servers that we have
// failed to fetch from on previous attempts, load that.
$this->failed = $this->tempStore->get('fetch_failures');
}
$max_fetch_attempts = $this->updateSettings->get('fetch.max_attempts');
$success = FALSE;
$available = [];
$site_key = Crypt::hmacBase64($base_url, $this->privateKey->get());
$fetch_url_base = $this->updateFetcher->getFetchBaseUrl($project);
$project_name = $project['name'];
if (empty($this->failed[$fetch_url_base]) || $this->failed[$fetch_url_base] < $max_fetch_attempts) {
$data = $this->updateFetcher->fetchProjectData($project, $site_key);
}
if (!empty($data)) {
$available = $this->parseXml($data);
// @todo Purge release data we don't need. See
// https://www.drupal.org/node/238950.
if (!empty($available)) {
// Only if we fetched and parsed something sane do we return success.
$success = TRUE;
}
}
else {
$available['project_status'] = 'not-fetched';
if (empty($this->failed[$fetch_url_base])) {
$this->failed[$fetch_url_base] = 1;
}
else {
$this->failed[$fetch_url_base]++;
}
}
$frequency = $this->updateSettings->get('check.interval_days');
$available['last_fetch'] = $this->time->getRequestTime() + $request_time_difference;
$this->availableReleasesTempStore->setWithExpire($project_name, $available, $request_time_difference + (60 * 60 * 24 * $frequency));
// Stash the $this->failed data back in the DB for the next 5 minutes.
$this->tempStore->setWithExpire('fetch_failures', $this->failed, $request_time_difference + (60 * 5));
// Whether this worked or not, we did just (try to) check for updates.
$this->stateStore->set('update.last_check', $this->time->getRequestTime() + $request_time_difference);
// Now that we processed the fetch task for this project, clear out the
// record for this task so we're willing to fetch again.
$this->fetchTaskStore->delete($project_name);
return $success;
}
/**
* Parses the XML of the Drupal release history info files.
*
* @param string $raw_xml
* A raw XML string of available release data for a given project.
*
* @return array
* Array of parsed data about releases for a given project, or NULL if there
* was an error parsing the string.
*/
protected function parseXml($raw_xml) {
try {
$xml = new \SimpleXMLElement($raw_xml);
}
catch (\Exception) {
// SimpleXMLElement::__construct produces an E_WARNING error message for
// each error found in the XML data and throws an exception if errors
// were detected. Catch any exception and return failure (NULL).
return NULL;
}
// If there is no valid project data, the XML is invalid, so return failure.
if (!isset($xml->short_name)) {
return NULL;
}
$data = [];
foreach ($xml as $k => $v) {
$data[$k] = (string) $v;
}
$data['releases'] = [];
if (isset($xml->releases)) {
foreach ($xml->releases->children() as $release) {
$version = (string) $release->version;
$data['releases'][$version] = [];
foreach ($release->children() as $k => $v) {
$data['releases'][$version][$k] = (string) $v;
}
$data['releases'][$version]['terms'] = [];
if ($release->terms) {
foreach ($release->terms->children() as $term) {
if (!isset($data['releases'][$version]['terms'][(string) $term->name])) {
$data['releases'][$version]['terms'][(string) $term->name] = [];
}
$data['releases'][$version]['terms'][(string) $term->name][] = (string) $term->value;
}
}
}
}
return $data;
}
/**
* {@inheritdoc}
*/
public function numberOfQueueItems() {
return $this->fetchQueue->numberOfItems();
}
/**
* {@inheritdoc}
*/
public function claimQueueItem() {
return $this->fetchQueue->claimItem();
}
/**
* {@inheritdoc}
*/
public function deleteQueueItem($item) {
return $this->fetchQueue->deleteItem($item);
}
}

View File

@ -0,0 +1,81 @@
<?php
namespace Drupal\update;
/**
* Processor of project update information.
*/
interface UpdateProcessorInterface {
/**
* Claims an item in the update fetch queue for processing.
*
* @return bool|object
* On success we return an item object. If the queue is unable to claim an
* item it returns false.
*
* @see \Drupal\Core\Queue\QueueInterface::claimItem()
*/
public function claimQueueItem();
/**
* Attempts to drain the queue of tasks for release history data to fetch.
*/
public function fetchData();
/**
* Adds a task to the queue for fetching release history data for a project.
*
* We only create a new fetch task if there's no task already in the queue for
* this particular project (based on 'update_fetch_task' key-value
* collection).
*
* @param array $project
* Associative array of information about a project as created by
* \Drupal\update\UpdateManager::getProjects(), including keys such as
* 'name' (short name), and the 'info' array with data from a .info.yml
* file for the project.
*
* @see \Drupal\update\UpdateManager::getProjects()
* @see update_get_available()
* @see \Drupal\update\UpdateManager::refreshUpdateData()
* @see \Drupal\update\UpdateProcessor::fetchData()
* @see \Drupal\update\UpdateProcessor::processFetchTask()
*/
public function createFetchTask($project);
/**
* Processes a task to fetch available update data for a single project.
*
* Once the release history XML data is downloaded, it is parsed and saved in
* an entry just for that project.
*
* @param array $project
* Associative array of information about the project to fetch data for.
*
* @return bool
* TRUE if we fetched parsable XML, otherwise FALSE.
*/
public function processFetchTask($project);
/**
* Retrieves the number of items in the update fetch queue.
*
* @return int
* An integer estimate of the number of items in the queue.
*
* @see \Drupal\Core\Queue\QueueInterface::numberOfItems()
*/
public function numberOfQueueItems();
/**
* Deletes a finished item from the update fetch queue.
*
* @param object $item
* The item returned by \Drupal\Core\Queue\QueueInterface::claimItem().
*
* @see \Drupal\Core\Queue\QueueInterface::deleteItem()
*/
public function deleteQueueItem($item);
}

View File

@ -0,0 +1,101 @@
<?php
namespace Drupal\update;
use Drupal\Core\DrupalKernelInterface;
use Symfony\Component\HttpFoundation\RequestStack;
/**
* Gets the root path used by the legacy Update Manager.
*
* @deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no
* replacement. Use composer to manage the code for your site.
*
* @see https://www.drupal.org/node/3522119
*/
class UpdateRoot {
/**
* The Drupal kernel.
*
* @var \Drupal\Core\DrupalKernelInterface
*/
protected $drupalKernel;
/**
* The request stack.
*
* @var \Symfony\Component\HttpFoundation\RequestStack
*/
protected $requestStack;
/**
* The update root.
*
* @var string
*/
protected $updateRoot;
/**
* Constructs an UpdateRoot instance.
*
* @param \Drupal\Core\DrupalKernelInterface $drupal_kernel
* The Drupal kernel.
* @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
* The request stack.
*/
public function __construct(DrupalKernelInterface $drupal_kernel, RequestStack $request_stack) {
@trigger_error(__CLASS__ . ' is deprecated in drupal:11.2.0 and is removed from drupal:12.0.0. There is no replacement. Use composer to manage the code for your site. See https://www.drupal.org/node/3522119', E_USER_DEPRECATED);
$this->drupalKernel = $drupal_kernel;
$this->requestStack = $request_stack;
}
/**
* Sets the root path under which projects are installed or updated.
*
* @param string $update_root
* The update root.
*/
public function set(string $update_root): void {
$this->updateRoot = $update_root;
}
/**
* Gets the root path under which projects are installed or updated.
*
* The Update Manager will ensure that project files can only be copied to
* specific subdirectories of this root path.
*
* @return string
* The root path for project installation or update.
*/
public function __toString(): string {
// Return the $updateRoot when it is set.
if (isset($this->updateRoot)) {
return $this->updateRoot;
}
// Normally the Update Manager's root path is the same as the app root (the
// directory in which the Drupal site is installed).
$root_path = $this->drupalKernel->getAppRoot();
// When running in a test site, change the root path to be the testing site
// directory. This ensures that it will always be writable by the webserver
// (thereby allowing the actual extraction and installation of projects by
// the Update Manager to be tested) and also ensures that new project files
// added there won't be visible to the parent site and will be properly
// cleaned up once the test finishes running. This is done here (rather
// than having the tests install a module which overrides the update root
// factory service) to ensure that the parent site is automatically kept
// clean without relying on test authors to take any explicit steps. See
// also \Drupal\update\Tests\Functional\UpdateTestBase::setUp().
if (DRUPAL_TEST_IN_CHILD_SITE) {
$kernel = $this->drupalKernel;
$request = $this->requestStack->getCurrentRequest();
$root_path .= '/' . $kernel::findSitePath($request);
}
return $root_path;
}
}

View File

@ -0,0 +1,145 @@
<?php
namespace Drupal\update;
use Drupal\Core\Form\ConfigFormBase;
use Drupal\Core\Form\ConfigTarget;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Url;
use Drupal\Core\Form\FormStateInterface;
/**
* Configure update settings for this site.
*
* @internal
*/
class UpdateSettingsForm extends ConfigFormBase {
/**
* {@inheritdoc}
*/
public function getFormId() {
return 'update_settings';
}
/**
* {@inheritdoc}
*/
protected function getEditableConfigNames() {
return ['update.settings'];
}
/**
* {@inheritdoc}
*/
public function buildForm(array $form, FormStateInterface $form_state) {
$form['update_check_frequency'] = [
'#type' => 'radios',
'#title' => $this->t('Check for updates'),
'#config_target' => new ConfigTarget(
'update.settings',
'check.interval_days',
toConfig: fn($value) => intval($value)),
'#options' => [
1 => $this->t('Daily'),
7 => $this->t('Weekly'),
],
'#description' => $this->t('Select how frequently you want to automatically check for new releases of your currently installed modules and themes.'),
];
$form['update_check_disabled'] = [
'#type' => 'checkbox',
'#title' => $this->t('Check for updates of uninstalled modules and themes'),
'#config_target' => 'update.settings:check.disabled_extensions',
];
$form['update_notify_emails'] = [
'#type' => 'textarea',
'#title' => $this->t('Email addresses to notify when updates are available'),
'#rows' => 4,
'#config_target' => new ConfigTarget(
'update.settings',
'notification.emails',
static::class . '::arrayToMultiLineString',
static::class . '::multiLineStringToArray',
),
'#description' => $this->t('Whenever your site checks for available updates and finds new releases, it can notify a list of users via email. Put each address on a separate line. If blank, no emails will be sent.'),
];
$form['update_notification_threshold'] = [
'#type' => 'radios',
'#title' => $this->t('Email notification threshold'),
'#config_target' => 'update.settings:notification.threshold',
'#options' => [
'all' => $this->t('All newer versions'),
'security' => $this->t('Only security updates'),
],
'#description' => $this->t(
'You can choose to send email only if a security update is available, or to be notified about all newer versions. If there are updates available of Drupal core or any of your installed modules and themes, your site will always print a message on the <a href=":status_report">status report</a> page. If there is a security update, an error message will be printed on administration pages for users with <a href=":update_permissions">permission to view update notifications</a>.',
[
':status_report' => Url::fromRoute('system.status')->toString(),
':update_permissions' => Url::fromRoute('user.admin_permissions', [], ['fragment' => 'module-update'])
->toString(),
]
),
];
return parent::buildForm($form, $form_state);
}
/**
* {@inheritdoc}
*/
protected function formatMultipleViolationsMessage(string $form_element_name, array $violations): TranslatableMarkup {
if ($form_element_name !== 'update_notify_emails') {
return parent::formatMultipleViolationsMessage($form_element_name, $violations);
}
$invalid_email_addresses = [];
foreach ($violations as $violation) {
$invalid_email_addresses[] = $violation->getInvalidValue();
}
return $this->t('%emails are not valid email addresses.', ['%emails' => implode(', ', $invalid_email_addresses)]);
}
/**
* {@inheritdoc}
*/
public function submitForm(array &$form, FormStateInterface $form_state) {
$config = $this->config('update.settings');
// See if the update_check_disabled setting is being changed, and if so,
// invalidate all update status data.
if ($form_state->getValue('update_check_disabled') != $config->get('check.disabled_extensions')) {
update_storage_clear();
}
parent::submitForm($form, $form_state);
}
/**
* Prepares the submitted value to be stored in the notify_emails property.
*
* @param string $value
* The submitted value.
*
* @return array
* The value to be stored in config.
*/
public static function multiLineStringToArray(string $value): array {
return array_map('trim', explode("\n", trim($value)));
}
/**
* Prepares the saved notify_emails property to be displayed in the form.
*
* @param array $value
* The value saved in config.
*
* @return string
* The value of the form element.
*/
public static function arrayToMultiLineString(array $value): string {
return implode("\n", $value);
}
}