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