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