1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Module\Support\Webapps\ComposerWrapper;
16: use Module\Support\Webapps\PhpWrapper;
17: use Module\Support\Webapps\Traits\PublicRelocatable;
18: use Module\Support\Webapps\VersionFetcher\Github;
19: use Opcenter\Versioning;
20:
21: /**
22: * Drupal drush interface
23: *
24: * @package core
25: */
26: class Drupal_Module extends \Module\Support\Webapps
27: {
28: use PublicRelocatable {
29: getAppRoot as getAppRootReal;
30: }
31:
32: const APP_NAME = 'Drupal';
33:
34: // primary domain document root
35: const DRUPAL_CLI = '/usr/share/pear/drush.phar';
36:
37: const DEFAULT_VERSION_LOCK = 'major';
38:
39: const DRUPAL_COMPATIBILITY = [
40: '8' => '8.x',
41: '9' => '8.4',
42: '10' => '11'
43: ];
44: protected $aclList = array(
45: 'max' => array('sites/*/files')
46: );
47:
48: /**
49: * void __construct(void)
50: *
51: * @ignore
52: */
53: public function __construct()
54: {
55: parent::__construct();
56: }
57:
58: /**
59: * Install WordPress into a pre-existing location
60: *
61: * @param string $hostname domain or subdomain to install WordPress
62: * @param string $path optional path under hostname
63: * @param array $opts additional install options
64: * @return bool
65: */
66: public function install(string $hostname, string $path = '', array $opts = array()): bool
67: {
68: if (isset($opts['version']) && version_compare((string)$opts['version'], '7.33', '<')) {
69: return error("Minimum %(app)s version is %(version)s", ['app' => self::APP_NAME, 'version' => '7.33']);
70: }
71: if (!$this->mysql_enabled()) {
72: return error('%(what)s must be enabled to install %(app)s',
73: ['what' => 'MySQL', 'app' => static::APP_NAME]);
74: }
75:
76: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
77: return false;
78: }
79:
80: $docroot = $this->getDocumentRoot($hostname, $path);
81:
82: // can't fetch translation file from ftp??
83: // don't worry about it for now
84: if (!isset($opts['locale'])) {
85: $opts['locale'] = 'us';
86: }
87:
88: if (!isset($opts['dist'])) {
89: $opts['profile'] = 'standard';
90: $opts['dist'] = 'drupal';
91: if (isset($opts['version'])) {
92: if (strcspn((string)$opts['version'], '.0123456789x')) {
93: return error('invalid version number, %s', $opts['version']);
94: }
95: $opts['dist'] .= '-' . $opts['version'];
96: }
97:
98: } else if (!isset($opts['profile'])) {
99: $opts['profile'] = $opts['dist'];
100: }
101:
102: if (version_compare((string)$opts['version'], '9.0.0', '>=')) {
103: $ret = serial(function () use ($docroot, $opts) {
104: $composer = ComposerWrapper::instantiateContexted(\Auth::context($this->getDocrootUser($docroot),
105: $this->site));
106:
107: $ret = $composer->exec($docroot,
108: 'create-project drupal/recommended-project:' . $opts['version'] . " .");
109: if (!$ret['success']) {
110: return $ret;
111: }
112:
113: return $composer->exec($docroot, 'require drush/drush');
114: });
115:
116: if (!$ret['success']) {
117: return error("Failed to install %(app)s: %(err)s", [
118: 'app' => static::APP_NAME,
119: 'err' => coalesce($ret['stderr'], $ret['stdout'])
120: ]);
121: }
122: if (null === ($docroot = $this->remapPublic($hostname, $path, 'web'))) {
123: // it's more reasonable to fail at this stage, but let's try to complete
124: return error("Failed to remap %(app)s to %(subdir)/, manually remap from `%(docroot)s' - %(app)s setup is incomplete!", [
125: 'app' => static::APP_NAME,
126: 'subdir' => 'web',
127: 'docroot' => $this->getDocumentRoot($hostname, $path),
128: ]);
129: }
130: } else {
131: $cmd = 'dl %(dist)s';
132:
133: $tmpdir = '/tmp/drupal' . crc32((string)\Util_PHP::random_int());
134: $args = array(
135: 'tempdir' => $tmpdir,
136: 'path' => $docroot,
137: 'dist' => $opts['dist']
138: );
139: /**
140: * drupal expects destination dir to exist
141: * move /tmp/<RANDOM NAME>/drupal to <DOCROOT> instead
142: * of downloading to <DOCROOT>/drupal and moving everything down 1
143: */
144: $this->file_create_directory($tmpdir);
145: $ret = $this->_exec('/tmp', $cmd . ' --drupal-project-rename --destination=%(tempdir)s -q', $args);
146:
147: if (!$ret['success']) {
148: return error('failed to download Drupal - out of space? Error: `%s\'',
149: coalesce($ret['stderr'], $ret['stdout'])
150: );
151: }
152:
153: if ($this->file_exists($docroot)) {
154: $this->file_delete($docroot, true);
155: }
156:
157: $this->file_purge();
158: $ret = $this->file_rename($tmpdir . '/drupal', $docroot);
159: $this->file_delete($tmpdir, true);
160: if (!$ret) {
161: return error("failed to move Drupal install to `%s'", $docroot);
162: }
163: }
164:
165: if (isset($opts['site-email']) && !preg_match(Regex::EMAIL, $opts['site-email'])) {
166: return error("invalid site email `%s' provided", $opts['site-email']);
167: }
168:
169: if (!isset($opts['site-email'])) {
170: // default to active domain, hope it's valid!
171: if (false === strpos($hostname, '.')) {
172: $hostname .= '.' . $this->domain;
173: }
174: $split = $this->web_split_host($hostname);
175: if (!$this->email_address_exists('postmaster', $split['domain'])) {
176: if (!$this->email_transport_exists($split['domain'])) {
177: warn("email is not configured for domain `%s', messages sent from installation may " .
178: 'be unrespondable', $split['domain']);
179: } else if ($this->email_add_alias('postmaster', $split['domain'], $opts['email'])) {
180: info("created `postmaster@%s' address for Drupal mailings that " .
181: "will forward to `%s'", $split['domain'], $opts['email']);
182: } else {
183: warn("failed to create Drupal postmaster address `postmaster@%s', messages " .
184: 'sent from installation may be unrespondable', $split['domain']);
185: }
186: }
187: $opts['site-email'] = 'postmaster@' . $split['domain'];
188: }
189:
190: $db = \Module\Support\Webapps\DatabaseGenerator::mysql($this->getAuthContext(), $hostname);
191: if (!$db->create()) {
192: return false;
193: }
194:
195: $proto = 'mysql';
196: if (!empty($opts['version']) && version_compare((string)$opts['version'], '7.0', '<')) {
197: $proto = 'mysqli';
198: }
199: $dburi = $proto . '://' . $db->username . ':' .
200: $db->password . '@' . $db->hostname . '/' . $db->database;
201:
202: if (!isset($opts['title'])) {
203: $opts['title'] = 'A Random Drupal Install';
204: }
205:
206: $autogenpw = false;
207: if (!isset($opts['password'])) {
208: $autogenpw = true;
209: $opts['password'] = \Opcenter\Auth\Password::generate();
210: info("autogenerated password `%s'", $opts['password']);
211: }
212:
213: info("setting admin user to `%s'", $opts['user']);
214:
215: $xtra = array(
216: "install_configure_form.update_status_module='array(FALSE,FALSE)'"
217: );
218: // drush reqs name if dist not drupal otherwise
219: // getPath() on null error
220:
221: if ($opts['dist'] === 'drupal') {
222: $dist = '';
223: } else {
224: $dist = $opts['dist'];
225: }
226: $args = array(
227: 'dist' => $dist,
228: 'profile' => $opts['profile'],
229: 'dburi' => $dburi,
230: 'account-name' => $opts['user'],
231: 'account-pass' => $opts['password'],
232: 'account-mail' => $opts['email'],
233: 'locale' => $opts['locale'],
234: 'site-mail' => $opts['site-email'],
235: 'title' => $opts['title'],
236: 'xtraopts' => implode(' ', $xtra)
237: );
238:
239: $approot = $this->getAppRoot($hostname, $path);
240: $ret = $this->_exec($approot,
241: 'site-install %(profile)s -q --db-url=%(dburi)s --account-name=%(account-name)s ' .
242: '--account-pass=%(account-pass)s -y --account-mail=%(account-mail)s ' .
243: '--site-mail=%(site-mail)s --site-name=%(title)s %(xtraopts)s', $args);
244:
245: if (!$ret['success']) {
246: info('removing temporary files');
247: $this->file_delete($docroot, true);
248: $db->rollback();
249:
250: return error('failed to install Drupal: %s', coalesce($ret['stderr'], $ret['stdout']));
251: }
252: // by default, let's only open up ACLs to the bare minimum
253: $this->file_touch($docroot . '/.htaccess');
254: $this->removeInvalidDirectives($docroot, 'sites/default/files/');
255:
256: $this->initializeMeta($docroot, $opts);
257:
258: $this->fortify($hostname, $path, 'max');
259: /**
260: * Make sure RewriteBase is present, move to Webapps?
261: */
262: $this->fixRewriteBase($docroot, $path);
263:
264: $this->_postInstallTrustedHost($opts['version'], $hostname, $docroot);
265:
266: if (!empty($opts['ssl'])) {
267: // @todo force redirect to HTTPS
268: }
269:
270: $this->notifyInstalled($hostname, $path, $opts);
271:
272: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
273: ['app' => static::APP_NAME, 'email' => $opts['email']]);
274: }
275:
276: /**
277: * @inheritDoc
278: */
279: protected function mapFilesFromList(array $files, string $approot): array
280: {
281: // remap for Drupal 9.x installed via Composer
282: if ($this->file_exists("$approot/web/sites")) {
283: $approot .= "/web";
284: }
285: return parent::mapFilesFromList($files, $approot);
286: }
287:
288: /**
289: * App root relocated in Drupal 9.x
290: * @param string $hostname
291: * @param string $path
292: * @return int
293: */
294: protected function getAppRootDepth(string $hostname, string $path = ''): int
295: {
296: $approot = dirname($this->web_normalize_path($hostname, $path));
297: return $this->file_exists("$approot/web/index.php") ? 1 : 0;
298: }
299:
300:
301: /**
302: * Look for manifest presence in v3.5+
303: *
304: * @param string $path
305: * @return string
306: */
307: private function assertCliTypeFromInstall(string $path): string
308: {
309: return $this->file_exists($path . '/Composer/Composer.php') || $this->file_exists($path . '/vendor/drupal/core-composer-scaffold') ? '999.999.999' : '8.9999.99999';
310: }
311:
312: private function cliFromVersion(string $version, string $poolVersion = null): string
313: {
314: $selections = [
315: dirname(self::DRUPAL_CLI) . '/drush-8.4.11.phar',
316: 'vendor/bin/drush'
317: ];
318: $choice = version_compare($version, '9.0.0', '<') ? 0 : 1;
319: if ($poolVersion && version_compare($poolVersion, '7.1.0', '<')) {
320: return $selections[0];
321: }
322:
323: return $selections[$choice];
324: }
325:
326: private function _exec(?string $path, $cmd, array $args = array())
327: {
328: $wrapper = PhpWrapper::instantiateContexted($this->getAuthContext());
329: $drush = $this->cliFromVersion($args['version'] ?? $this->assertCliTypeFromInstall($path),
330: $this->php_pool_version_from_path((string)$path));
331: $ret = $wrapper->exec($path, $drush . ' ' . $cmd, $args);
332:
333: if (0 === strncmp((string)coalesce($ret['stderr'], $ret['stdout']), 'Error:', 6)) {
334: // move stdout to stderr on error for consistency
335: $ret['success'] = false;
336: if (!$ret['stderr']) {
337: $ret['stderr'] = $ret['stdout'];
338: }
339: }
340:
341: return $ret;
342: }
343:
344: /**
345: * Get installed version
346: *
347: * @param string $hostname
348: * @param string $path
349: * @return null|string version number
350: */
351: public function get_version(string $hostname, string $path = ''): ?string
352: {
353:
354: if (!$this->valid($hostname, $path)) {
355: return null;
356: }
357: $docroot = $this->getAppRoot($hostname, $path);
358:
359: return $this->_getVersion($docroot);
360: }
361:
362: /**
363: * Location is a valid WP install
364: *
365: * @param string $hostname or $docroot
366: * @param string $path
367: * @return bool
368: */
369: public function valid(string $hostname, string $path = ''): bool
370: {
371: if ($hostname[0] === '/') {
372: $docroot = $hostname;
373: } else {
374: $docroot = $this->getAppRoot($hostname, $path);
375: if (!$docroot) {
376: return false;
377: }
378: }
379:
380: return $this->file_exists($docroot . '/sites/default')
381: || $this->file_exists($docroot . '/sites/all')
382: /* Drupal 9.0+ */
383: || $this->file_exists($docroot . '/vendor/drupal/core-composer-scaffold');
384: }
385:
386: /**
387: * Get version using exact docroot
388: *
389: * @param $docroot
390: * @return string
391: */
392: protected function _getVersion($docroot): ?string
393: {
394: $ret = $this->_exec($docroot, 'status --format=json');
395: if (!$ret['success']) {
396: return null;
397: }
398:
399: $output = json_decode($ret['stdout'], true);
400: return $output['drupal-version'] ?? null;
401: }
402:
403: /**
404: * Add trusted_host_patterns if necessary
405: *
406: * @param $version
407: * @param $hostname
408: * @param $docroot
409: * @return bool
410: */
411: private function _postInstallTrustedHost($version, $hostname, $docroot): bool
412: {
413: if (\Opcenter\Versioning::compare((string)$version, '8.0', '<')) {
414: return true;
415: }
416: $file = $docroot . '/sites/default/settings.php';
417: $content = $this->file_get_file_contents($file);
418: if (!$content) {
419: return error('unable to add trusted_host_patterns configuration - cannot get ' .
420: "Drupal configuration for `%s'", $hostname);
421: }
422: $content .= "\n\n" .
423: '/** in the event the domain name changes, trust site configuration */' . "\n" .
424: '$settings["trusted_host_patterns"] = array(' . "\n" .
425: "\t" . "'^(www\.)?' . " . 'str_replace(".", "\\\\.", $_SERVER["DOMAIN"]) . ' . "'$'" . "\n" .
426: ');' . "\n";
427:
428: return $this->file_put_file_contents($file, $content);
429: }
430:
431: /**
432: * Install and activate plugin
433: *
434: * @param string $hostname domain or subdomain of wp install
435: * @param string $path optional path component of wp install
436: * @param string $plugin plugin name
437: * @param string $version optional plugin version
438: * @return bool
439: */
440: public function install_plugin(string $hostname, string $path, string $plugin, string $version = ''): bool
441: {
442: $docroot = $this->getAppRoot($hostname, $path);
443: if (!$docroot) {
444: return error('invalid Drupal location');
445: }
446: $dlplugin = $plugin;
447: if ($version) {
448: if (false === strpos($version, '-')) {
449: // Drupal seems to like <major>-x naming conventions
450: $version .= '-x';
451: }
452: $dlplugin .= '-' . $version;
453: }
454: $args = array($plugin);
455: $ret = $this->_exec($docroot, 'pm-download -y %s', $args);
456: if (!$ret['success']) {
457: return error("failed to install plugin `%s': %s", $plugin, $ret['stderr']);
458: }
459:
460: if (!$this->enable_plugin($hostname, $path, $plugin)) {
461: return warn("downloaded plugin `%s' but failed to activate: %s", $plugin, $ret['stderr']);
462: }
463: info("installed plugin `%s'", $plugin);
464:
465: return true;
466: }
467:
468: public function enable_plugin(string $hostname, ?string $path, $plugin)
469: {
470: $docroot = $this->getAppRoot($hostname, $path);
471: if (!$docroot) {
472: return error('invalid Drupal location');
473: }
474: $ret = $this->_exec($docroot, 'pm-enable -y %s', array($plugin));
475: if (!$ret) {
476: return error("failed to enable plugin `%s': %s", $plugin, $ret['stderr']);
477: }
478:
479: return true;
480: }
481:
482: /**
483: * Uninstall a plugin
484: *
485: * @param string $hostname
486: * @param string $path
487: * @param string $plugin plugin name
488: * @param bool|string $force delete even if plugin activated
489: * @return bool
490: */
491: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
492: {
493: $docroot = $this->getAppRoot($hostname, $path);
494: if (!$docroot) {
495: return error('invalid Drupal location');
496: }
497:
498: $args = array($plugin);
499:
500: if ($this->plugin_active($hostname, $path, $plugin)) {
501: if (!$force) {
502: return error("plugin `%s' is active, disable first");
503: }
504: $this->disable_plugin($hostname, $path, $plugin);
505: }
506:
507: $cmd = 'pm-uninstall %s';
508:
509: $ret = $this->_exec($docroot, $cmd, $args);
510:
511: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
512: return error("failed to uninstall plugin `%s': %s", $plugin, $ret['stderr']);
513: }
514: info("uninstalled plugin `%s'", $plugin);
515:
516: return true;
517: }
518:
519: public function plugin_active(string $hostname, ?string $path, $plugin)
520: {
521: $docroot = $this->getAppRoot($hostname, (string)$path);
522: if (!$docroot) {
523: return error('invalid Drupal location');
524: }
525: $plugin = $this->plugin_status($hostname, (string)$path, $plugin);
526:
527: return $plugin['status'] === 'enabled';
528: }
529:
530: public function plugin_status(string $hostname, string $path = '', string $plugin = null): ?array
531: {
532: $docroot = $this->getAppRoot($hostname, $path);
533: if (!$docroot) {
534: return error('invalid Drupal location');
535: }
536: $cmd = 'pm-info --format=json %(plugin)s';
537: $ret = $this->_exec($docroot, $cmd, ['plugin' => $plugin]);
538: if (!$ret['success']) {
539: return null;
540: }
541: $plugins = [];
542: foreach (json_decode($ret['stdout'], true) as $name => $meta) {
543: $plugins[$name] = [
544: 'version' => $meta['version'],
545: 'next' => null,
546: 'current' => true,
547: 'max' => $meta['version']
548: ];
549: }
550:
551: return $plugin ? array_pop($plugins) : $plugins;
552: }
553:
554: public function disable_plugin($hostname, ?string $path, $plugin)
555: {
556: $docroot = $this->getAppRoot($hostname, (string)$path);
557: if (!$docroot) {
558: return error('invalid Drupal location');
559: }
560: $ret = $this->_exec($docroot, 'pm-disable -y %s', array($plugin));
561: if (!$ret) {
562: return error("failed to disable plugin `%s': %s", $plugin, $ret['stderr']);
563: }
564: info("disabled plugin `%s'", $plugin);
565:
566: return true;
567: }
568:
569: /**
570: * Recovery mode to disable all plugins
571: *
572: * @param string $hostname subdomain or domain of WP
573: * @param string $path optional path
574: * @return bool
575: */
576: public function disable_all_plugins(string $hostname, string $path = ''): bool
577: {
578: $docroot = $this->getAppRoot($hostname, $path);
579: if (!$docroot) {
580: return error('failed to determine path');
581: }
582: $plugins = array();
583: $installed = $this->list_all_plugins($hostname, $path);
584: if (!$installed) {
585: return true;
586: }
587: foreach ($installed as $plugin => $info) {
588: if (strtolower($info['status']) !== 'enabled') {
589: continue;
590: }
591: $this->disable_plugin($hostname, $path, $plugin);
592: $plugins[] = $info['name'];
593:
594: }
595: if ($plugins) {
596: info("disabled plugins: `%s'", implode(',', $plugins));
597: }
598:
599: return true;
600: }
601:
602: public function list_all_plugins($hostname, $path = '', $status = '')
603: {
604: $docroot = $this->getAppRoot($hostname, $path);
605: if (!$docroot) {
606: return error('invalid Drupal location');
607: }
608: if ($status) {
609: $status = strtolower($status);
610: $status = '--status=' . $status;
611: }
612: $ret = $this->_exec($docroot, 'pm-list --format=json --no-core %s', array($status));
613: if (!$ret['success']) {
614: return error('failed to enumerate plugins: %s', $ret['stderr']);
615: }
616:
617: return json_decode($ret['stdout'], true);
618: }
619:
620: /**
621: * Uninstall Drupal from a location
622: *
623: * @param string $hostname
624: * @param string $path
625: * @param string $delete
626: * @return bool
627: * @internal param string $deletefiles remove all files under docroot
628: */
629: public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool
630: {
631: return parent::uninstall($hostname, $path, $delete);
632: }
633:
634: /**
635: * Get database configuration for a blog
636: *
637: * @param string $hostname domain or subdomain of Drupal
638: * @param string $path optional path
639: * @return array|bool
640: */
641: public function db_config(string $hostname, string $path = '')
642: {
643: $docroot = $this->getDocumentRoot($hostname, $path);
644: if (!$docroot) {
645: return error('failed to determine Drupal');
646: }
647: $code = 'include("./sites/default/settings.php"); $conf = $databases["default"]["default"]; print serialize(array("user" => $conf["username"], "password" => $conf["password"], "db" => $conf["database"], "prefix" => $conf["prefix"], "host" => $conf["host"]));';
648: $cmd = 'cd %(path)s && php -r %(code)s';
649: $ret = $this->pman_run($cmd, array('path' => $docroot, 'code' => $code));
650:
651: if (!$ret['success']) {
652: return error("failed to obtain Drupal configuration for `%s'", $docroot);
653: }
654:
655: return \Util_PHP::unserialize(trim($ret['stdout']));
656: }
657:
658: private function _extractBranch($version)
659: {
660: if (substr($version, -2) === '.x') {
661: return $version;
662: }
663: $pos = strpos($version, '.');
664: if (false === $pos) {
665: // sent major alone
666: return $version . '.x';
667: }
668: $newver = substr($version, 0, $pos);
669:
670: return $newver . '.x';
671: }
672:
673: /**
674: * Get all current major versions
675: *
676: * @return array
677: */
678: private function _getVersions(): array
679: {
680: $key = 'drupal.versions';
681: $cache = Cache_Super_Global::spawn();
682: if (false !== ($ver = $cache->get($key))) {
683: return (array)$ver;
684: }
685: // 8.7.11+
686: $versions = (new Github)->setMode('tags')->fetch('drupal/drupal');
687: $cache->set($key, $versions, 43200);
688:
689: return $versions;
690: }
691:
692: /**
693: * Change WP admin credentials
694: *
695: * $fields is a hash whose indices match password
696: *
697: * @param string $hostname
698: * @param string $path
699: * @param array $fields password only field supported for now
700: * @return bool
701: */
702: public function change_admin(string $hostname, string $path, array $fields): bool
703: {
704: $docroot = $this->getAppRoot($hostname, $path);
705: if (!$docroot) {
706: return warn('failed to change administrator information');
707: }
708: $admin = $this->get_admin($hostname, $path);
709:
710: if (!$admin) {
711: return error('cannot determine admin of Drupal install');
712: }
713:
714: $args = array(
715: 'user' => $admin
716: );
717:
718: if (isset($fields['password'])) {
719: $args['password'] = $fields['password'];
720: $ret = $this->_exec($docroot, 'user-password --password=%(password)s %(user)s', $args);
721: if (!$ret['success']) {
722: return error("failed to update password for user `%s': %s", $admin, $ret['stderr']);
723: }
724: }
725:
726: return true;
727: }
728:
729: /**
730: * Get the primary admin for a Drupal instance
731: *
732: * @param string $hostname
733: * @param string $path
734: * @return null|string admin or false on failure
735: */
736: public function get_admin(string $hostname, string $path = ''): ?string
737: {
738: $docroot = $this->getAppRoot($hostname, $path);
739: $ret = $this->_exec($docroot, 'user-information 1 --format=json');
740: if (!$ret['success']) {
741: warn('failed to enumerate Drupal administrative users');
742:
743: return null;
744: }
745: $tmp = json_decode($ret['stdout'], true);
746: if (!$tmp) {
747: return null;
748: }
749: $tmp = array_pop($tmp);
750:
751: return $tmp['name'];
752: }
753:
754: /**
755: * Update core, plugins, and themes atomically
756: *
757: * @param string $hostname subdomain or domain
758: * @param string $path optional path under hostname
759: * @param string $version
760: * @return bool
761: */
762: public function update_all(string $hostname, string $path = '', string $version = null): bool
763: {
764: $ret = ($this->update($hostname, $path, $version) && $this->update_plugins($hostname, $path))
765: || error('failed to update all components');
766:
767: parent::setInfo($this->getDocumentRoot($hostname, $path), [
768: 'version' => $this->get_version($hostname, $path),
769: 'failed' => !$ret
770: ]);
771:
772: return $ret;
773: }
774:
775: /**
776: * Update Drupal to latest version
777: *
778: * @param string $hostname domain or subdomain under which WP is installed
779: * @param string $path optional subdirectory
780: * @param string $version
781: * @return bool
782: */
783: public function update(string $hostname, string $path = '', string $version = null): bool
784: {
785: $approot = $this->getAppRoot($hostname, $path);
786: if (!$approot) {
787: return error('update failed');
788: }
789: if ($this->isLocked($approot)) {
790: return error('Drupal is locked - remove lock file from `%s\' and try again', $approot);
791: }
792:
793: $oldVersion = $this->get_version($hostname, $path);
794: if ($version) {
795: if (!is_scalar($version) || strcspn($version, '.0123456789x-')) {
796: return error('invalid version number, %s', $version);
797: }
798: $current = $this->_extractBranch($version);
799: } else {
800: $current = $this->_extractBranch($this->get_version($hostname, $path));
801: $version = Versioning::nextVersion(
802: $this->get_versions(),
803: $oldVersion
804: );
805: }
806:
807: if (version_compare($oldVersion, '9.0.0', '<') && version_compare($version, '9.0.0', '>=')) {
808: // moves to drush as Composer package
809: return error("No automatic upgrade path exists from pre-9.0 to 9.0+");
810: }
811:
812: $docroot = $this->getDocumentRoot($hostname, $path);
813: // save .htaccess
814: $htaccess = $docroot . DIRECTORY_SEPARATOR . '.htaccess';
815: if ($this->file_exists($htaccess) && !$this->file_move($htaccess, $htaccess . '.bak', true)) {
816: return error('upgrade failure: failed to save copy of original .htaccess');
817: }
818: $this->file_purge();
819: $this->_setMaintenance($approot, true, $current);
820:
821: if (version_compare($version, '9.0.0', '<')) {
822: $cmd = 'pm-update drupal-%(version)s -y';
823: $args = array('version' => $version);
824: $ret = $this->_exec($approot, $cmd, $args);
825: if ($ret['success']) {
826: $this->_exec($approot, 'cache-build');
827: }
828:
829: } else {
830: $composer = ComposerWrapper::instantiateContexted($this->getAuthContextFromDocroot($approot));
831: $ret = $composer->exec($approot, "update 'drupal/core-*' -W --with='drupal/core-recommended:$version'");
832: if ($ret['success']) {
833: $ret = $this->_exec($approot, "updatedb");
834: }
835:
836: if ($ret['success']) {
837: $this->_exec($approot, "cache:rebuild");
838: }
839: }
840:
841: $this->file_purge();
842: $this->_setMaintenance($approot, false, $current);
843:
844: if ($this->file_exists($htaccess . '.bak') && !$this->file_move($htaccess . '.bak', $htaccess, true)
845: && ($this->file_purge() || true)
846: ) {
847: warn("failed to rename backup `%s/.htaccess.bak' to .htaccess", $approot);
848: }
849:
850: parent::setInfo($approot, [
851: 'version' => $this->get_version($hostname, $path) ?? $version,
852: 'failed' => !$ret['success']
853: ]);
854: $this->fortify($hostname, $path, array_get($this->getOptions($approot), 'fortify') ?: 'max');
855:
856: if (!$ret['success']) {
857: return error('failed to update Drupal: %s', coalesce($ret['stderr'], $ret['stdout']));
858: }
859:
860: return $ret['success'];
861: }
862:
863: public function isLocked(string $docroot): bool
864: {
865: return file_exists($this->domain_fs_path() . $docroot . DIRECTORY_SEPARATOR .
866: '.drush-lock-update');
867: }
868:
869: /**
870: * Set Drupal maintenance mode before/after update
871: *
872: * @param $docroot
873: * @param $mode
874: * @param null $version
875: * @return bool
876: */
877: private function _setMaintenance($docroot, $mode, $version = null)
878: {
879: if (null === $version) {
880: $version = $this->_getVersion($docroot);
881: }
882: $version = explode('.', $version, 2);
883: if ((int)$version[0] >= 8) {
884: $maintenancecmd = 'sset system.maintenance_mode %(mode)d';
885: $cachecmd = 'cr';
886: } else {
887: $maintenancecmd = 'vset --exact maintenance_mode %(mode)d';
888: $cachecmd = 'cache-clear all';
889: }
890:
891: $ret = $this->_exec($docroot, $maintenancecmd, array('mode' => (int)$mode));
892: if (!$ret['success']) {
893: warn('failed to set maintenance mode');
894: }
895: $ret = $this->_exec($docroot, $cachecmd);
896: if (!$ret['success']) {
897: warn('failed to rebuild cache');
898: }
899:
900: return true;
901: }
902:
903: /**
904: * Update Drupal plugins and themes
905: *
906: * @param string $hostname domain or subdomain
907: * @param string $path optional path within host
908: * @param array $plugins
909: * @return bool
910: */
911: public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
912: {
913: $docroot = $this->getAppRoot($hostname, $path);
914: if (version_compare($this->get_version($hostname, $path), '9.0', '>=')) {
915: return debug("Individual plugin updates no longer supported");
916: }
917: if (!$docroot) {
918: return error('update failed');
919: }
920: $cmd = 'pm-update -y --check-disabled --no-core';
921:
922: $args = array();
923: if ($plugins) {
924: for ($i = 0, $n = count($plugins); $i < $n; $i++) {
925: $plugin = $plugins[$i];
926: $version = null;
927: if (isset($plugin['version'])) {
928: $version = $plugin['version'];
929: }
930: if (isset($plugin['name'])) {
931: $plugin = $plugin['name'];
932: }
933:
934: $name = 'p' . $i;
935: $cmd .= ' %(' . $name . ')s';
936: $args[$name] = $plugin . ($version ? '-' . $version : '');
937: }
938: }
939:
940: $ret = $this->_exec($docroot, $cmd, $args);
941: if (!$ret['success']) {
942: /**
943: * NB: "Command pm-update needs a higher bootstrap level"...
944: * Use an older version of Drush to bring the version up
945: * to use the latest drush
946: */
947: return error("plugin update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
948: }
949:
950: return $ret['success'];
951: }
952:
953: public function _housekeeping()
954: {
955: $versions = [
956: 'drush-8.4.11.phar' => null
957: ];
958:
959: foreach ($versions as $full => $short) {
960: $src = resource_path('storehouse') . '/' . $full;
961: $dest = \Opcenter\Php::PEAR_HOME. '/' . ($short ?? $full);
962: if (is_file($dest) && sha1_file($src) === sha1_file($dest)) {
963: continue;
964: }
965:
966: copy($src, $dest);
967: chmod($dest, 0755);
968: info('Copied %(src)s to %(dest)s', ['src' => $full, 'dest' => $dest]);
969: }
970:
971: return true;
972: }
973:
974: /**
975: * Get all available Drupal versions
976: *
977: * @return array
978: */
979: public function get_versions(): array
980: {
981: $versions = $this->_getVersions();
982:
983: return array_column($versions, 'version');
984: }
985:
986: /**
987: * Update WordPress themes
988: *
989: * @param string $hostname subdomain or domain
990: * @param string $path optional path under hostname
991: * @param array $themes
992: * @return bool
993: */
994: public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
995: {
996: return false;
997: }
998:
999: private function _getCommand()
1000: {
1001: return 'php ' . self::DRUPAL_CLI;
1002: }
1003: }