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\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;

Narendra Singh Rathore
committed
use Drupal\project_browser\InstallState;
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
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
53
54
/**
* 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;
public function __construct(
private readonly Installer $installer,
private readonly EnabledSourceHandler $enabledSourceHandler,
private readonly TimeInterface $time,
private readonly LoggerInterface $logger,
private readonly ActivatorInterface $activator,

Narendra Singh Rathore
committed
private readonly InstallState $installState,
) {}

Ben Mullins
committed
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get(Installer::class),
$container->get(EnabledSourceHandler::class),
$container->get(TimeInterface::class),

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

Narendra Singh Rathore
committed
$container->get(InstallState::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);
}
/**
* Resets progress and destroys the stage.
*/
private function cancelRequire(): void {

Narendra Singh Rathore
committed
$this->installState->deleteAll();

Ben Mullins
committed
// 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.
*/
public function inProgress(Project $project): JsonResponse {

Narendra Singh Rathore
committed
$project_state = $this->installState->getStatus($project);
$return = ['status' => self::STATUS_IDLE];

Narendra Singh Rathore
committed
if ($project_state !== NULL) {
$return['status'] = ($project_state === 'requiring' || $project_state === 'applying')
? self::STATUS_REQUIRING_PROJECT
: self::STATUS_INSTALLING_PROJECT;
$return['phase'] = $project_state;

Ben Mullins
committed
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
}
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
165
166
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
*
* @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);
}
/**
* 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()) {

utkarsh_33
committed
throw new StageException('Another project is being added. Try again in a few minutes.');

Ben Mullins
committed
}
// 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);
}

Narendra Singh Rathore
committed
$this->installState->deleteAll();

utkarsh_33
committed
$this->messenger()->addStatus($this->t('Operation complete, you can add a new project again.'));

Ben Mullins
committed
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) {

Narendra Singh Rathore
committed
$updated_time = $this->installState->getFirstUpdatedTime();

Ben Mullins
committed
if (!$this->installer->lockCameFromProjectBrowserInstaller()) {

utkarsh_33
committed
return $this->lockedResponse($this->t('The process for adding projects was locked by something else outside of Project Browser. Projects can be added again once the process is unlocked. Try again in a few minutes.'), '');

Ben Mullins
committed
}

Narendra Singh Rathore
committed
if (empty($updated_time)) {

Ben Mullins
committed
$unlock_url = self::getUrlWithReplacedCsrfTokenPlaceholder(
Url::fromRoute('project_browser.install.unlock')
);

utkarsh_33
committed
$message = t('The process for adding projects is locked, but that lock has expired. Use [+ unlock link] to unlock the process and try to add the project again.');

Ben Mullins
committed
return $this->lockedResponse($message, $unlock_url);
}

Narendra Singh Rathore
committed
$time_since_updated = $this->time->getRequestTime() - $updated_time;

Ben Mullins
committed
$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)) ?

utkarsh_33
committed
$this->t('The process for adding the project was locked @minutes minutes ago. It should not be unlocked while changes are being applied to the site.', ['@minutes' => $minutes]) :
$this->t('The process for adding the project was locked @hours hours, @minutes minutes ago. It should not be unlocked
while changes 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')) {

utkarsh_33
committed
$message = $this->t('The process for adding the project that was locked @minutes minutes ago might still be in progress. Consider waiting a few more minutes before using [+unlock link].', ['@minutes' => $minutes]);

Ben Mullins
committed
}
else {
$message = empty($hours) ?

utkarsh_33
committed
$this->t('The process for adding the project was locked @minutes minutes ago. Use [+ unlock link] to unlock the process.', ['@minutes' => $minutes]) :
$this->t('The process for adding the project was locked @hours hours, @minutes minutes ago. Use [+ unlock link] to unlock the process.',
['@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 {

Narendra Singh Rathore
committed
$package_names = [];
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);
}
}

Narendra Singh Rathore
committed
$this->installState->setState($project, 'requiring');
$package_names[] = $project->packageName;
}
try {
$this->installer->claim($stage_id)->require($package_names);
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 {

Narendra Singh Rathore
committed
foreach (array_keys($this->installState->toArray()) as $project_id) {
$this->installState->setState($this->enabledSourceHandler->getStoredProject($project_id), '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 {

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 {

Ben Mullins
committed
try {
$this->installer->claim($stage_id)->destroy();
}
catch (\Exception $e) {
return $this->errorResponse($e, 'destroy');
}
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);

Narendra Singh Rathore
committed
$this->installState->setState($project, 'activating');
try {
$this->activator->activate($project);

Narendra Singh Rathore
committed
$this->installState->setState($project, 'installed');
}
catch (\Throwable $e) {
return $this->errorResponse($e, 'project install');
}
finally {

Narendra Singh Rathore
committed
$this->installState->deleteAll();
}

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