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