Newer
Older

Ben Mullins
committed
<?php
namespace Drupal\project_browser\Controller;
use Drupal\Component\Datetime\TimeInterface;
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Extension\ModuleInstallerInterface;
use Drupal\Core\TempStore\SharedTempStore;

Ben Mullins
committed
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
use Drupal\Core\TempStore\SharedTempStoreFactory;
use Drupal\Core\Url;
use Drupal\package_manager\Exception\StageException;
use Drupal\project_browser\ComposerInstaller\Installer;
use Drupal\project_browser\EnabledSourceHandler;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
/**
* Defines a controller to install projects via UI.
*/
class InstallerController extends ControllerBase {
/**
* No require or install in progress for a given module.
*
* @var int
*/
protected const STATUS_IDLE = 0;
/**
* A staging install in progress for a given module.
*
* @var int
*/
protected const STATUS_REQUIRING_PROJECT = 1;
/**
* A core install in progress for a given project.
*
* @var int
*/
protected const STATUS_INSTALLING_PROJECT = 2;
/**
* The endpoint successfully returned the expected data.
*
* @var int
*/
protected const STAGE_STATUS_OK = 0;
/**
* The Project Browser tempstore object.

Ben Mullins
committed
*
* @var \Drupal\Core\TempStore\SharedTempStore
*/
protected SharedTempStore $projectBrowserTempStore;
/**
* Constructor for install controller.
*
* @param \Drupal\project_browser\ComposerInstaller\Installer $installer
* The installer service.
* @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_temp_store_factory
* The temporary storage factory.
* @param \Drupal\Core\Extension\ModuleInstallerInterface $moduleInstaller
* The the module installer.
* @param \Drupal\project_browser\EnabledSourceHandler $enabledSourceHandler
* The enabled project browser source.
* @param \Drupal\Component\Datetime\TimeInterface $time
* The system time.
* @param \Psr\Log\LoggerInterface $logger
* The logger instance.
*/
public function __construct(
private readonly Installer $installer,
SharedTempStoreFactory $shared_temp_store_factory,
private readonly ModuleInstallerInterface $moduleInstaller,
private readonly EnabledSourceHandler $enabledSourceHandler,
private readonly TimeInterface $time,
private readonly LoggerInterface $logger,
) {

Ben Mullins
committed
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
$this->projectBrowserTempStore = $shared_temp_store_factory->get('project_browser');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('project_browser.installer'),
$container->get('project_browser.tempstore.shared'),
$container->get('module_installer'),
$container->get('project_browser.enabled_source'),
$container->get('datetime.time'),
$container->get('logger.channel.project_browser'),
);
}
/**
* Checks if UI install is enabled on the site.
*/
public function access() :AccessResult {
$ui_install = $this->config('project_browser.admin_settings')->get('allow_ui_install');
return AccessResult::allowedIf((bool) $ui_install);
}
/**
* Nulls the installing and core installing states.
*/
private function resetProgress(): void {
$this->projectBrowserTempStore->delete('requiring');
$this->projectBrowserTempStore->delete('installing');
}
/**
* Resets progress and destroys the stage.
*/
private function cancelRequire(): void {
$this->resetProgress();
// Checking the for the presence of a lock in the package manager stage is
// necessary as this method can be called during create(), which includes both
// the PreCreate and PostCreate events. If an exception is caught during
// PreCreate, there's no stage to destroy and an exception would be raised.
// So, we check for the presence of a stage before calling destroy().
if (!$this->installer->isAvailable() && $this->installer->lockCameFromProjectBrowserInstaller()) {
// The risks of forcing a destroy with TRUE are understood, which is why
// we first check if the lock originated from Project Browser. This
// function is called if an exception is thrown during an install. This
// can occur during a phase where the stage might not be claimable, so we
// force-destroy with the TRUE parameter, knowing that the checks above
// will prevent destroying an Automatic Updates stage or a stage that is
// in the process of applying.
$this->installer->destroy(TRUE);

Ben Mullins
committed
}
}
/**
* Returns the status of the project in the temp store.
*
* @param string $project_id
* The project machine name.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Information about the project's require/install status.
*
* If the project is being required, the response will include which require
* phase is currently occurring.
*
* When a project is required via the UI, the UI fetches this endpoint
* regularly so it can monitor the progress of the process and report which
* stage is taking place.
*/
public function inProgress(string $project_id): JsonResponse {
$requiring = $this->projectBrowserTempStore->get('requiring');
$core_installing = $this->projectBrowserTempStore->get('installing');
$return = ['status' => self::STATUS_IDLE];

Ben Mullins
committed
if (isset($requiring['project_id']) && $requiring['project_id'] === $project_id) {
$return['status'] = self::STATUS_REQUIRING_PROJECT;
$return['phase'] = $requiring['phase'];

Ben Mullins
committed
}
if ($core_installing === $project_id) {
$return['status'] = self::STATUS_INSTALLING_PROJECT;

Ben Mullins
committed
}

Ben Mullins
committed
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
return new JsonResponse($return);
}
/**
* Provides a JSON response for a given error.
*
* @param \Exception $e
* The error that occurred.
* @param string $phase
* The phase the error occurred in.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Provides an error message to be displayed by the Project Browser UI.
*/
private function errorResponse(\Exception $e, string $phase = ''): JsonResponse {
$exception_type_short = (new \ReflectionClass($e))->getShortName();
$exception_message = $e->getMessage();
$response_body = ['message' => "$exception_type_short: $exception_message"];
$this->logger->warning('@exception_type: @exception_message. @trace ', [
'@exception_type' => get_class($e),
'@exception_message' => $exception_message,
'@trace' => $e->getTraceAsString(),
]);
if (!empty($phase)) {
$response_body['phase'] = $phase;
}
return new JsonResponse($response_body, 500);
}
/**
* Provides a JSON response for a successful request.
*
* @param string $phase
* The phase the request was made in.
* @param string|null $stage_id
* The stage id of the installer within the request.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Provides information about the completed operation.
*/
private function successResponse(string $phase, ?string $stage_id = NULL): JsonResponse {
$response_body = [
'phase' => $phase,
'status' => self::STAGE_STATUS_OK,
];
if (!empty($stage_id)) {
$response_body['stage_id'] = $stage_id;
}
return new JsonResponse($response_body);
}
/**
* Provides a JSON response for require requests while the stage is locked.
*
* @param string $message
* The message content of the response.
* @param string $unlock_url
* An unlock url provided in instances where unlocking is safe.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Provides a message regarding the status of the staging lock.
*
* If the stage is not in a phase where it is unsafe to unlock, a CSRF
* protected unlock URL is also provided.
*/
private function lockedResponse(string $message, string $unlock_url = ''): JsonResponse {
return new JsonResponse([
'message' => $message,
'unlock_url' => $unlock_url,
], 418);
}
/**
* Updates the 'requiring' state in the temp store.
*
* @param string $project_id
* The module being required.
* @param string $phase
* The require phase in progress.
* @param string $stage_id
* The stage id.
*/
private function setRequiringState(string $project_id, string $phase, string $stage_id = ''): void {
$this->projectBrowserTempStore->set('requiring', [
'project_id' => $project_id,
'phase' => $phase,
'stage_id' => $stage_id,
]);
}
/**
* Unlocks and destroys the stage.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
* Redirects to the main project browser page.
*
* @todo add return type when php 7.4 support ends.
*/
public function unlock() {
try {
// It's possible the unlock url was provided before applying began, but
// accessed after. This final check ensures a destroy is not attempted
// during apply.
if ($this->installer->isApplying()) {

James Shields
committed
throw new StageException($this->installer, 'A stage can not be unlocked while applying');

Ben Mullins
committed
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
}
// Adding the TRUE parameter to destroy is dangerous, but we provide it
// here for a few reasons.
// - This endpoint is only available if it's confirmed the stage lock was
// created by Drupal\project_browser\ComposerInstaller\Installer.
// - This endpoint is not available if the stage is applying.
// - In the event of a flawed install, we want it to be possible for users
// to unlock the stage via the GUI, even if they're not the user that
// initiated the install.
// - The unlock link is accompanied by information regarding when the
// stage was locked, and warns the user when the time is recent enough
// that they risk aborting a legitimate install.
$this->installer->destroy(TRUE);
}
catch (\Exception $e) {
return $this->errorResponse($e);
}
$this->projectBrowserTempStore->delete('requiring');
$this->messenger()->addStatus($this->t('Install staging area unlocked.'));
return $this->redirect('project_browser.browse');
}
/**
* Gets the given URL with all placeholders replaced.
*
* @param \Drupal\Core\Url $url
* A URL which generates CSRF token placeholders.
*
* @return string
* The URL string, with all placeholders replaced.
*/
private static function getUrlWithReplacedCsrfTokenPlaceholder(Url $url): string {
$generated_url = $url->toString(TRUE);
$url_with_csrf_token_placeholder = [
'#plain_text' => $generated_url->getGeneratedUrl(),
];
$generated_url->applyTo($url_with_csrf_token_placeholder);
return (string) \Drupal::service('renderer')->renderPlain($url_with_csrf_token_placeholder);
}
/**
* Begins requiring by creating a stage.
*
* @param string $composer_namespace
* The project composer namespace.
* @param string $project_id
* The project id.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function begin(string $composer_namespace, string $project_id): JsonResponse {
// @todo Expand to support other plugins in https://drupal.org/i/3312354.
$source = $this->enabledSourceHandler->getCurrentSources()['drupalorg_mockapi'] ?? NULL;
if ($source === NULL) {
return new JsonResponse(['message' => "Cannot download $project_id from any available source"], 500);
}
if (!$source->isProjectSafe($project_id)) {
return new JsonResponse(['message' => "$project_id is not safe to add because its security coverage has been revoked"], 500);
}
$stage_available = $this->installer->isAvailable();
if (!$stage_available) {
$requiring_metadata = $this->projectBrowserTempStore->getMetadata('requiring');
if (!$this->installer->lockCameFromProjectBrowserInstaller()) {
return $this->lockedResponse($this->t('The installation stage is locked by a process outside of Project Browser'), '');
}
if (empty($requiring_metadata)) {
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);
$message = t('An install staging area claimed by Project Browser exists but has expired. You may unlock the stage and try the install again.');
return $this->lockedResponse($message, $unlock_url);
}
$time_since_updated = $this->time->getRequestTime() - $requiring_metadata->getUpdated();
$hours = (int) gmdate("H", $time_since_updated);
$minutes = (int) gmdate("i", $time_since_updated);
$minutes = $time_since_updated > 60 ? $minutes : 'less than 1';
if ($this->installer->isApplying()) {
$message = empty(floor($hours)) ?
$this->t('The install staging area was locked @minutes minutes ago. It should not be unlocked as the changes from staging are being applied to the site.', ['@minutes' => $minutes]) :
$this->t('The install staging area was locked @hours hours, @minutes minutes ago. It should not be unlocked as the changes from staging are being applied to the site.', ['@hours' => $hours, '@minutes' => $minutes]);
return $this->lockedResponse($message, '');
}
elseif ($hours === 0 && ($minutes < 7 || $minutes === 'less than 1')) {
$message = $this->t('The install staging area was locked @minutes minutes ago. This is recent enough that a legitimate installation may be in progress. Consider waiting before unlocking the installation staging area.', ['@minutes' => $minutes]);
}
else {
$message = empty($hours) ?
$this->t('The install staging area was locked @minutes minutes ago.', ['@minutes' => $minutes]) :
$this->t('The install staging area was locked @hours hours, @minutes minutes ago.', ['@hours' => $hours, '@minutes' => $minutes]);
}
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);
return $this->lockedResponse($message, $unlock_url);
}
try {
$stage_id = $this->installer->create();
$this->setRequiringState($project_id, 'creating install stage', $stage_id);

Ben Mullins
committed
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
}
catch (\Exception $e) {
$this->cancelRequire();
return $this->errorResponse($e, 'create');
}
return $this->successResponse('create', $stage_id);
}
/**
* Performs require operations on the stage.
*
* @param string $composer_namespace
* The project composer namespace.
* @param string $project_id
* The project id.
* @param string $stage_id
* ID of stage created in the begin() method.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function require(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
$requiring = $this->projectBrowserTempStore->get('requiring');
if (empty($requiring['project_id']) || $requiring['project_id'] !== $project_id) {
return new JsonResponse([
'message' => sprintf('Error: a request to install %s was ignored as an install for a different module is in progress.', $project_id),
], 500);
}
$this->setRequiringState($project_id, 'requiring module', $stage_id);
try {
$this->installer->claim($stage_id)->require(["$composer_namespace/$project_id"]);
}
catch (\Exception $e) {
$this->cancelRequire();
return $this->errorResponse($e, 'require');
}
return $this->successResponse('require', $stage_id);
}
/**
* Performs apply operations on the stage.
*
* @param string $composer_namespace
* The project composer namespace.
* @param string $project_id
* The project id.
* @param string $stage_id
* ID of stage created in the begin() method.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function apply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
$this->setRequiringState($project_id, 'applying', $stage_id);
try {
$this->installer->claim($stage_id)->apply();
}
catch (\Exception $e) {
$this->cancelRequire();
return $this->errorResponse($e, 'apply');
}
return $this->successResponse('apply', $stage_id);
}
/**
* Performs post apply operations on the stage.
*
* @param string $composer_namespace
* The project composer namespace.
* @param string $project_id
* The project id.
* @param string $stage_id
* ID of stage created in the begin() method.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function postApply(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
$this->setRequiringState($project_id, 'post apply', $stage_id);
try {
$this->installer->claim($stage_id)->postApply();
}
catch (\Exception $e) {
return $this->errorResponse($e, 'post apply');
}
return $this->successResponse('post apply', $stage_id);
}
/**
* Performs destroy operations on the stage.
*
* @param string $composer_namespace
* The project composer namespace.
* @param string $project_id
* The project id.
* @param string $stage_id
* ID of stage created in the begin() method.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function destroy(string $composer_namespace, string $project_id, string $stage_id): JsonResponse {
$this->setRequiringState($project_id, 'completing', $stage_id);
try {
$this->installer->claim($stage_id)->destroy();
}
catch (\Exception $e) {
return $this->errorResponse($e, 'destroy');
}
$this->projectBrowserTempStore->delete('requiring');
return new JsonResponse([
'phase' => 'destroy',
'status' => self::STAGE_STATUS_OK,
'stage_id' => $stage_id,
]);
}
/**
* Installs an already downloaded module.
*
* @param string $project_id
* The project machine name.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function activateModule(string $project_id): JsonResponse {
$this->projectBrowserTempStore->set('installing', $project_id);
try {
$this->moduleInstaller->install([$project_id]);
}
catch (\Exception $e) {
$this->resetProgress();
return $this->errorResponse($e, 'project install');
}
$this->projectBrowserTempStore->delete('installing');
return new JsonResponse([
'status' => 0,
]);