update.manager.inc 13.3 KB
Newer Older
1 2 3 4
<?php

/**
 * @file
5 6
 * Administrative screens and processing functions of the Update Manager module.
 *
7 8 9
 * This allows site administrators with the 'administer software updates'
 * permission to either upgrade existing projects, or download and install new
 * ones, so long as the killswitch setting ('allow_authorize_operations') is
10
 * not FALSE.
11 12 13 14
 *
 * To install new code, the administrator is prompted for either the URL of an
 * archive file, or to directly upload the archive file. The archive is loaded
 * into a temporary location, extracted, and verified. If everything is
15 16 17 18
 * successful, the user is redirected to authorize.php to type in file transfer
 * credentials and authorize the installation to proceed with elevated
 * privileges, such that the extracted files can be copied out of the temporary
 * location and into the live web root.
19 20
 *
 * Updating existing code is a more elaborate process. The first step is a
21 22 23 24 25 26 27 28
 * selection form where the user is presented with a table of installed projects
 * that are missing newer releases. The user selects which projects they wish to
 * update, and presses the "Download updates" button to continue. This sets up a
 * batch to fetch all the selected releases, and redirects to
 * admin/update/download to display the batch progress bar as it runs. Each
 * batch operation is responsible for downloading a single file, extracting the
 * archive, and verifying the contents. If there are any errors, the user is
 * redirected back to the first page with the error messages. If all downloads
29
 * were extracted and verified, the user is instead redirected to
30 31 32 33 34 35 36
 * admin/update/ready, a landing page which reminds them to backup their
 * database and asks if they want to put the site offline during the update.
 * Once the user presses the "Install updates" button, they are redirected to
 * authorize.php to supply their web root file access credentials. The
 * authorized operation (which lives in update.authorize.inc) sets up a batch to
 * copy each extracted update from the temporary location into the live web
 * root.
37
 */
38

39
use Drupal\Core\Url;
40
use Drupal\Core\File\Exception\FileException;
41
use Drupal\Core\File\FileSystemInterface;
42
use Symfony\Component\HttpFoundation\RedirectResponse;
43

44
/**
45 46 47 48 49 50
 * Batch callback: Performs actions when the download batch is completed.
 *
 * @param $success
 *   TRUE if the batch operation was successful, FALSE if there were errors.
 * @param $results
 *   An associative array of results from the batch operation.
51 52
 */
function update_manager_download_batch_finished($success, $results) {
53
  if (!empty($results['errors'])) {
54
    $item_list = [
55 56 57
      '#theme' => 'item_list',
      '#title' => t('Downloading updates failed:'),
      '#items' => $results['errors'],
58
    ];
59
    \Drupal::messenger()->addError(\Drupal::service('renderer')->render($item_list));
60 61
  }
  elseif ($success) {
62
    \Drupal::messenger()->addStatus(t('Updates downloaded successfully.'));
63
    \Drupal::request()->getSession()->set('update_manager_update_projects', $results['projects']);
64
    return new RedirectResponse(Url::fromRoute('update.confirmation_page', [], ['absolute' => TRUE])->toString());
65 66
  }
  else {
67 68
    // Ideally we're catching all Exceptions, so they should never see this,
    // but just in case, we have to tell them something.
69
    \Drupal::messenger()->addError(t('Fatal error trying to download.'));
70 71 72
  }
}

73 74 75 76 77 78
/**
 * Checks for file transfer backends and prepares a form fragment about them.
 *
 * @param array $form
 *   Reference to the form array we're building.
 * @param string $operation
79 80
 *   The update manager operation we're in the middle of. Can be either 'update'
 *   or 'install'. Use to provide operation-specific interface text.
81 82
 *
 * @return
83
 *   TRUE if the update manager should continue to the next step in the
84 85 86 87 88 89 90 91 92 93 94 95 96
 *   workflow, or FALSE if we've hit a fatal configuration and must halt the
 *   workflow.
 */
