1: <?php declare(strict_types=1);
2: /**
3: * +------------------------------------------------------------+
4: * | apnscp |
5: * +------------------------------------------------------------+
6: * | Copyright (c) Apis Networks |
7: * +------------------------------------------------------------+
8: * | Licensed under Artistic License 2.0 |
9: * +------------------------------------------------------------+
10: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
11: * +------------------------------------------------------------+
12: */
13:
14: use Module\Support\Webapps\App\Loader;
15: use Module\Support\Webapps\App\Type\Wordpress\DefineReplace;
16: use Module\Support\Webapps\App\Type\Wordpress\Wpcli;
17: use Module\Support\Webapps\ComposerWrapper;
18: use Module\Support\Webapps\DatabaseGenerator;
19: use Module\Support\Webapps\Git;
20: use Module\Support\Webapps\MetaManager;
21: use Opcenter\Versioning;
22:
23: /**
24: * WordPress management
25: *
26: * An interface to wp-cli
27: *
28: * @package core
29: */
30: class Wordpress_Module extends \Module\Support\Webapps
31: {
32: const APP_NAME = 'WordPress';
33: const ASSET_SKIPLIST = '.wp-update-skip';
34:
35: const VERSION_CHECK_URL = 'https://api.wordpress.org/core/version-check/1.7/';
36: const PLUGIN_VERSION_CHECK_URL = 'https://api.wordpress.org/plugins/info/1.0/%plugin%.json';
37: const THEME_VERSION_CHECK_URL = 'https://api.wordpress.org/themes/info/1.2/?action=theme_information&request[slug]=%theme%&request[fields][versions]=1';
38: const DEFAULT_VERSION_LOCK = 'none';
39:
40: protected $aclList = array(
41: 'min' => array(
42: 'wp-content',
43: '.htaccess',
44: 'wp-config.php'
45: ),
46: 'max' => array(
47: 'wp-content/uploads',
48: 'wp-content/cache',
49: 'wp-content/wflogs',
50: 'wp-content/updraft'
51: )
52: );
53:
54: /**
55: * @var array files detected by Wordpress when determining write-access
56: */
57: protected $controlFiles = [
58: '/wp-admin/includes/file.php'
59: ];
60:
61: /**
62: * @var array list of plugin/theme types that cannot be updated manually
63: */
64: const NON_UPDATEABLE_TYPES = [
65: 'dropin',
66: 'must-use'
67: ];
68:
69: /**
70: * Install WordPress
71: *
72: * @param string $hostname domain or subdomain to install WordPress
73: * @param string $path optional path under hostname
74: * @param array $opts additional install options
75: * @return bool
76: */
77: public function install(string $hostname, string $path = '', array $opts = array()): bool
78: {
79: if (!$this->mysql_enabled()) {
80: return error('%(what)s must be enabled to install %(app)s',
81: ['what' => 'MySQL', 'app' => static::APP_NAME]);
82: }
83:
84: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
85: return false;
86: }
87:
88: $docroot = $this->getDocumentRoot($hostname, $path);
89:
90: $args = [
91: 'mode' => 'download',
92: 'version' => $opts['version']
93: ];
94:
95: $args['user'] = $opts['user'];
96:
97: $ret = $this->execCommand($docroot, 'core %(mode)s --version=%(version)s', $args);
98:
99: if (!$ret['success']) {
100: return error("failed to download WP version `%s', error: %s",
101: $opts['version'],
102: coalesce($ret['stdout'], $ret['stderr'])
103: );
104: }
105:
106: $dbCred = DatabaseGenerator::mysql($this->getAuthContext(), $hostname);
107: if (!$dbCred->create()) {
108: return false;
109: }
110:
111: if (!$this->generateNewConfiguration($hostname, $docroot, $dbCred)) {
112: info('removing temporary files');
113: if (!array_get($opts, 'hold')) {
114: $this->file_delete($docroot, true);
115: $dbCred->rollback();
116: }
117: return false;
118: }
119:
120: if (!isset($opts['title'])) {
121: $opts['title'] = 'A Random Blog for a Random Reason';
122: }
123:
124: if (!isset($opts['password'])) {
125: $opts['password'] = \Opcenter\Auth\Password::generate();
126: info("autogenerated password `%s'", $opts['password']);
127: }
128:
129: info("setting admin user to `%s'", $this->username);
130: // fix situations when installed on global subdomain
131: $fqdn = $this->web_normalize_hostname($hostname);
132: $opts['url'] = rtrim($fqdn . '/' . $path, '/');
133: $args = array(
134: 'email' => $opts['email'],
135: 'mode' => 'install',
136: 'url' => $opts['url'],
137: 'title' => $opts['title'],
138: 'user' => $opts['user'],
139: 'password' => $opts['password'],
140: 'proto' => !empty($opts['ssl']) ? 'https://' : 'http://',
141: 'mysqli81' => 'function_exists("mysqli_report") && mysqli_report(0);'
142: );
143: $ret = $this->execCommand($docroot, 'core %(mode)s --admin_email=%(email)s --skip-email ' .
144: '--url=%(proto)s%(url)s --title=%(title)s --admin_user=%(user)s --exec=%(mysqli81)s ' .
145: '--admin_password=%(password)s', $args);
146: if (!$ret['success']) {
147: if (!array_get($opts, 'hold')) {
148: $dbCred->rollback();
149: }
150: return error('failed to create database structure: %s', coalesce($ret['stderr'], $ret['stdout']));
151: }
152:
153: $this->initializeMeta($docroot, $opts);
154: if (!file_exists($this->domain_fs_path() . "/${docroot}/.htaccess")) {
155: $this->file_touch("${docroot}/.htaccess");
156: }
157:
158: $wpcli = Wpcli::instantiateContexted($this->getAuthContext());
159: $wpcli->setConfiguration(['apache_modules' => ['mod_rewrite']]);
160:
161: $ret = $this->execCommand($docroot, "rewrite structure --hard '/%%postname%%/'");
162: if (!$ret['success']) {
163: return error('failed to set rewrite structure, error: %s', coalesce($ret['stderr'], $ret['stdout']));
164: }
165:
166: if (!empty($opts['cache'])) {
167: if ($this->install_plugin($hostname, $path, 'w3-total-cache')) {
168: $wpcli->exec($docroot, 'w3-total-cache option set pgcache.enabled true --type=boolean');
169: $wpcli->exec($docroot, 'w3-total-cache fix_environment');
170: $httxt = preg_replace(
171: '/^\s*AddType\s+.*$[\r\n]?/mi',
172: '',
173: $this->file_get_file_contents($docroot . '/.htaccess')
174: );
175: $this->file_put_file_contents($docroot . '/.htaccess', $httxt);
176: } else {
177: warn("Failed to install caching plugin - performance will be suboptimal");
178: }
179: }
180: if (!$this->file_exists($docroot . '/wp-content/cache')) {
181: $this->file_create_directory($docroot . '/wp-content/cache');
182: }
183: // by default, let's only open up ACLs to the bare minimum
184: $this->fortify($hostname, $path, 'max');
185:
186:
187: $this->notifyInstalled($hostname, $path, $opts);
188:
189: return info('%(app)s installed - confirmation email with login info sent to %(email)s', ['app' => static::APP_NAME, 'email' => $opts['email']]);
190: }
191:
192: protected function execCommand(?string $path, string $cmd, array $args = [], array $env = [])
193: {
194: return Wpcli::instantiateContexted($this->getAuthContextFromDocroot($path ?? \Web_Module::MAIN_DOC_ROOT))->exec($path, $cmd, $args, $env);
195: }
196:
197: protected function generateNewConfiguration(string $domain, string $docroot, DatabaseGenerator $dbcredentials, array $ftpcredentials = array()): bool
198: {
199: // generate db
200: if (!isset($ftpcredentials['user'])) {
201: $ftpcredentials['user'] = $this->username . '@' . $this->domain;
202: }
203: if (!isset($ftpcredentials['host'])) {
204: $ftpcredentials['host'] = 'localhost';
205: }
206: if (!isset($ftpcredentials['password'])) {
207: $ftpcredentials['password'] = '';
208: }
209: $svc = \Opcenter\SiteConfiguration::shallow($this->getAuthContext());
210: $xtraPHP = (string)(new \Opcenter\Provisioning\ConfigurationWriter('@webapp(wordpress)::templates.wp-config-extra', $svc))->compile([
211: 'svc' => $svc,
212: 'afi' => $this->getApnscpFunctionInterceptor(),
213: 'db' => $dbcredentials,
214: 'ftp' => [
215: 'username' => $ftpcredentials['user'],
216: 'hostname' => 'localhost',
217: 'password' => $ftpcredentials['password']
218: ],
219: 'hostname' => $domain,
220: 'docroot' => $docroot
221: ]);
222:
223: $xtraphp = '<<EOF ' . "\n" . $xtraPHP . "\n" . 'EOF';
224: $args = array(
225: 'mode' => 'config',
226: 'db' => $dbcredentials->database,
227: 'password' => $dbcredentials->password,
228: 'user' => $dbcredentials->username
229: );
230:
231: $ret = $this->execCommand($docroot,
232: 'core %(mode)s --dbname=%(db)s --dbpass=%(password)s --dbuser=%(user)s --dbhost=localhost --extra-php ' . $xtraphp,
233: $args);
234: if (!$ret['success']) {
235: return error('failed to generate configuration, error: %s', coalesce($ret['stderr'], $ret['stdout']));
236: }
237:
238: return true;
239: }
240:
241: /**
242: * Get installed version
243: *
244: * @param string $hostname
245: * @param string $path
246: * @return string version number
247: */
248: public function get_version(string $hostname, string $path = ''): ?string
249: {
250: if (!$this->valid($hostname, $path)) {
251: return null;
252: }
253: $docroot = $this->getAppRoot($hostname, $path);
254: $ret = $this->execCommand($docroot, 'core version');
255: if (!$ret['success']) {
256: return null;
257: }
258:
259: return trim($ret['stdout']);
260:
261: }
262:
263: /**
264: * Location is a valid WP install
265: *
266: * @param string $hostname or $docroot
267: * @param string $path
268: * @return bool
269: */
270: public function valid(string $hostname, string $path = ''): bool
271: {
272: if ($hostname[0] === '/') {
273: $docroot = $hostname;
274: } else {
275: $docroot = $this->getAppRoot($hostname, $path);
276: if (!$docroot) {
277: return false;
278: }
279: }
280:
281: return $this->file_exists($docroot . '/wp-config.php') || $this->file_exists($docroot . '/wp-config-sample.php');
282: }
283:
284: /**
285: * Restrict write-access by the app
286: *
287: * @param string $hostname
288: * @param string $path
289: * @param string $mode
290: * @param array $args
291: * @return bool
292: */
293: public function fortify(string $hostname, string $path = '', string $mode = 'max', $args = []): bool
294: {
295: if (!parent::fortify($hostname, $path, $mode, $args)) {
296: return false;
297: }
298: $docroot = $this->getAppRoot($hostname, $path);
299: if ($mode === 'min') {
300: // allow direct access on min to squelch FTP dialog
301: $this->shareOwnershipSystemCheck($docroot);
302: } else {
303: // flipping from min to max, reset file check
304: $this->assertOwnershipSystemCheck($docroot);
305: }
306:
307: $this->setFsMethod($docroot, $mode);
308:
309: return true;
310: }
311:
312: /**
313: * Update FS_METHOD
314: *
315: * @param string $approot
316: * @param string|false $mode
317: * @return bool
318: */
319: protected function setFsMethod(string $approot, $mode): bool
320: {
321: $method = \in_array($mode, [false, 'learn', 'write', null /* release */], true) ? 'direct' : false;
322: return $this->updateConfiguration($approot, ['FS_METHOD' => $method]);
323: }
324:
325: /**
326: * Replace configuration with new values
327: *
328: * @param string $approot
329: * @param array $pairs
330: * @return bool
331: */
332: protected function updateConfiguration(string $approot, array $pairs): bool
333: {
334: $file = $approot . '/wp-config.php';
335: try {
336: $instance = DefineReplace::instantiateContexted($this->getAuthContext(), [$file]);
337: foreach ($pairs as $k => $v) {
338: $instance->set($k, $v);
339: }
340: return $instance->save();
341: } catch (\PhpParser\Error $e) {
342: return warn("Failed parsing %(file)s - cannot update %(directive)s",
343: ['file' => $file, 'directive' => 'FS_METHOD']);
344: } catch (\ArgumentError $e) {
345: return warn("Failed parsing %(file)s - does not exist");
346: }
347:
348: return false;
349: }
350:
351: /**
352: * Share ownership of a WordPress install allowing WP write-access in min fortification
353: *
354: * @param string $docroot
355: * @return int num files changed
356: */
357: protected function shareOwnershipSystemCheck(string $docroot): int
358: {
359: $changed = 0;
360: $options = $this->getOptions($docroot);
361: if (!array_get($options, 'fortify', 'min')) {
362: return $changed;
363: }
364: $user = array_get($options, 'user', $this->getDocrootUser($docroot));
365: $webuser = $this->web_get_user($docroot);
366: foreach ($this->controlFiles as $file) {
367: $path = $docroot . $file;
368: if (!file_exists($this->domain_fs_path() . $path)) {
369: continue;
370: }
371: $this->file_chown($path, $webuser);
372: $this->file_set_acls($path, $user, 6);
373: $changed++;
374: }
375:
376: return $changed;
377: }
378:
379: /**
380: * Change ownership over to WordPress admin
381: *
382: * @param string $docroot
383: * @return int num files changed
384: */
385: protected function assertOwnershipSystemCheck(string $docroot): int
386: {
387: $changed = 0;
388: $options = $this->getOptions($docroot);
389: $user = array_get($options, 'user', $this->getDocrootUser($docroot));
390: foreach ($this->controlFiles as $file) {
391: $path = $docroot . $file;
392: if (!file_exists($this->domain_fs_path() . $path)) {
393: continue;
394: }
395: $this->file_chown($path, $user);
396: $changed++;
397: }
398:
399: return $changed;
400: }
401:
402: /**
403: * Enumerate plugin states
404: *
405: * @param string $hostname
406: * @param string $path
407: * @param string|null $plugin optional plugin
408: * @return array|bool
409: */
410: public function plugin_status(string $hostname, string $path = '', string $plugin = null)
411: {
412: $docroot = $this->getAppRoot($hostname, $path);
413: if (!$docroot) {
414: return error('invalid WP location');
415: }
416:
417: $matches = $this->assetListWrapper($docroot, 'plugin', [
418: 'name',
419: 'status',
420: 'version',
421: 'update_version'
422: ]);
423:
424: if (!$matches) {
425: return false;
426: }
427:
428: $pluginmeta = [];
429:
430: foreach ($matches as $match) {
431: if (\in_array($match['status'], self::NON_UPDATEABLE_TYPES , true)) {
432: continue;
433: }
434: $name = $match['name'];
435: $version = $match['version'];
436: if (!$versions = $this->pluginVersions($name)) {
437: // commercial plugin
438: if (empty($match['update_version'])) {
439: $match['update_version'] = $match['version'];
440: }
441:
442: $versions = [$match['version'], $match['update_version']];
443: }
444: $pluginmeta[$name] = [
445: 'version' => $version,
446: 'next' => Versioning::nextVersion($versions, $version),
447: 'max' => $this->pluginInfo($name)['version'] ?? end($versions),
448: 'active' => $match['status'] !== 'inactive'
449: ];
450: // dev version may be present
451: $pluginmeta[$name]['current'] = version_compare((string)array_get($pluginmeta, "${name}.max",
452: '99999999.999'), (string)$version, '<=') ?:
453: (bool)Versioning::current($versions, $version);
454: }
455:
456: return $plugin ? $pluginmeta[$plugin] ?? error("unknown plugin `%s'", $plugin) : $pluginmeta;
457: }
458:
459: protected function assetListWrapper(string $approot, string $type, array $fields): ?array {
460: $ret = $this->execCommand($approot,
461: $type . ' list --format=json --fields=%s', [implode(',', $fields)]);
462: // filter plugin garbage from Elementor, et al
463: // enqueued updates emits non-JSON in stdout
464: $line = strtok($ret['stdout'], "\n");
465: do {
466: if ($line[0] === '[') {
467: break;
468: }
469: } while (false !== ($line = strtok("\n")));
470: if (!$ret['success']) {
471: error('failed to get %s status: %s', $type, coalesce($ret['stderr'], $ret['stdout']));
472: return null;
473: }
474:
475: if (null === ($matches = json_decode(str_replace(':""', ':null', $ret['stdout']), true))) {
476: dlog('Failed decode results: %s', var_export($ret, true));
477: return nerror('Failed to decode %s output', $type);
478: }
479:
480: return $matches;
481: }
482:
483: protected function pluginVersions(string $plugin): ?array
484: {
485: $info = $this->pluginInfo($plugin);
486: if (!$info || empty($info['versions'])) {
487: return null;
488: }
489: array_forget($info, 'versions.trunk');
490:
491: return array_keys($info['versions']);
492: }
493:
494: /**
495: * Get information about a plugin
496: *
497: * @param string $plugin
498: * @return array
499: */
500: protected function pluginInfo(string $plugin): array
501: {
502: $cache = \Cache_Super_Global::spawn();
503: $key = 'wp.pinfo-' . $plugin;
504: if (false !== ($data = $cache->get($key))) {
505: return $data;
506: }
507: $url = str_replace('%plugin%', $plugin, static::PLUGIN_VERSION_CHECK_URL);
508: $info = [];
509: $contents = silence(static function() use($url) {
510: return file_get_contents($url);
511: });
512: if (false !== $contents) {
513: $info = (array)json_decode($contents, true);
514: if (isset($info['versions'])) {
515: uksort($info['versions'], 'version_compare');
516: }
517: } else {
518: info("Plugin `%s' detected as commercial. Using transient data.", $plugin);
519: }
520: $cache->set($key, $info, 86400);
521:
522: return $info;
523: }
524:
525: /**
526: * Install and activate plugin
527: *
528: * @param string $hostname domain or subdomain of wp install
529: * @param string $path optional path component of wp install
530: * @param string $plugin plugin name
531: * @param string $version optional plugin version
532: * @return bool
533: */
534: public function install_plugin(
535: string $hostname,
536: string $path,
537: string $plugin,
538: string $version = ''
539: ): bool {
540: $docroot = $this->getAppRoot($hostname, $path);
541: if (!$docroot) {
542: return error('invalid WP location');
543: }
544:
545: $args = array(
546: 'plugin' => $plugin
547: );
548: $cmd = 'plugin install %(plugin)s --activate';
549: if ($version) {
550: $cmd .= ' --version=%(version)s';
551: $args['version'] = $version;
552: }
553:
554: $ret = $this->execCommand(
555: $docroot,
556: $cmd,
557: $args,
558: [
559: 'REQUEST_URI' => '/' . rtrim($path)
560: ]
561: );
562: if (!$ret['success']) {
563: return error("failed to install plugin `%s': %s", $plugin, coalesce($ret['stderr'], $ret['stdout']));
564: }
565: info("installed plugin `%s'", $plugin);
566:
567: return true;
568: }
569:
570: /**
571: * Uninstall a plugin
572: *
573: * @param string $hostname
574: * @param string $path
575: * @param string $plugin plugin name
576: * @param bool $force delete even if plugin activated
577: * @return bool
578: */
579: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
580: {
581: $docroot = $this->getAppRoot($hostname, $path);
582: if (!$docroot) {
583: return error('invalid WP location');
584: }
585:
586: $args = array(
587: 'plugin' => $plugin
588: );
589: $cmd = 'plugin uninstall %(plugin)s';
590: if ($force) {
591: $cmd .= ' --deactivate';
592: }
593: $ret = $this->execCommand($docroot, $cmd, $args);
594:
595: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
596: return error("failed to uninstall plugin `%s': %s", $plugin, coalesce($ret['stderr'], $ret['stdout']));
597: }
598: info("uninstalled plugin `%s'", $plugin);
599:
600: return true;
601: }
602:
603: /**
604: * Disable plugin
605: *
606: * @param string $hostname
607: * @param string $path
608: * @param string $plugin
609: * @return bool
610: */
611: public function disable_plugin(string $hostname, string $path, string $plugin): bool
612: {
613: $docroot = $this->getAppRoot($hostname, $path);
614: if (!$docroot) {
615: return error('invalid WP location');
616: }
617:
618: return $this->assetManagerWrapper($docroot, 'plugin', 'deactivate', $plugin);
619: }
620:
621: /**
622: * Enable plugin
623: *
624: * @param string $hostname
625: * @param string $path
626: * @param string $plugin
627: * @return bool
628: */
629: public function enable_plugin(string $hostname, string $path, string $plugin): bool
630: {
631: $docroot = $this->getAppRoot($hostname, $path);
632: if (!$docroot) {
633: return error('invalid WP location');
634: }
635:
636: return $this->assetManagerWrapper($docroot, 'plugin', 'activate', $plugin);
637: }
638:
639: /**
640: * Disable theme
641: *
642: * @param string $hostname
643: * @param string $path
644: * @param string $plugin
645: * @return bool
646: */
647: public function disable_theme(string $hostname, string $path, string $plugin): bool
648: {
649: $docroot = $this->getAppRoot($hostname, $path);
650: if (!$docroot) {
651: return error('invalid WP location');
652: }
653:
654: return $this->assetManagerWrapper($docroot, 'theme', 'disable', $plugin);
655: }
656:
657: /**
658: * Enable theme
659: *
660: * @param string $hostname
661: * @param string $path
662: * @param string $plugin
663: * @return bool
664: */
665: public function enable_theme(string $hostname, string $path, string $plugin): bool
666: {
667: $docroot = $this->getAppRoot($hostname, $path);
668: if (!$docroot) {
669: return error('invalid WP location');
670: }
671:
672: return $this->assetManagerWrapper($docroot, 'theme', 'activate', $plugin);
673: }
674:
675: private function assetManagerWrapper(string $docroot, string $type, string $mode, string $asset): bool
676: {
677: $ret = $this->execCommand($docroot, '%s %s %s', [$type, $mode, $asset]);
678:
679: return $ret['success'] ?: error("Failed to %(mode)s `%(asset)s': %(err)s", [
680: 'mode' => $mode, 'asset' => $asset, 'err' => coalesce($ret['stderr'], $ret['stdout'])
681: ]);
682: }
683:
684:
685: /**
686: * Remove a Wordpress theme
687: *
688: * @param string $hostname
689: * @param string $path
690: * @param string $theme
691: * @param bool $force unused
692: * @return bool
693: */
694: public function uninstall_theme(string $hostname, string $path, string $theme, bool $force = false): bool
695: {
696: $docroot = $this->getAppRoot($hostname, $path);
697: if (!$docroot) {
698: return error('invalid WP location');
699: }
700:
701: $args = array(
702: 'theme' => $theme
703: );
704: if ($force) {
705: warn('Force parameter unused - deactivate theme first through WP panel if necessary');
706: }
707: $cmd = 'theme uninstall %(theme)s';
708: $ret = $this->execCommand($docroot, $cmd, $args);
709:
710: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
711: return error("failed to uninstall plugin `%s': %s", $theme, coalesce($ret['stderr'], $ret['stdout']));
712: }
713: info("uninstalled theme `%s'", $theme);
714:
715: return true;
716: }
717:
718: /**
719: * Recovery mode to disable all plugins
720: *
721: * @param string $hostname subdomain or domain of WP
722: * @param string $path optional path
723: * @return bool
724: */
725: public function disable_all_plugins(string $hostname, string $path = ''): bool
726: {
727: $docroot = $this->getAppRoot($hostname, $path);
728: if (!$docroot) {
729: return error('failed to determine path');
730: }
731:
732: $ret = $this->execCommand($docroot, 'plugin deactivate --all --skip-plugins');
733: if (!$ret['success']) {
734: return error('failed to deactivate all plugins: %s', coalesce($ret['stderr'], $ret['stdout']));
735: }
736:
737: return info('plugin deactivation successful: %s', $ret['stdout']);
738: }
739:
740: /**
741: * Uninstall WP from a location
742: *
743: * @param $hostname
744: * @param string $path
745: * @param string $delete "all", "db", or "files"
746: * @return bool
747: */
748: public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool
749: {
750: return parent::uninstall($hostname, $path, $delete);
751: }
752:
753: /**
754: * Get database configuration for a blog
755: *
756: * @param string $hostname domain or subdomain of wp blog
757: * @param string $path optional path
758: * @return array|bool
759: */
760: public function db_config(string $hostname, string $path = '')
761: {
762: $docroot = $this->getAppRoot($hostname, $path);
763: if (!$docroot) {
764: return error('failed to determine WP');
765: }
766: $code = 'ob_start(); register_shutdown_function(static function() { global $table_prefix; file_put_contents("php://fd/3", serialize(array("user" => DB_USER, "password" => DB_PASSWORD, "db" => DB_NAME, "host" => DB_HOST, "prefix" => $table_prefix))); ob_get_level() && ob_clean(); die(); }); include("./wp-config.php"); die();';
767: $ret = \Module\Support\Webapps\PhpWrapper::instantiateContexted($this->getAuthContextFromDocroot($docroot))->exec(
768: $docroot, '-r %(code)s 3>&1-', ['code' => $code],
769: );
770:
771: if (!$ret['success']) {
772: return error("failed to obtain WP configuration for `%s': %s", $docroot, $ret['stderr']);
773: }
774:
775: return \Util_PHP::unserialize(trim($ret['stdout']));
776: }
777:
778: /**
779: * Change WP admin credentials
780: *
781: * $fields is a hash whose indices match wp_update_user
782: * common fields include: user_pass, user_login, and user_nicename
783: *
784: * @link https://codex.wordpress.org/Function_Reference/wp_update_user
785: *
786: * @param string $hostname
787: * @param string $path
788: * @param array $fields
789: * @return bool
790: */
791: public function change_admin(string $hostname, string $path, array $fields): bool
792: {
793: $docroot = $this->getAppRoot($hostname, $path);
794: if (!$docroot) {
795: return warn('failed to change administrator information');
796: }
797: $admin = $this->get_admin($hostname, $path);
798:
799: if (!$admin) {
800: return error('cannot determine admin of WP install');
801: }
802:
803: if (isset($fields['user_login'])) {
804: return error('user login field cannot be changed in WP');
805: }
806:
807: $args = array(
808: 'user' => $admin
809: );
810: $cmd = 'user update %(user)s';
811: foreach ($fields as $k => $v) {
812: $cmd .= ' --' . $k . '=%(' . $k . ')s';
813: $args[$k] = $v;
814: }
815:
816: $ret = $this->execCommand($docroot, $cmd, $args);
817: if (!$ret['success']) {
818: return error("failed to update admin `%s', error: %s",
819: $admin,
820: coalesce($ret['stderr'], $ret['stdout'])
821: );
822: }
823:
824: return $ret['success'];
825: }
826:
827: /**
828: * Get the primary admin for a WP instance
829: *
830: * @param string $hostname
831: * @param null|string $path
832: * @return string admin or false on failure
833: */
834: public function get_admin(string $hostname, string $path = ''): ?string
835: {
836: $docroot = $this->getAppRoot($hostname, $path);
837: $ret = $this->execCommand($docroot, 'user list --role=administrator --field=user_login');
838: if (!$ret['success'] || !$ret['stdout']) {
839: warn('failed to enumerate WP administrative users');
840:
841: return null;
842: }
843:
844: return strtok($ret['stdout'], "\r\n");
845: }
846:
847: /**
848: * Update core, plugins, and themes atomically
849: *
850: * @param string $hostname subdomain or domain
851: * @param string $path optional path under hostname
852: * @param string $version
853: * @return bool
854: */
855: public function update_all(string $hostname, string $path = '', string $version = null): bool
856: {
857: $docroot = $this->getAppRoot($hostname, $path);
858: if (is_dir($this->domain_fs_path($docroot . '/wp-content/upgrade'))) {
859: // ensure upgrade/ is writeable. WP may create the directory if permissions allow
860: // during a self-directed upgrade
861: $ctx = null;
862: $stat = $this->file_stat($docroot);
863: if (!$stat || !$this->file_set_acls($docroot . '/wp-content/upgrade', [
864: [$stat['owner'] => 'rwx'],
865: [$stat['owner'] => 'drwx']
866: ], File_Module::ACL_MODE_RECURSIVE)) {
867: warn('Failed to apply ACLs for %s/wp-content/upgrade. WP update may fail', $docroot);
868: }
869: }
870: $ret = ($this->update_themes($hostname, $path) && $this->update_plugins($hostname, $path) &&
871: $this->update($hostname, $path, $version)) || error('failed to update all components');
872:
873: $this->setInfo($this->getDocumentRoot($hostname, $path), [
874: 'version' => $this->get_version($hostname, $path),
875: 'failed' => !$ret
876: ]);
877:
878: return $ret;
879: }
880:
881: /**
882: * Get next asset version
883: *
884: * @param string $name
885: * @param array $assetInfo
886: * @param string $lock
887: * @param string $type theme or plugin
888: * @return null|string
889: */
890: private function getNextVersionFromAsset(string $name, array $assetInfo, string $lock, string $type): ?string
891: {
892: if (!isset($assetInfo['version'])) {
893: error("Unable to query version for %s `%s', ignoring. Asset info: %s",
894: ucwords($type),
895: $name,
896: var_export($assetInfo, true)
897: );
898:
899: return null;
900: }
901:
902: $version = $assetInfo['version'];
903: $versions = $this->{$type . 'Versions'}($name) ?? [$assetInfo['version'], $assetInfo['max']];
904: $next = $this->windThroughVersions($version, $lock, $versions);
905: if ($next === null && end($versions) !== $version) {
906: info("%s `%s' already at maximal version `%s' for lock spec `%s'. " .
907: 'Newer versions available. Manually upgrade or disable version lock to ' .
908: 'update this component.',
909: ucwords($type), $name, $version, $lock
910: );
911: }
912:
913: return $next;
914: }
915:
916: /**
917: * Move pointer through versions finding the next suitable candidate
918: *
919: * @param string $cur
920: * @param null|string $lock
921: * @param array $versions
922: * @return string|null
923: */
924: private function windThroughVersions(string $cur, ?string $lock, array $versions): ?string
925: {
926: $maximal = $tmp = $cur;
927: do {
928: $tmp = $maximal;
929: $maximal = Versioning::nextSemanticVersion(
930: $tmp,
931: $versions,
932: $lock
933: );
934: } while ($maximal && $tmp !== $maximal);
935:
936: if ($maximal === $cur) {
937: return null;
938: }
939:
940: return $maximal;
941: }
942:
943: /**
944: * Update WordPress themes
945: *
946: * @param string $hostname subdomain or domain
947: * @param string $path optional path under hostname
948: * @param array $themes
949: * @return bool
950: */
951: public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
952: {
953: $docroot = $this->getAppRoot($hostname, $path);
954: if (!$docroot) {
955: return error('update failed');
956: }
957: $flags = [];
958: $lock = $this->getVersionLock($docroot);
959: $skiplist = $this->getSkiplist($docroot, 'theme');
960:
961: if (!$skiplist && !$themes && !$lock) {
962: $ret = $this->execCommand($docroot, 'theme update --all ' . implode(' ', $flags));
963: if (!$ret['success']) {
964: return error("theme update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
965: }
966:
967: return $ret['success'];
968: }
969:
970: $status = 1;
971: if (false === ($allthemeinfo = $this->theme_status($hostname, $path))) {
972: return false;
973: }
974: $themes = $themes ?: array_keys($allthemeinfo);
975: foreach ($themes as $theme) {
976: $version = null;
977: $name = $theme['name'] ?? $theme;
978: $themeInfo = $allthemeinfo[$name];
979: if ((isset($skiplist[$name]) || $themeInfo['current']) && !array_get((array)$theme, 'force')) {
980: continue;
981: }
982:
983: if (isset($theme['version'])) {
984: $version = $theme['version'];
985: } else if ($lock && !($version = $this->getNextVersionFromAsset($name, $themeInfo, $lock, 'theme'))) {
986: // see if 'next' will satisfy the requirement
987: continue;
988: }
989:
990: $cmd = 'theme update %(name)s';
991: $args = [
992: 'name' => $name
993: ];
994:
995: // @XXX theme update --version=X.Y.Z NAME
996: // bad themes (better-wp-security) will induce false positives on remote versions
997: // if a version is specified, pass this explicitly to force an update
998: // see wp-cli issue #370
999:
1000: $cmdTmp = $cmd;
1001: if ($version) {
1002: $cmd .= ' --version=%(version)s';
1003: $args['version'] = $version;
1004: }
1005:
1006: $cmd .= ' ' . implode(' ', $flags);
1007: $ret = $this->execCommand($docroot, $cmd, $args);
1008:
1009: if (!$ret['success'] && $version) {
1010: warn(
1011: "Update failed for %s, falling back to versionless update: %s",
1012: $name,
1013: coalesce($ret['stderr'], $ret['stdout'])
1014: );
1015: $cmdTmp .= ' ' . implode(' ', $flags);
1016: $ret = $this->execCommand($docroot, $cmdTmp, $args);
1017: }
1018:
1019: if (!$ret['success']) {
1020: error("failed to update theme `%s': %s", $name, coalesce($ret['stderr'], $ret['stdout']));
1021: }
1022: $status &= $ret['success'];
1023: }
1024:
1025: return (bool)$status;
1026: }
1027:
1028: /**
1029: * Get update protection list
1030: *
1031: * @param string $docroot
1032: * @param string|null $type
1033: * @return array
1034: */
1035: protected function getSkiplist(string $docroot, ?string $type)
1036: {
1037: if ($type !== null && $type !== 'plugin' && $type !== 'theme') {
1038: error("Unrecognized skiplist type `%s'", $type);
1039:
1040: return [];
1041: }
1042: $skiplist = $this->skiplistContents($docroot);
1043:
1044: return array_flip(array_filter(array_map(static function ($line) use ($type) {
1045: if (false !== ($pos = strpos($line, ':'))) {
1046: if (!$type || strpos($line, $type . ':') === 0) {
1047: return substr($line, $pos + 1);
1048: }
1049:
1050: return;
1051: }
1052:
1053: return $line;
1054: }, $skiplist)));
1055: }
1056:
1057: private function skiplistContents(string $approot): array
1058: {
1059: $skipfile = $this->domain_fs_path($approot . '/' . self::ASSET_SKIPLIST);
1060: if (!file_exists($skipfile)) {
1061: return [];
1062: }
1063:
1064: return (array)file($skipfile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
1065: }
1066:
1067: /**
1068: * Update WordPress plugins
1069: *
1070: * @param string $hostname domain or subdomain
1071: * @param string $path optional path within host
1072: * @param array $plugins flat list of plugins or multi-dimensional of name, force, version
1073: * @return bool
1074: */
1075: public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
1076: {
1077: $docroot = $this->getAppRoot($hostname, $path);
1078: if (!$docroot) {
1079: return error('update failed');
1080: }
1081: $flags = [];
1082: $lock = $this->getVersionLock($docroot);
1083: $skiplist = $this->getSkiplist($docroot, 'plugin');
1084:
1085: if (!$plugins && !$skiplist && !$lock) {
1086: $ret = $this->execCommand($docroot, 'plugin update --all ' . implode(' ', $flags));
1087: if (!$ret['success']) {
1088: return error("plugin update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
1089: }
1090:
1091: return $ret['success'];
1092: }
1093:
1094: $status = 1;
1095: if (false === ($allplugininfo = $this->plugin_status($hostname, $path))) {
1096: return false;
1097: }
1098: $plugins = $plugins ?: array_keys($allplugininfo);
1099: foreach ($plugins as $plugin) {
1100:
1101: $version = null;
1102: $name = $plugin['name'] ?? $plugin;
1103: $pluginInfo = $allplugininfo[$name];
1104: if ((isset($skiplist[$name]) || $pluginInfo['current']) && !array_get((array)$plugin, 'force')) {
1105: continue;
1106: }
1107:
1108: if (isset($plugin['version'])) {
1109: $version = $plugin['version'];
1110: } else if ($lock && !($version = $this->getNextVersionFromAsset($name, $pluginInfo, $lock, 'plugin'))) {
1111: // see if 'next' will satisfy the requirement
1112: continue;
1113: }
1114:
1115: $cmd = 'plugin update %(name)s';
1116: $args = [
1117: 'name' => $name
1118: ];
1119: // @XXX plugin update --version=X.Y.Z NAME
1120: // bad plugins (better-wp-security) will induce false positives on remote versions
1121: // if a version is specified, pass this explicitly to force an update
1122: // see wp-cli issue #370
1123: //
1124: // confirm with third party checks
1125:
1126: $cmdTmp = $cmd;
1127: if ($version) {
1128: $cmd .= ' --version=%(version)s';
1129: $args['version'] = $version;
1130: }
1131: $cmd .= ' ' . implode(' ', $flags);
1132: $ret = $this->execCommand($docroot, $cmd, $args);
1133:
1134: if (!$ret['success'] && $version) {
1135: warn(
1136: "Update failed for %s, falling back to versionless update: %s",
1137: $name,
1138: coalesce($ret['stderr'], $ret['stdout'])
1139: );
1140: $cmdTmp .= ' ' . implode(' ', $flags);
1141: $ret = $this->execCommand($docroot, $cmdTmp, $args);
1142: }
1143:
1144: if (!$ret['success']) {
1145: error("failed to update plugin `%s': %s", $name, coalesce($ret['stderr'], $ret['stdout']));
1146: }
1147: $status &= $ret['success'];
1148: }
1149:
1150: return (bool)$status;
1151: }
1152:
1153: /**
1154: * Update WordPress to latest version
1155: *
1156: * @param string $hostname domain or subdomain under which WP is installed
1157: * @param string $path optional subdirectory
1158: * @param string $version
1159: * @return bool
1160: */
1161: public function update(string $hostname, string $path = '', string $version = null): bool
1162: {
1163: $docroot = $this->getAppRoot($hostname, $path);
1164: if (!$docroot) {
1165: return error('update failed');
1166: }
1167: $this->assertOwnershipSystemCheck($docroot);
1168:
1169: $cmd = 'core update';
1170: $args = [];
1171:
1172: if ($version) {
1173: if (!is_scalar($version) || strcspn($version, '.0123456789')) {
1174: return error('invalid version number, %s', $version);
1175: }
1176: $cmd .= ' --version=%(version)s';
1177: $args['version'] = $version;
1178:
1179: $ret = $this->execCommand($docroot, 'option get WPLANG');
1180: if (trim($ret['stdout']) === 'en') {
1181: // issue seen with Softaculous installing under "en" locale, which generates
1182: // an invalid update URI
1183: warn('Bugged WPLANG setting. Changing en to en_US');
1184: $this->execCommand($docroot, 'site switch-language en_US');
1185: }
1186: }
1187:
1188: $oldversion = $this->get_version($hostname, $path);
1189: $ret = $this->execCommand($docroot, $cmd, $args);
1190:
1191: if (!$ret['success']) {
1192: $output = coalesce($ret['stderr'], $ret['stdout']);
1193: if (str_starts_with($output, 'Error: Download failed.')) {
1194: return warn('Failed to fetch update - retry update later: %s', $output);
1195: }
1196:
1197: return error("update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
1198: }
1199:
1200: // Sanity check as WP-CLI is known to fail while producing a 0 exit code
1201: if ($oldversion === $this->get_version($hostname, $path) &&
1202: !$this->is_current($oldversion, Versioning::asMajor($oldversion))) {
1203: return error('Failed to update WordPress - old version is same as new version - %s! ' .
1204: 'Diagnostics: (stderr) %s (stdout) %s', $oldversion, $ret['stderr'], $ret['stdout']);
1205: }
1206:
1207: info('updating WP database if necessary');
1208: $ret = $this->execCommand($docroot, 'core update-db');
1209: $this->shareOwnershipSystemCheck($docroot);
1210:
1211: if (!$ret['success']) {
1212: return warn('failed to update WP database - ' .
1213: 'login to WP admin panel to manually perform operation');
1214: }
1215:
1216: return $ret['success'];
1217: }
1218:
1219: /**
1220: * Get theme status
1221: *
1222: * Sample response:
1223: * [
1224: * hestia => [
1225: * version => 1.1.50
1226: * next => 1.1.51
1227: * current => false
1228: * max => 1.1.66
1229: * ]
1230: * ]
1231: *
1232: * @param string $hostname
1233: * @param string $path
1234: * @param string|null $theme
1235: * @return array|bool
1236: */
1237: public function theme_status(string $hostname, string $path = '', string $theme = null)
1238: {
1239: $docroot = $this->getAppRoot($hostname, $path);
1240: if (!$docroot) {
1241: return error('invalid WP location');
1242: }
1243:
1244: $matches = $this->assetListWrapper($docroot, 'theme', [
1245: 'name',
1246: 'status',
1247: 'version',
1248: 'update_version'
1249: ]);
1250:
1251: if (!$matches) {
1252: return false;
1253: }
1254:
1255: $themes = [];
1256: foreach ($matches as $match) {
1257: if (\in_array($match['status'], self::NON_UPDATEABLE_TYPES, true)) {
1258: continue;
1259: }
1260: $name = $match['name'];
1261: $version = $match['version'];
1262: if (!$versions = $this->themeVersions($name)) {
1263: // commercial themes
1264: if (empty($match['update_version'])) {
1265: $match['update_version'] = $match['version'];
1266: }
1267:
1268: $versions = [$match['version'], $match['update_version']];
1269: }
1270:
1271: $themes[$name] = [
1272: 'version' => $version,
1273: 'next' => Versioning::nextVersion($versions, $version),
1274: 'max' => $this->themeInfo($name)['version'] ?? end($versions)
1275: ];
1276: // dev version may be present
1277: $themes[$name]['current'] = version_compare((string)array_get($themes, "${name}.max",
1278: '99999999.999'), (string)$version, '<=') ?:
1279: (bool)Versioning::current($versions, $version);
1280: }
1281:
1282: return $theme ? $themes[$theme] ?? error("unknown theme `%s'", $theme) : $themes;
1283: }
1284:
1285: /**
1286: * Get theme versions
1287: *
1288: * @param string $theme
1289: * @return null|array
1290: */
1291: protected function themeVersions($theme): ?array
1292: {
1293: $info = $this->themeInfo($theme);
1294: if (!$info || empty($info['versions'])) {
1295: return null;
1296: }
1297: array_forget($info, 'versions.trunk');
1298:
1299: return array_keys($info['versions']);
1300: }
1301:
1302: /**
1303: * Get theme information
1304: *
1305: * @param string $theme
1306: * @return array|null
1307: */
1308: protected function themeInfo(string $theme): ?array
1309: {
1310: $cache = \Cache_Super_Global::spawn();
1311: $key = 'wp.tinfo-' . $theme;
1312: if (false !== ($data = $cache->get($key))) {
1313: return $data;
1314: }
1315: $url = str_replace('%theme%', $theme, static::THEME_VERSION_CHECK_URL);
1316: $info = [];
1317: $contents = silence(static function () use ($url) {
1318: return file_get_contents($url);
1319: });
1320: if (false !== $contents) {
1321: $info = (array)json_decode($contents, true);
1322: if (isset($info['versions'])) {
1323: uksort($info['versions'], 'version_compare');
1324: }
1325: } else {
1326: info("Theme `%s' detected as commercial. Using transient data.", $theme);
1327: }
1328: $cache->set($key, $info, 86400);
1329:
1330: return $info;
1331: }
1332:
1333: public function install_theme(string $hostname, string $path, string $theme, string $version = null): bool
1334: {
1335: $docroot = $this->getAppRoot($hostname, $path);
1336: if (!$docroot) {
1337: return error('invalid WP location');
1338: }
1339:
1340: $args = array(
1341: 'theme' => $theme
1342: );
1343: $cmd = 'theme install %(theme)s --activate';
1344: if ($version) {
1345: $cmd .= ' --version=%(version)s';
1346: $args['version'] = $version;
1347: }
1348: $ret = $this->execCommand($docroot, $cmd, $args);
1349: if (!$ret['success']) {
1350: return error("failed to install theme `%s': %s", $theme, coalesce($ret['stderr'], $ret['stdout']));
1351: }
1352: info("installed theme `%s'", $theme);
1353:
1354: return true;
1355: }
1356:
1357: /**
1358: * Relax permissions to allow write-access
1359: *
1360: * @param string $hostname
1361: * @param string $path
1362: * @return bool
1363: * @internal param string $mode
1364: */
1365: public function unfortify(string $hostname, string $path = ''): bool
1366: {
1367: return parent::unfortify($hostname, $path) && $this->setFsMethod($this->getAppRoot($hostname, $path), false);
1368: }
1369:
1370: /**
1371: * Install wp-cli if necessary
1372: *
1373: * @return bool
1374: * @throws \Exception
1375: */
1376: public function _housekeeping()
1377: {
1378: if (file_exists(Wpcli::BIN) && filemtime(Wpcli::BIN) < filemtime(__FILE__)) {
1379: unlink(Wpcli::BIN);
1380: }
1381:
1382: if (!file_exists(Wpcli::BIN)) {
1383: $url = Wpcli::DOWNLOAD_URL;
1384: $tmp = tempnam(storage_path('tmp'), 'wp-cli') . '.phar';
1385: $res = Util_HTTP::download($url, $tmp);
1386: if (!$res) {
1387: file_exists($tmp) && unlink($tmp);
1388:
1389: return error('failed to install wp-cli module');
1390: }
1391: try {
1392: (new \Phar($tmp))->getSignature();
1393: rename($tmp, Wpcli::BIN) && chmod(Wpcli::BIN, 0755);
1394: info('downloaded wp-cli');
1395: } catch (\UnexpectedValueException $e) {
1396: return error('WP-CLI signature failed, ignoring update');
1397: } finally {
1398: if (file_exists($tmp)) {
1399: unlink($tmp);
1400: }
1401: }
1402: // older platforms
1403: $local = $this->service_template_path('siteinfo') . Wpcli::BIN;
1404: if (!file_exists($local) && !copy(Wpcli::BIN, $local)) {
1405: return false;
1406: }
1407: chmod($local, 0755);
1408:
1409: }
1410:
1411: if (is_dir($this->service_template_path('siteinfo'))) {
1412: $link = $this->service_template_path('siteinfo') . '/usr/bin/wp-cli';
1413: $local = $this->service_template_path('siteinfo') . Wpcli::BIN;
1414: if (!is_link($link) || realpath($link) !== realpath($local)) {
1415: is_link($link) && unlink($link);
1416: $referent = $this->file_convert_absolute_relative($link, $local);
1417:
1418: return symlink($referent, $link);
1419: }
1420: }
1421:
1422: return true;
1423: }
1424:
1425: /**
1426: * Get all available WordPress versions
1427: *
1428: * @return array versions descending
1429: */
1430: public function get_versions(): array
1431: {
1432: $versions = $this->_getVersions();
1433:
1434: return array_reverse(array_column($versions, 'version'));
1435: }
1436:
1437: protected function mapFilesFromList(array $files, string $docroot): array
1438: {
1439: if (file_exists($this->domain_fs_path($docroot . '/wp-content'))) {
1440: return parent::mapFilesFromList($files, $docroot);
1441: }
1442: $path = $tmp = $docroot;
1443: // WP can allow relocation of assets, look for them
1444: $ret = \Module\Support\Webapps\PhpWrapper::instantiateContexted($this->getAuthContextFromDocroot($docroot))->exec(
1445: $docroot, '-r %(code)s', [
1446: 'code' => 'set_error_handler(function() { echo defined("WP_CONTENT_DIR") ? constant("WP_CONTENT_DIR") : dirname(__FILE__); die(); }); include("./wp-config.php"); trigger_error("");define("ABS_PATH", "/dev/null");'
1447: ]
1448: );
1449:
1450: if ($ret['success']) {
1451: $tmp = $ret['stdout'];
1452: if (0 === strpos($tmp, $this->domain_fs_path() . '/')) {
1453: $tmp = $this->file_unmake_path($tmp);
1454: }
1455: }
1456:
1457: if ($path !== $tmp) {
1458: $relpath = $this->file_convert_absolute_relative($docroot . '/wp-content/', $tmp);
1459: foreach ($files as $k => $f) {
1460: if (0 !== strncmp($f, 'wp-content/', 11)) {
1461: continue;
1462: }
1463: $f = $relpath . substr($f, strlen('wp-content'));
1464: $files[$k] = $f;
1465: }
1466: }
1467:
1468: return parent::mapFilesFromList($files, $docroot);
1469: }
1470:
1471: /**
1472: * Get latest WP release
1473: *
1474: * @return string
1475: */
1476: protected function _getLastestVersion()
1477: {
1478: $versions = $this->_getVersions();
1479: if (!$versions) {
1480: return null;
1481: }
1482:
1483: return $versions[0]['version'];
1484: }
1485:
1486: /**
1487: * Get all current major versions
1488: *
1489: * @return array
1490: */
1491: protected function _getVersions()
1492: {
1493: $key = 'wp.versions';
1494: $cache = Cache_Super_Global::spawn();
1495: if (false !== ($ver = $cache->get($key))) {
1496: return $ver;
1497: }
1498: $url = self::VERSION_CHECK_URL;
1499: $context = stream_context_create(['http' => ['timeout' => 5]]);
1500: $contents = file_get_contents($url, false, $context);
1501: if (!$contents) {
1502: return array();
1503: }
1504: $versions = json_decode($contents, true);
1505: $versions = $versions['offers'];
1506: if (isset($versions[1]['version'], $versions[0]['version'])
1507: && $versions[0]['version'] === $versions[1]['version']) {
1508: // WordPress sends most current + version tree
1509: array_shift($versions);
1510: }
1511: $cache->set($key, $versions, 43200);
1512:
1513: return $versions;
1514: }
1515:
1516:
1517: /**
1518: * Get basic summary of assets
1519: *
1520: * @param string $hostname
1521: * @param string $path
1522: * @return array
1523: */
1524: public function asset_summary(string $hostname, string $path = ''): array
1525: {
1526: if (!$approot = $this->getAppRoot($hostname, $path)) {
1527: return [];
1528: }
1529:
1530: $plugin = $this->assetListWrapper($approot, 'plugin', ['name', 'status', 'version', 'description', 'update_version']);
1531: $theme = $this->assetListWrapper($approot, 'theme', ['name', 'status', 'version', 'description', 'update_version']);
1532: $skippedtheme = $this->getSkiplist($approot, 'theme');
1533: $skippedplugin = $this->getSkiplist($approot, 'plugin');
1534: $merged = [];
1535: foreach (['plugin', 'theme'] as $type) {
1536: $skipped = ${'skipped' . $type};
1537: $assets = (array)${$type};
1538: usort($assets, static function ($a1, $a2) {
1539: return strnatcmp($a1['name'], $a2['name']);
1540: });
1541: foreach ($assets as &$asset) {
1542: if (\in_array($asset['status'], self::NON_UPDATEABLE_TYPES, true)) {
1543: continue;
1544: }
1545: $name = $asset['name'];
1546: $asset['skipped'] = isset($skipped[$name]);
1547: $asset['active'] = $asset['status'] !== 'inactive';
1548: $asset['type'] = $type;
1549: $merged[] = $asset;
1550: }
1551: unset($asset);
1552: }
1553: return $merged;
1554: }
1555:
1556: /**
1557: * Skip updating an asset
1558: *
1559: * @param string $hostname
1560: * @param string $path
1561: * @param string $name
1562: * @param string|null $type
1563: * @return bool
1564: */
1565: public function skip_asset(string $hostname, string $path, string $name, ?string $type): bool
1566: {
1567: if (!$approot = $this->getAppRoot($hostname, $path)) {
1568: return error("App root for `%s'/`%s' does not exist", $hostname, $path);
1569: }
1570:
1571: $contents = implode("\n", $this->skiplistContents($approot));
1572: $contents .= "\n" . $type . ($type ? ':' : '') . $name;
1573:
1574: return $this->file_put_file_contents("${approot}/" . self::ASSET_SKIPLIST, ltrim($contents));
1575: }
1576:
1577: /**
1578: * Permit updates of an asset
1579: *
1580: * @param string $hostname
1581: * @param string $path
1582: * @param string $name
1583: * @param string|null $type
1584: * @return bool
1585: */
1586: public function unskip_asset(string $hostname, string $path, string $name, ?string $type): bool
1587: {
1588: if (!$approot = $this->getAppRoot($hostname, $path)) {
1589: return error("App root for `%s'/`%s' does not exist", $hostname, $path);
1590: }
1591:
1592: $assets = $this->getSkiplist($approot, $type);
1593:
1594: if (!isset($assets[$name])) {
1595: return warn("%(type)s `%(asset)s' not present in skiplist", ['type' => $type, 'asset' => $name]);
1596: }
1597:
1598: $skiplist = array_flip($this->skiplistContents($approot));
1599: unset($skiplist["${type}:${name}"],$skiplist[$name]);
1600: return $this->file_put_file_contents("${approot}/" . self::ASSET_SKIPLIST, implode("\n", array_keys($skiplist)));
1601: }
1602:
1603:
1604: public function asset_skipped(string $hostname, string $path, string $name, ?string $type): bool
1605: {
1606: if (!$approot = $this->getAppRoot($hostname, $path)) {
1607: return error("App root for `%s'/`%s' does not exist", $hostname, $path);
1608: }
1609:
1610: $assets = $this->getSkiplist($approot, $type);
1611: return isset($assets[$name]);
1612: }
1613:
1614: /**
1615: * Duplicate a WordPress instance
1616: *
1617: * @param string $shostname
1618: * @param string $spath
1619: * @param string $dhostname
1620: * @param string $dpath
1621: * @param array $opts clean: (bool) purge target directory
1622: * @return bool
1623: */
1624: public function duplicate(string $shostname, string $spath, string $dhostname, string $dpath, array $opts = []): bool
1625: {
1626: if (!$this->valid($shostname, $spath)) {
1627: return error("%(hostname)s/%(path)s does not contain a valid %(app)s install",
1628: ['hostname' => $shostname, 'path' => $spath, 'app' => static::APP_NAME]
1629: );
1630: }
1631:
1632: return (bool)serial(function () use ($spath, $dpath, $dhostname, $shostname, $opts) {
1633: $sapproot = $this->getAppRoot($shostname, $spath);
1634: $dapproot = $this->getAppRoot($dhostname, $dpath);
1635: // nesting directories is permitted, denesting will fail in checkDocroot() below
1636: // otherwise add reciprocal strpos() check
1637: if ($sapproot === $dapproot || 0 === strpos("${dapproot}/", "${sapproot}/")) {
1638: return error("Source `%(source)s' and target `%(target)s' are the same or nested",
1639: ['source' => $sapproot, 'target' => $dapproot]);
1640: }
1641:
1642: if (!empty($opts['clean']) && is_dir($this->domain_fs_path($dapproot))) {
1643: if ($this->webapp_valid($dhostname, $dpath)) {
1644: $this->webapp_uninstall($dhostname, $dpath);
1645: } else {
1646: $this->file_delete($dapproot, true);
1647: }
1648: }
1649:
1650: $this->checkDocroot($dapproot);
1651:
1652: // @todo $opts['link-uploads']
1653: $this->file_copy("${sapproot}/", $dapproot, true);
1654: $db = DatabaseGenerator::mysql($this->getAuthContext(), $dhostname);
1655: $db->create();
1656: $db->autoRollback = true;
1657: $oldDbConfig = $this->db_config($shostname, $spath);
1658: $this->mysql_clone($oldDbConfig['db'], $db->database);
1659: $sapp = Loader::fromDocroot('wordpress', $sapproot, $this->getAuthContext());
1660: $dapp = Loader::fromDocroot('wordpress', $dapproot, $this->getAuthContext());
1661:
1662: $vals = array_diff_key(
1663: $this->get_reconfigurable($shostname, $spath, $sapp->getReconfigurables()),
1664: array_flip($dapp::TRANSIENT_RECONFIGURABLES)
1665: );
1666: info("Reconfiguring %s", implode(", ", array_key_map(static function ($k, $v) {
1667: if (is_bool($v)) {
1668: $v = $v ? "true" : "false";
1669: }
1670: return "$k => $v";
1671: }, $vals)));
1672: $dapp->reconfigure($vals);
1673: $this->updateConfiguration($dapproot, [
1674: 'DB_NAME' => $db->database,
1675: 'DB_USER' => $db->username,
1676: 'DB_PASSWORD' => $db->password,
1677: 'DB_HOST' => $db->hostname,
1678: ]);
1679: $db->autoRollback = false;
1680: $cli = Wpcli::instantiateContexted($this->getAuthContext());
1681: $cli->exec($dapproot, 'config shuffle-salts');
1682: $dapp->reconfigure(['migrate' => $dhostname . '/' . $dpath]);
1683:
1684: if ($dapp->hasGit()) {
1685: $git = Git::instantiateContexted(
1686: $this->getAuthContext(), [
1687: $dapp->getAppRoot(),
1688: MetaManager::factory($this->getAuthContext())->get($dapp->getDocumentRoot())
1689: ]
1690: );
1691: $git->remove();
1692: $git->createRepository();
1693: }
1694:
1695: return null !== $this->webapp_discover($dhostname, $dpath);
1696: });
1697: }
1698:
1699: /**
1700: * Install a WP-CLI package
1701: *
1702: * @param string $package
1703: * @return bool
1704: */
1705: public function install_package(string $package): bool
1706: {
1707: $cli = ComposerWrapper::instantiateContexted($this->getAuthContext());
1708: if (!$this->file_exists('~/.wp-cli/packages')) {
1709: $this->file_create_directory('~/.wp-cli/packages', 493, true);
1710: $cli->exec('~/.wp-cli/packages', 'init -n --name=local/wp-cli-packages -sdev --repository=https://wp-cli.org/package-index/');
1711: }
1712: $ret = $cli->exec('~/.wp-cli/packages', 'require %s', [$package]);
1713:
1714:
1715: return $ret['success'] ?:
1716: error("Failed to install %s: %s", $package, coalesce($ret['stderr'], $ret['stdout']));
1717: }
1718:
1719: /**
1720: * WP-CLI package installed
1721: *
1722: * @param string $package
1723: * @return bool
1724: */
1725: public function package_installed(string $package): bool
1726: {
1727: $cli = Wpcli::instantiateContexted($this->getAuthContext());
1728: $ret = $cli->exec(null, 'package path %s', [$package]);
1729: return $ret['success'];
1730: }
1731:
1732: /**
1733: * Uninstall WP-CLI package
1734: *
1735: * @param string $package
1736: * @return bool
1737: */
1738: public function uninstall_package(string $package): bool
1739: {
1740: $cli = ComposerWrapper::instantiateContexted($this->getAuthContext());
1741: $ret = $cli->exec('~/.wp-cli/packages', 'remove %s', [$package]);
1742: return $ret['success'] ?:
1743: error("Failed to uninstall %s: %s", $package, coalesce($ret['stderr'], $ret['stdout']));
1744: }
1745:
1746: /**
1747: * Apply WP-CLI directive
1748: *
1749: * @param string $command directive
1750: * @param array|string $args formatted args
1751: * @param string $hostname hostname
1752: * @param string $path subpath
1753: * @return mixed hash of paths or single arraycomprised of @see pman:run() + ['hostname', 'path']
1754: *
1755: * Sample usage:
1756: *
1757: * `wordpress:cli "plugin uninstall dolly"`
1758: * `wordpress:cli "plugin uninstall %s" ["dolly"]`
1759: * - Remove dolly plugin from all WP sites
1760: * `wordpress:cli "core verify-checksums" "" domain.com`
1761: * - Run verify-checksums on core distribution against domain.com
1762: * `wordpress:cli "--json plugin list"`
1763: * - Report all plugins encoded in JSON
1764: *
1765: */
1766: public function cli(string $command, $args = [], string $hostname = null, string $path = ''): array
1767: {
1768: if (!$hostname) {
1769: $apps = (new \Module\Support\Webapps\Finder($this->getAuthContext()))->getApplicationsByType($this->getModule());
1770: } else {
1771: $docroot = $this->getDocumentRoot($hostname, $path);
1772: $apps = [$docroot => ['path' => $path, 'hostname' => $hostname]];
1773: }
1774:
1775: $wpcli = Wpcli::instantiateContexted($this->getAuthContext());
1776: $processed = [];
1777: foreach ($apps as $info) {
1778: if (!$this->valid($info['hostname'], $info['path'] ?? '')) {
1779: debug("%(host)/%(path)s is not valid %(type)s, skipping", [
1780: 'host' => $info['hostname'],
1781: 'path' => $info['path'] ?? '',
1782: 'type' => $this->getModule()
1783: ]);
1784: }
1785: $appRoot = $this->getAppRoot($info['hostname'], $info['path']);
1786: $ret = $wpcli->exec($appRoot, $command, (array)$args);
1787:
1788: $processed[$appRoot] = array_only($info, ['hostname', 'path']) + $ret;
1789: }
1790:
1791: return $hostname ? array_pop($processed) : $processed;
1792: }
1793: }