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