function _update_manager_check_backends(&$form, $operation) {
  // If file transfers will be performed locally, we do not need to display any
  // warnings or notices to the user and should automatically continue the
  // workflow, since we won't be using a FileTransfer backend that requires
  // user input or a specific server configuration.
  if (update_manager_local_transfers_allowed()) {
    return TRUE;
  }

  // Otherwise, show the available backends.
97
  $form['available_backends'] = [
98 99
    '#prefix' => '<p>',
    '#suffix' => '</p>',
100
  ];
101 102 103 104

  $available_backends = drupal_get_filetransfer_info();
  if (empty($available_backends)) {
    if ($operation == 'update') {
105
      $form['available_backends']['#markup'] = t('Your server does not support updating modules and themes from this interface. Instead, update modules and themes by uploading the new versions directly to the server, as documented in <a href=":doc_url">Extending Drupal 8</a>.', [':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview']);
106 107
    }
    else {
108
      $form['available_backends']['#markup'] = t('Your server does not support installing modules and themes from this interface. Instead, install modules and themes by uploading them directly to the server, as documented in <a href=":doc_url">Extending Drupal 8</a>.', [':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview']);
109 110 111 112
    }
    return FALSE;
  }

113
  $backend_names = [];
114 115 116 117
  foreach ($available_backends as $backend) {
    $backend_names[] = $backend['title'];
  }
  if ($operation == 'update') {
118
    $form['available_backends']['#markup'] = \Drupal::translation()->formatPlural(
119
      count($available_backends),
120 121
      'Updating modules and themes requires <strong>@backends access</strong> to your server. See <a href=":doc_url">Extending Drupal 8</a> for other update methods.',
      'Updating modules and themes requires access to your server via one of the following methods: <strong>@backends</strong>. See <a href=":doc_url">Extending Drupal 8</a> for other update methods.',
122
      [
123
        '@backends' => implode(', ', $backend_names),
124
        ':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview',
125
      ]);
126 127
  }
  else {
128
    $form['available_backends']['#markup'] = \Drupal::translation()->formatPlural(
129
      count($available_backends),
130 131
      'Installing modules and themes requires <strong>@backends access</strong> to your server. See <a href=":doc_url">Extending Drupal 8</a> for other installation methods.',
      'Installing modules and themes requires access to your server via one of the following methods: <strong>@backends</strong>. See <a href=":doc_url">Extending Drupal 8</a> for other installation methods.',
132
      [
133
        '@backends' => implode(', ', $backend_names),
134
        ':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview',
135
      ]);
136 137 138 139
  }
  return TRUE;
}

140
/**
141
 * Unpacks a downloaded archive file.
142 143 144 145
 *
 * @param string $file
 *   The filename of the archive you wish to extract.
 * @param string $directory
146
 *   The directory you wish to extract the archive into.
147
 *
148
 * @return \Drupal\Core\Archiver\ArchiverInterface
149
 *   The Archiver object used to extract the archive.
150 151
 *
 * @throws Exception
152 153
 */
function update_manager_archive_extract($file, $directory) {
154 155 156 157
  /** @var \Drupal\Core\Archiver\ArchiverInterface $archiver */
  $archiver = \Drupal::service('plugin.manager.archiver')->getInstance([
    'filepath' => $file,
  ]);
158
  if (!$archiver) {
159
    throw new Exception("Cannot extract '$file', not a valid archive");
160
  }
161 162 163 164 165

  // Remove the directory if it exists, otherwise it might contain a mixture of
  // old files mixed with the new files (e.g. in cases where files were removed
  // from a later release).
  $files = $archiver->listContents();
166 167 168 169 170 171

  // Unfortunately, we can only use the directory name to determine the project
  // name. Some archivers list the first file as the directory (i.e., MODULE/)
  // and others list an actual file (i.e., MODULE/README.TXT).
  $project = strtok($files[0], '/\\');

172 173
  $extract_location = $directory . '/' . $project;
  if (file_exists($extract_location)) {
174 175 176 177 178 179
    try {
      \Drupal::service('file_system')->deleteRecursive($extract_location);
    }
    catch (FileException $e) {
      // Ignore failed deletes.
    }
180 181
  }

182 183
  $archiver->extract($directory);
  return $archiver;
184 185 186
}

/**
187
 * Verifies an archive after it has been downloaded and extracted.
188 189 190 191 192 193 194 195 196 197
 *
 * This function is responsible for invoking hook_verify_update_archive().
 *
 * @param string $project
 *   The short name of the project to download.
 * @param string $archive_file
 *   The filename of the unextracted archive.
 * @param string $directory
 *   The directory that the archive was extracted into.
 *
198
 * @return array
199 200
 *   An array of error messages to display if the archive was invalid. If there
 *   are no errors, it will be an empty array.
201 202
 */
function update_manager_archive_verify($project, $archive_file, $directory) {
203
  return \Drupal::moduleHandler()->invokeAll('verify_update_archive', [$project, $archive_file, $directory]);
204 205 206
}

/**
207
 * Copies a file from the specified URL to the temporary directory for updates.
208
 *
209
 * Returns the local path if the file has already been downloaded.
210 211 212 213 214 215 216 217 218
 *
 * @param $url
 *   The URL of the file on the server.
 *
 * @return string
 *   Path to local file.
 */
