update.manager.inc 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. <?php
  2. /**
  3. * @file
  4. * Administrative screens and processing functions of the Update Manager module.
  5. *
  6. * This allows site administrators with the 'administer software updates'
  7. * permission to either upgrade existing projects, or download and install new
  8. * ones, so long as the killswitch setting ('allow_authorize_operations') is
  9. * not FALSE.
  10. *
  11. * To install new code, the administrator is prompted for either the URL of an
  12. * archive file, or to directly upload the archive file. The archive is loaded
  13. * into a temporary location, extracted, and verified. If everything is
  14. * successful, the user is redirected to authorize.php to type in file transfer
  15. * credentials and authorize the installation to proceed with elevated
  16. * privileges, such that the extracted files can be copied out of the temporary
  17. * location and into the live web root.
  18. *
  19. * Updating existing code is a more elaborate process. The first step is a
  20. * selection form where the user is presented with a table of installed projects
  21. * that are missing newer releases. The user selects which projects they wish to
  22. * update, and presses the "Download updates" button to continue. This sets up a
  23. * batch to fetch all the selected releases, and redirects to
  24. * admin/update/download to display the batch progress bar as it runs. Each
  25. * batch operation is responsible for downloading a single file, extracting the
  26. * archive, and verifying the contents. If there are any errors, the user is
  27. * redirected back to the first page with the error messages. If all downloads
  28. * were extracted and verified, the user is instead redirected to
  29. * admin/update/ready, a landing page which reminds them to backup their
  30. * database and asks if they want to put the site offline during the update.
  31. * Once the user presses the "Install updates" button, they are redirected to
  32. * authorize.php to supply their web root file access credentials. The
  33. * authorized operation (which lives in update.authorize.inc) sets up a batch to
  34. * copy each extracted update from the temporary location into the live web
  35. * root.
  36. */
  37. use Drupal\Core\Url;
  38. use Drupal\Core\File\Exception\FileException;
  39. use Drupal\Core\File\FileSystemInterface;
  40. use Symfony\Component\HttpFoundation\RedirectResponse;
  41. /**
  42. * Batch callback: Performs actions when the download batch is completed.
  43. *
  44. * @param $success
  45. * TRUE if the batch operation was successful, FALSE if there were errors.
  46. * @param $results
  47. * An associative array of results from the batch operation.
  48. */
  49. function update_manager_download_batch_finished($success, $results) {
  50. if (!empty($results['errors'])) {
  51. $item_list = [
  52. '#theme' => 'item_list',
  53. '#title' => t('Downloading updates failed:'),
  54. '#items' => $results['errors'],
  55. ];
  56. \Drupal::messenger()->addError(\Drupal::service('renderer')->render($item_list));
  57. }
  58. elseif ($success) {
  59. \Drupal::messenger()->addStatus(t('Updates downloaded successfully.'));
  60. \Drupal::request()->getSession()->set('update_manager_update_projects', $results['projects']);
  61. return new RedirectResponse(Url::fromRoute('update.confirmation_page', [], ['absolute' => TRUE])->toString());
  62. }
  63. else {
  64. // Ideally we're catching all Exceptions, so they should never see this,
  65. // but just in case, we have to tell them something.
  66. \Drupal::messenger()->addError(t('Fatal error trying to download.'));
  67. }
  68. }
  69. /**
  70. * Checks for file transfer backends and prepares a form fragment about them.
  71. *
  72. * @param array $form
  73. * Reference to the form array we're building.
  74. * @param string $operation
  75. * The update manager operation we're in the middle of. Can be either 'update'
  76. * or 'install'. Use to provide operation-specific interface text.
  77. *
  78. * @return
  79. * TRUE if the update manager should continue to the next step in the
  80. * workflow, or FALSE if we've hit a fatal configuration and must halt the
  81. * workflow.
  82. */
  83. function _update_manager_check_backends(&$form, $operation) {
  84. // If file transfers will be performed locally, we do not need to display any
  85. // warnings or notices to the user and should automatically continue the
  86. // workflow, since we won't be using a FileTransfer backend that requires
  87. // user input or a specific server configuration.
  88. if (update_manager_local_transfers_allowed()) {
  89. return TRUE;
  90. }
  91. // Otherwise, show the available backends.
  92. $form['available_backends'] = [
  93. '#prefix' => '<p>',
  94. '#suffix' => '</p>',
  95. ];
  96. $available_backends = drupal_get_filetransfer_info();
  97. if (empty($available_backends)) {
  98. if ($operation == 'update') {
  99. $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']);
  100. }
  101. else {
  102. $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']);
  103. }
  104. return FALSE;
  105. }
  106. $backend_names = [];
  107. foreach ($available_backends as $backend) {
  108. $backend_names[] = $backend['title'];
  109. }
  110. if ($operation == 'update') {
  111. $form['available_backends']['#markup'] = \Drupal::translation()->formatPlural(
  112. count($available_backends),
  113. '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.',
  114. '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.',
  115. [
  116. '@backends' => implode(', ', $backend_names),
  117. ':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview',
  118. ]);
  119. }
  120. else {
  121. $form['available_backends']['#markup'] = \Drupal::translation()->formatPlural(
  122. count($available_backends),
  123. '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.',
  124. '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.',
  125. [
  126. '@backends' => implode(', ', $backend_names),
  127. ':doc_url' => 'https://www.drupal.org/docs/8/extending-drupal-8/overview',
  128. ]);
  129. }
  130. return TRUE;
  131. }
  132. /**
  133. * Unpacks a downloaded archive file.
  134. *
  135. * @param string $file
  136. * The filename of the archive you wish to extract.
  137. * @param string $directory
  138. * The directory you wish to extract the archive into.
  139. *
  140. * @return \Drupal\Core\Archiver\ArchiverInterface
  141. * The Archiver object used to extract the archive.
  142. *
  143. * @throws Exception
  144. */
  145. function update_manager_archive_extract($file, $directory) {
  146. /** @var \Drupal\Core\Archiver\ArchiverInterface $archiver */
  147. $archiver = \Drupal::service('plugin.manager.archiver')->getInstance([
  148. 'filepath' => $file,
  149. ]);
  150. if (!$archiver) {
  151. throw new Exception("Cannot extract '$file', not a valid archive");
  152. }
  153. // Remove the directory if it exists, otherwise it might contain a mixture of
  154. // old files mixed with the new files (e.g. in cases where files were removed
  155. // from a later release).
  156. $files = $archiver->listContents();
  157. // Unfortunately, we can only use the directory name to determine the project
  158. // name. Some archivers list the first file as the directory (i.e., MODULE/)
  159. // and others list an actual file (i.e., MODULE/README.TXT).
  160. $project = strtok($files[0], '/\\');
  161. $extract_location = $directory . '/' . $project;
  162. if (file_exists($extract_location)) {
  163. try {
  164. \Drupal::service('file_system')->deleteRecursive($extract_location);
  165. }
  166. catch (FileException $e) {
  167. // Ignore failed deletes.
  168. }
  169. }
  170. $archiver->extract($directory);
  171. return $archiver;
  172. }
  173. /**
  174. * Verifies an archive after it has been downloaded and extracted.
  175. *
  176. * This function is responsible for invoking hook_verify_update_archive().
  177. *
  178. * @param string $project
  179. * The short name of the project to download.
  180. * @param string $archive_file
  181. * The filename of the unextracted archive.
  182. * @param string $directory
  183. * The directory that the archive was extracted into.
  184. *
  185. * @return array
  186. * An array of error messages to display if the archive was invalid. If there
  187. * are no errors, it will be an empty array.
  188. */
  189. function update_manager_archive_verify($project, $archive_file, $directory) {
  190. return \Drupal::moduleHandler()->invokeAll('verify_update_archive', [$project, $archive_file, $directory]);
  191. }
  192. /**
  193. * Copies a file from the specified URL to the temporary directory for updates.
  194. *
  195. * Returns the local path if the file has already been downloaded.
  196. *
  197. * @param $url
  198. * The URL of the file on the server.
  199. *
  200. * @return string
  201. * Path to local file.
  202. */
  203. function update_manager_file_get($url) {
  204. $parsed_url = parse_url($url);
  205. $remote_schemes = ['http', 'https', 'ftp', 'ftps', 'smb', 'nfs'];
  206. if (!isset($parsed_url['scheme']) || !in_array($parsed_url['scheme'], $remote_schemes)) {
  207. // This is a local file, just return the path.
  208. return \Drupal::service('file_system')->realpath($url);
  209. }
  210. // Check the cache and download the file if needed.
  211. $cache_directory = _update_manager_cache_directory();
  212. $local = $cache_directory . '/' . \Drupal::service('file_system')->basename($parsed_url['path']);
  213. if (!file_exists($local) || update_delete_file_if_stale($local)) {
  214. return system_retrieve_file($url, $local, FALSE, FileSystemInterface::EXISTS_REPLACE);
  215. }
  216. else {
  217. return $local;
  218. }
  219. }
  220. /**
  221. * Implements callback_batch_operation().
  222. *
  223. * Downloads, unpacks, and verifies a project.
  224. *
  225. * This function assumes that the provided URL points to a file archive of some
  226. * sort. The URL can have any scheme that we have a file stream wrapper to
  227. * support. The file is downloaded to a local cache.
  228. *
  229. * @param string $project
  230. * The short name of the project to download.
  231. * @param string $url
  232. * The URL to download a specific project release archive file.
  233. * @param array $context
  234. * Reference to an array used for Batch API storage.
  235. *
  236. * @see update_manager_download_page()
  237. */
  238. function update_manager_batch_project_get($project, $url, &$context) {
  239. // This is here to show the user that we are in the process of downloading.
  240. if (!isset($context['sandbox']['started'])) {
  241. $context['sandbox']['started'] = TRUE;
  242. $context['message'] = t('Downloading %project', ['%project' => $project]);
  243. $context['finished'] = 0;
  244. return;
  245. }
  246. // Actually try to download the file.
  247. if (!($local_cache = update_manager_file_get($url))) {
  248. $context['results']['errors'][$project] = t('Failed to download %project from %url', ['%project' => $project, '%url' => $url]);
  249. return;
  250. }
  251. // Extract it.
  252. $extract_directory = _update_manager_extract_directory();
  253. try {
  254. update_manager_archive_extract($local_cache, $extract_directory);
  255. }
  256. catch (Exception $e) {
  257. $context['results']['errors'][$project] = $e->getMessage();
  258. return;
  259. }
  260. // Verify it.
  261. $archive_errors = update_manager_archive_verify($project, $local_cache, $extract_directory);
  262. if (!empty($archive_errors)) {
  263. // We just need to make sure our array keys don't collide, so use the
  264. // numeric keys from the $archive_errors array.
  265. foreach ($archive_errors as $key => $error) {
  266. $context['results']['errors']["$project-$key"] = $error;
  267. }
  268. return;
  269. }
  270. // Yay, success.
  271. $context['results']['projects'][$project] = $url;
  272. $context['finished'] = 1;
  273. }
  274. /**
  275. * Determines if file transfers will be performed locally.
  276. *
  277. * If the server is configured such that webserver-created files have the same
  278. * owner as the configuration directory (e.g., sites/default) where new code
  279. * will eventually be installed, the update manager can transfer files entirely
  280. * locally, without changing their ownership (in other words, without prompting
  281. * the user for FTP, SSH or other credentials).
  282. *
  283. * This server configuration is an inherent security weakness because it allows
  284. * a malicious webserver process to append arbitrary PHP code and then execute
  285. * it. However, it is supported here because it is a common configuration on
  286. * shared hosting, and there is nothing Drupal can do to prevent it.
  287. *
  288. * @return
  289. * TRUE if local file transfers are allowed on this server, or FALSE if not.
  290. *
  291. * @see install_check_requirements()
  292. */
  293. function update_manager_local_transfers_allowed() {
  294. $file_system = \Drupal::service('file_system');
  295. // Compare the owner of a webserver-created temporary file to the owner of
  296. // the configuration directory to determine if local transfers will be
  297. // allowed.
  298. $temporary_file = \Drupal::service('file_system')->tempnam('temporary://', 'update_');
  299. $site_path = \Drupal::service('site.path');
  300. $local_transfers_allowed = fileowner($temporary_file) === fileowner($site_path);
  301. // Clean up. If this fails, we can ignore it (since this is just a temporary
  302. // file anyway).
  303. @$file_system->unlink($temporary_file);
  304. return $local_transfers_allowed;
  305. }