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