function update_manager_file_get($url) {
  $parsed_url = parse_url($url);
219
  $remote_schemes = ['http', 'https', 'ftp', 'ftps', 'smb', 'nfs'];
220
  if (!isset($parsed_url['scheme']) || !in_array($parsed_url['scheme'], $remote_schemes)) {
221
    // This is a local file, just return the path.
222
    return \Drupal::service('file_system')->realpath($url);
223 224 225
  }

  // Check the cache and download the file if needed.
226
  $cache_directory = _update_manager_cache_directory();
227
  $local = $cache_directory . '/' . \Drupal::service('file_system')->basename($parsed_url['path']);
228

229
  if (!file_exists($local) || update_delete_file_if_stale($local)) {
230
    return system_retrieve_file($url, $local, FALSE, FileSystemInterface::EXISTS_REPLACE);
231 232 233 234 235 236 237
  }
  else {
    return $local;
  }
}

/**
238 239 240
 * Implements callback_batch_operation().
 *
 * Downloads, unpacks, and verifies a project.
241
 *
242 243 244
 * This function assumes that the provided URL points to a file archive of some
 * sort. The URL can have any scheme that we have a file stream wrapper to
 * support. The file is downloaded to a local cache.
245 246 247 248 249
 *
 * @param string $project
 *   The short name of the project to download.
 * @param string $url
 *   The URL to download a specific project release archive file.
250
 * @param array $context
251
 *   Reference to an array used for Batch API storage.
252 253 254 255 256 257 258
 *
 * @see update_manager_download_page()
 */
function update_manager_batch_project_get($project, $url, &$context) {
  // This is here to show the user that we are in the process of downloading.
  if (!isset($context['sandbox']['started'])) {
    $context['sandbox']['started'] = TRUE;
259
    $context['message'] = t('Downloading %project', ['%project' => $project]);
260 261 262 263 264 265
    $context['finished'] = 0;
    return;
  }

  // Actually try to download the file.
  if (!($local_cache = update_manager_file_get($url))) {
266
    $context['results']['errors'][$project] = t('Failed to download %project from %url', ['%project' => $project, '%url' => $url]);
267 268 269 270 271 272 273 274 275
    return;
  }

  // Extract it.
  $extract_directory = _update_manager_extract_directory();
  try {
    update_manager_archive_extract($local_cache, $extract_directory);
  }
  catch (Exception $e) {
276
    $context['results']['errors'][$project] = $e->getMessage();
277 278 279 280
    return;
  }

  // Verify it.
281 282 283 284 285 286 287
  $archive_errors = update_manager_archive_verify($project, $local_cache, $extract_directory);
  if (!empty($archive_errors)) {
    // We just need to make sure our array keys don't collide, so use the
    // numeric keys from the $archive_errors array.
    foreach ($archive_errors as $key => $error) {
      $context['results']['errors']["$project-$key"] = $error;
    }
288 289 290 291
    return;
  }

  // Yay, success.
292
  $context['results']['projects'][$project] = $url;
293 294 295
  $context['finished'] = 1;
}

296 297 298 299
/**
 * Determines if file transfers will be performed locally.
 *
 * If the server is configured such that webserver-created files have the same
300 301
 * owner as the configuration directory (e.g., sites/default) where new code
 * will eventually be installed, the update manager can transfer files entirely
302 303 304 305 306 307 308 309 310 311 312 313 314 315
 * locally, without changing their ownership (in other words, without prompting
 * the user for FTP, SSH or other credentials).
 *
 * This server configuration is an inherent security weakness because it allows
 * a malicious webserver process to append arbitrary PHP code and then execute
 * it. However, it is supported here because it is a common configuration on
 * shared hosting, and there is nothing Drupal can do to prevent it.
 *
 * @return
 *   TRUE if local file transfers are allowed on this server, or FALSE if not.
 *
 * @see install_check_requirements()
 */
function update_manager_local_transfers_allowed() {
316
  $file_system = \Drupal::service('file_system');
317 318 319
  // Compare the owner of a webserver-created temporary file to the owner of
  // the configuration directory to determine if local transfers will be
  // allowed.
320
  $temporary_file = \Drupal::service('file_system')->tempnam('temporary://', 'update_');
321
  $site_path = \Drupal::getContainer()->getParameter('site.path');
322
  $local_transfers_allowed = fileowner($temporary_file) === fileowner($site_path);
323 324 325

  // Clean up. If this fails, we can ignore it (since this is just a temporary
  // file anyway).
326
  @$file_system->unlink($temporary_file);
327 328 329

  return $local_transfers_allowed;
}