Newer
Older

Ben Mullins
committed
<?php
namespace Drupal\project_browser\Controller;
use Drupal\Component\Datetime\TimeInterface;

James Shields
committed
use Drupal\Component\Utility\DeprecationHelper;

Ben Mullins
committed
use Drupal\Core\Access\AccessResult;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\TempStore\SharedTempStore;

Ben Mullins
committed
use Drupal\Core\TempStore\SharedTempStoreFactory;
use Drupal\Core\Url;
use Drupal\package_manager\Exception\StageException;
use Drupal\project_browser\ActivatorInterface;

Ben Mullins
committed
use Drupal\project_browser\ComposerInstaller\Installer;
use Drupal\project_browser\EnabledSourceHandler;
use Drupal\project_browser\ProjectBrowser\Project;

Ben Mullins
committed
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;

Ben Mullins
committed
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
53
54
55
56
/**
* 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\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.
* @param \Drupal\project_browser\ActivatorInterface $activator
* The project activator service.
public function __construct(
private readonly Installer $installer,
SharedTempStoreFactory $shared_temp_store_factory,
private readonly EnabledSourceHandler $enabledSourceHandler,
private readonly TimeInterface $time,
private readonly LoggerInterface $logger,
private readonly ActivatorInterface $activator,
) {

Ben Mullins
committed
$this->projectBrowserTempStore = $shared_temp_store_factory->get('project_browser');
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get(Installer::class),
$container->get(SharedTempStoreFactory::class),
$container->get(EnabledSourceHandler::class),
$container->get(TimeInterface::class),

Ben Mullins
committed
$container->get('logger.channel.project_browser'),
$container->get(ActivatorInterface::class),

Ben Mullins
committed
);
}
/**
* 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 \Drupal\project_browser\ProjectBrowser\Project $project
* A project whose status to report.

Ben Mullins
committed
*
* @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(Project $project): JsonResponse {

Ben Mullins
committed
$requiring = $this->projectBrowserTempStore->get('requiring');
$core_installing = $this->projectBrowserTempStore->get('installing');
$return = ['status' => self::STATUS_IDLE];
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
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
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

Narendra Singh Rathore
committed
* The stage ID of the installer within the request.

Ben Mullins
committed
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
*
* @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 $id
* The ID of the project being required, as known to the enabled sources
* handler.

Ben Mullins
committed
* @param string $phase
* The require phase in progress.
*/

Narendra Singh Rathore
committed
private function setRequiringState(?string $id, string $phase): void {
$data = $this->projectBrowserTempStore->get('requiring') ?? [];
if ($id) {
$data['project_id'] = $id;
}
$data['phase'] = $phase;
$this->projectBrowserTempStore->set('requiring', $data);

Ben Mullins
committed
}
/**
* Unlocks and destroys the stage.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse|\Symfony\Component\HttpFoundation\RedirectResponse
* Redirects to the main project browser page.
*/
public function unlock(): JsonResponse|RedirectResponse {

Ben Mullins
committed
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
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
}
// 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);
$renderer = \Drupal::service('renderer');

James Shields
committed
$output = DeprecationHelper::backwardsCompatibleCall(
currentVersion: \Drupal::VERSION,
deprecatedVersion: '10.3',
currentCallable: fn() => $renderer->renderInIsolation($url_with_csrf_token_placeholder),
deprecatedCallable: fn() => $renderer->renderPlain($url_with_csrf_token_placeholder),
);
return (string) $output;

Ben Mullins
committed
}
/**
* Begins requiring by creating a stage.
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function begin(): JsonResponse {

Ben Mullins
committed
$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,
]);

Ben Mullins
committed
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,
]);

Ben Mullins
committed
}
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);
return $this->lockedResponse($message, $unlock_url);
}
try {
$stage_id = $this->installer->create();
}
catch (\Exception $e) {
$this->cancelRequire();
return $this->errorResponse($e, 'create');
}
return $this->successResponse('create', $stage_id);
}
/**
* Performs require operations on the stage.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request.
* @param string $stage_id

Narendra Singh Rathore
committed
* The stage ID of the installer within the request.

Ben Mullins
committed
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function require(Request $request, string $stage_id): JsonResponse {
$package_names = $package_ids = [];
foreach ($request->toArray() as $project) {
$project = $this->enabledSourceHandler->getStoredProject($project);
if ($project->source === 'project_browser_test_mock') {
$source = $this->enabledSourceHandler->getCurrentSources()[$project->source] ?? NULL;
if ($source === NULL) {
return new JsonResponse(['message' => "Cannot download $project->id from any available source"], 500);
}
if (!$source->isProjectSafe($project)) {
return new JsonResponse(['message' => "$project->machineName is not safe to add because its security coverage has been revoked"], 500);
}
}
$package_names[] = $project->packageName;
$package_ids[] = $project->id;

Ben Mullins
committed
}
$requiring = $this->projectBrowserTempStore->get('requiring');
$current_package_names = implode(', ', $package_names);
if (!empty($requiring['project_id']) && $requiring['project_id'] !== $current_package_names) {
$error_message = sprintf(
'Error: a request to install %s was ignored as an install for a different project is in progress.',
$current_package_names
);
return new JsonResponse(['message' => $error_message], 500);
}

Narendra Singh Rathore
committed
$this->setRequiringState(implode(', ', $package_ids), 'requiring module');
try {
$this->installer->claim($stage_id)->require($package_names);

Narendra Singh Rathore
committed
$this->setRequiringState(NULL, 'requiring module');
return $this->successResponse('require', $stage_id);
}
catch (\Exception $e) {
$this->cancelRequire();
return $this->errorResponse($e, 'require');

Ben Mullins
committed
}
}
/**
* Performs apply operations on the stage.
*

Narendra Singh Rathore
committed
* @param string $stage_id
* The stage ID of the installer within the request.
*

Ben Mullins
committed
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/

Narendra Singh Rathore
committed
public function apply(string $stage_id): JsonResponse {
$this->setRequiringState(NULL, 'applying');

Ben Mullins
committed
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.
*

Narendra Singh Rathore
committed
* @param string $stage_id
* The stage ID of the installer within the request.
*

Ben Mullins
committed
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/

Narendra Singh Rathore
committed
public function postApply(string $stage_id): JsonResponse {
$this->setRequiringState(NULL, 'post apply');

Ben Mullins
committed
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.
*

Narendra Singh Rathore
committed
* @param string $stage_id
* The stage ID of the installer within the request.
*

Ben Mullins
committed
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/

Narendra Singh Rathore
committed
public function destroy(string $stage_id): JsonResponse {
$this->setRequiringState(NULL, 'completing');

Ben Mullins
committed
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 \Symfony\Component\HttpFoundation\Request $request
* The request.

Ben Mullins
committed
*
* @return \Symfony\Component\HttpFoundation\JsonResponse
* Status message.
*/
public function activate(Request $request): JsonResponse {
foreach ($request->toArray() as $project) {
$project = $this->enabledSourceHandler->getStoredProject($project);
$this->projectBrowserTempStore->set('installing', $project->id);
try {
$this->activator->activate($project);
}
catch (\Throwable $e) {
return $this->errorResponse($e, 'project install');
}
finally {
$this->resetProgress();
}

Ben Mullins
committed
}
return new JsonResponse(['status' => 0]);