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: if (!$versions = $this->pluginVersions($name)) {
444: // commercial plugin
445: if (empty($match['update_version'])) {
446: $match['update_version'] = $match['version'];
447: }
448:
449: $versions = [$match['version'], $match['update_version']];
450: }
451: $pluginmeta[$name] = [
452: 'version' => $version,
453: 'next' => Versioning::nextVersion($versions, $version),
454: 'max' => $this->pluginInfo($name)['version'] ?? end($versions),
455: 'active' => $match['status'] !== 'inactive'
456: ];
457: // dev version may be present
458: $pluginmeta[$name]['current'] = version_compare((string)array_get($pluginmeta, "${name}.max",
459: '99999999.999'), (string)$version, '<=') ?:
460: (bool)Versioning::current($versions, $version);
461: }
462:
463: return $plugin ? $pluginmeta[$plugin] ?? error("unknown plugin `%s'", $plugin) : $pluginmeta;
464: }
465:
466: protected function assetListWrapper(string $approot, string $type, array $fields): ?array {
467: $ret = $this->execCommand($approot,
468: $type . ' list --format=json --fields=%s', [implode(',', $fields)]);
469: // filter plugin garbage from Elementor, et al
470: // enqueued updates emits non-JSON in stdout
471: $line = strtok($ret['stdout'], "\n");
472: do {
473: if ($line[0] === '[') {
474: break;
475: }
476: } while (false !== ($line = strtok("\n")));
477: if (!$ret['success']) {
478: error('failed to get %s status: %s', $type, coalesce($ret['stderr'], $ret['stdout']));
479: return null;
480: }
481:
482: if (null === ($matches = json_decode(str_replace(':""', ':null', $ret['stdout']), true))) {
483: dlog('Failed decode results: %s', var_export($ret, true));
484: return nerror('Failed to decode %s output', $type);
485: }
486:
487: return $matches;
488: }
489:
490: protected function pluginVersions(string $plugin): ?array
491: {
492: $info = $this->pluginInfo($plugin);
493: if (!$info || empty($info['versions'])) {
494: return null;
495: }
496: array_forget($info, 'versions.trunk');
497:
498: return array_keys($info['versions']);
499: }
500:
501: /**
502: * Get information about a plugin
503: *
504: * @param string $plugin
505: * @return array
506: */
507: protected function pluginInfo(string $plugin): array
508: {
509: $cache = \Cache_Super_Global::spawn();
510: $key = 'wp.pinfo-' . $plugin;
511: if (false !== ($data = $cache->get($key))) {
512: return $data;
513: }
514: $url = str_replace('%plugin%', $plugin, static::PLUGIN_VERSION_CHECK_URL);
515: $info = [];
516: $contents = silence(static function() use($url) {
517: return file_get_contents($url);
518: });
519: if (false !== $contents) {
520: $info = (array)json_decode($contents, true);
521: if (isset($info['versions'])) {
522: uksort($info['versions'], 'version_compare');
523: }
524: } else {
525: info("Plugin `%s' detected as commercial. Using transient data.", $plugin);
526: }
527: $cache->set($key, $info, 86400);
528:
529: return $info;
530: }
531:
532: /**
533: * Install and activate plugin
534: *
535: * @param string $hostname domain or subdomain of wp install
536: * @param string $path optional path component of wp install
537: * @param string $plugin plugin name
538: * @param string $version optional plugin version
539: * @return bool
540: */
541: public function install_plugin(
542: string $hostname,
543: string $path,
544: string $plugin,
545: string $version = ''
546: ): bool {
547: $docroot = $this->getAppRoot($hostname, $path);
548: if (!$docroot) {
549: return error('invalid WP location');
550: }
551:
552: $args = array(
553: 'plugin' => $plugin
554: );
555: $cmd = 'plugin install %(plugin)s --activate';
556: if ($version) {
557: $cmd .= ' --version=%(version)s';
558: $args['version'] = $version;
559: }
560:
561: $ret = $this->execCommand(
562: $docroot,
563: $cmd,
564: $args,
565: [
566: 'REQUEST_URI' => '/' . rtrim($path)
567: ]
568: );
569: if (!$ret['success']) {
570: return error("failed to install plugin `%s': %s", $plugin, coalesce($ret['stderr'], $ret['stdout']));
571: }
572: info("installed plugin `%s'", $plugin);
573:
574: return true;
575: }
576:
577: /**
578: * Uninstall a plugin
579: *
580: * @param string $hostname
581: * @param string $path
582: * @param string $plugin plugin name
583: * @param bool $force delete even if plugin activated
584: * @return bool
585: */
586: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
587: {
588: $docroot = $this->getAppRoot($hostname, $path);
589: if (!$docroot) {
590: return error('invalid WP location');
591: }
592:
593: $args = array(
594: 'plugin' => $plugin
595: );
596: $cmd = 'plugin uninstall %(plugin)s';
597: if ($force) {
598: $cmd .= ' --deactivate';
599: }
600: $ret = $this->execCommand($docroot, $cmd, $args);
601:
602: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
603: return error("failed to uninstall plugin `%s': %s", $plugin, coalesce($ret['stderr'], $ret['stdout']));
604: }
605: info("uninstalled plugin `%s'", $plugin);
606:
607: return true;
608: }
609:
610: /**
611: * Disable plugin
612: *
613: * @param string $hostname
614: * @param string $path
615: * @param string $plugin
616: * @return bool
617: */
618: public function disable_plugin(string $hostname, string $path, string $plugin): bool
619: {
620: $docroot = $this->getAppRoot($hostname, $path);
621: if (!$docroot) {
622: return error('invalid WP location');
623: }
624:
625: return $this->assetManagerWrapper($docroot, 'plugin', 'deactivate', $plugin);
626: }
627:
628: /**
629: * Enable plugin
630: *
631: * @param string $hostname
632: * @param string $path
633: * @param string $plugin
634: * @return bool
635: */
636: public function enable_plugin(string $hostname, string $path, string $plugin): bool
637: {
638: $docroot = $this->getAppRoot($hostname, $path);
639: if (!$docroot) {
640: return error('invalid WP location');
641: }
642:
643: return $this->assetManagerWrapper($docroot, 'plugin', 'activate', $plugin);
644: }
645:
646: /**
647: * Disable theme
648: *
649: * @param string $hostname
650: * @param string $path
651: * @param string $plugin
652: * @return bool
653: */
654: public function disable_theme(string $hostname, string $path, string $plugin): bool
655: {
656: $docroot = $this->getAppRoot($hostname, $path);
657: if (!$docroot) {
658: return error('invalid WP location');
659: }
660:
661: return $this->assetManagerWrapper($docroot, 'theme', 'disable', $plugin);
662: }
663:
664: /**
665: * Enable theme
666: *
667: * @param string $hostname
668: * @param string $path
669: * @param string $plugin
670: * @return bool
671: */
672: public function enable_theme(string $hostname, string $path, string $plugin): bool
673: {
674: $docroot = $this->getAppRoot($hostname, $path);
675: if (!$docroot) {
676: return error('invalid WP location');
677: }
678:
679: return $this->assetManagerWrapper($docroot, 'theme', 'activate', $plugin);
680: }
681:
682: private function assetManagerWrapper(string $docroot, string $type, string $mode, string $asset): bool
683: {
684: $ret = $this->execCommand($docroot, '%s %s %s', [$type, $mode, $asset]);
685:
686: return $ret['success'] ?: error("Failed to %(mode)s `%(asset)s': %(err)s", [
687: 'mode' => $mode, 'asset' => $asset, 'err' => coalesce($ret['stderr'], $ret['stdout'])
688: ]);
689: }
690:
691:
692: /**
693: * Remove a Wordpress theme
694: *
695: * @param string $hostname
696: * @param string $path
697: * @param string $theme
698: * @param bool $force unused
699: * @return bool
700: */
701: public function uninstall_theme(string $hostname, string $path, string $theme, bool $force = false): bool
702: {
703: $docroot = $this->getAppRoot($hostname, $path);
704: if (!$docroot) {
705: return error('invalid WP location');
706: }
707:
708: $args = array(
709: 'theme' => $theme
710: );
711: if ($force) {
712: warn('Force parameter unused - deactivate theme first through WP panel if necessary');
713: }
714: $cmd = 'theme uninstall %(theme)s';
715: $ret = $this->execCommand($docroot, $cmd, $args);
716:
717: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
718: return error("failed to uninstall plugin `%s': %s", $theme, coalesce($ret['stderr'], $ret['stdout']));
719: }
720: info("uninstalled theme `%s'", $theme);
721:
722: return true;
723: }
724:
725: /**
726: * Recovery mode to disable all plugins
727: *
728: * @param string $hostname subdomain or domain of WP
729: * @param string $path optional path
730: * @return bool
731: */
732: public function disable_all_plugins(string $hostname, string $path = ''): bool
733: {
734: $docroot = $this->getAppRoot($hostname, $path);
735: if (!$docroot) {
736: return error('failed to determine path');
737: }
738:
739: $ret = $this->execCommand($docroot, 'plugin deactivate --all --skip-plugins');
740: if (!$ret['success']) {
741: return error('failed to deactivate all plugins: %s', coalesce($ret['stderr'], $ret['stdout']));
742: }
743:
744: return info('plugin deactivation successful: %s', $ret['stdout']);
745: }
746:
747: /**
748: * Uninstall WP from a location
749: *
750: * @param $hostname
751: * @param string $path
752: * @param string $delete "all", "db", or "files"
753: * @return bool
754: */
755: public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool
756: {
757: return parent::uninstall($hostname, $path, $delete);
758: }
759:
760: /**
761: * Get database configuration for a blog
762: *
763: * @param string $hostname domain or subdomain of wp blog
764: * @param string $path optional path
765: * @return array|bool
766: */
767: public function db_config(string $hostname, string $path = '')
768: {
769: $docroot = $this->getAppRoot($hostname, $path);
770: if (!$docroot) {
771: return error('failed to determine WP');
772: }
773: $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();';
774: $ret = \Module\Support\Webapps\PhpWrapper::instantiateContexted($this->getAuthContextFromDocroot($docroot))->exec(
775: $docroot, '-r %(code)s 3>&1-', ['code' => $code],
776: );
777:
778: if (empty($ret['stdout']) && !$ret['success']) {
779: return error("failed to obtain WP configuration for `%s': %s", $docroot, $ret['stderr']);
780: }
781:
782: return \Util_PHP::unserialize(trim($ret['stdout']));
783: }
784:
785: /**
786: * Change WP admin credentials
787: *
788: * $fields is a hash whose indices match wp_update_user
789: * common fields include: user_pass, user_login, and user_nicename
790: *
791: * @link https://codex.wordpress.org/Function_Reference/wp_update_user
792: *
793: * @param string $hostname
794: * @param string $path
795: * @param array $fields
796: * @return bool
797: */
798: public function change_admin(string $hostname, string $path, array $fields): bool
799: {
800: $docroot = $this->getAppRoot($hostname, $path);
801: if (!$docroot) {
802: return warn('failed to change administrator information');
803: }
804: $admin = $this->get_admin($hostname, $path);
805:
806: if (!$admin) {
807: return error('cannot determine admin of WP install');
808: }
809:
810: if (isset($fields['user_login'])) {
811: return error('user login field cannot be changed in WP');
812: }
813:
814: $args = array(
815: 'user' => $admin
816: );
817: $cmd = 'user update %(user)s';
818: foreach ($fields as $k => $v) {
819: $cmd .= ' --' . $k . '=%(' . $k . ')s';
820: $args[$k] = $v;
821: }
822:
823: $ret = $this->execCommand($docroot, $cmd, $args);
824: if (!$ret['success']) {
825: return error("failed to update admin `%s', error: %s",
826: $admin,
827: coalesce($ret['stderr'], $ret['stdout'])
828: );
829: }
830:
831: return $ret['success'];
832: }
833:
834: /**
835: * Get the primary admin for a WP instance
836: *
837: * @param string $hostname
838: * @param null|string $path
839: * @return string admin or false on failure
840: */
841: public function get_admin(string $hostname, string $path = ''): ?string
842: {
843: $docroot = $this->getAppRoot($hostname, $path);
844: $ret = $this->execCommand($docroot, 'user list --role=administrator --field=user_login');
845: if (!$ret['success'] || !$ret['stdout']) {
846: warn('failed to enumerate WP administrative users');
847:
848: return null;
849: }
850:
851: return strtok($ret['stdout'], "\r\n");
852: }
853:
854: /**
855: * Update core, plugins, and themes atomically
856: *
857: * @param string $hostname subdomain or domain
858: * @param string $path optional path under hostname
859: * @param string $version
860: * @return bool
861: */
862: public function update_all(string $hostname, string $path = '', string $version = null): bool
863: {
864: $docroot = $this->getAppRoot($hostname, $path);
865: if (is_dir($this->domain_fs_path($docroot . '/wp-content/upgrade'))) {
866: // ensure upgrade/ is writeable. WP may create the directory if permissions allow
867: // during a self-directed upgrade
868: $ctx = null;
869: $stat = $this->file_stat($docroot);
870: if (!$stat || !$this->file_set_acls($docroot . '/wp-content/upgrade', [
871: [$stat['owner'] => 'rwx'],
872: [$stat['owner'] => 'drwx']
873: ], File_Module::ACL_MODE_RECURSIVE)) {
874: warn('Failed to apply ACLs for %s/wp-content/upgrade. WP update may fail', $docroot);
875: }
876: }
877: $ret = ($this->update_themes($hostname, $path) && $this->update_plugins($hostname, $path) &&
878: $this->update($hostname, $path, $version)) || error('failed to update all components');
879:
880: $this->setInfo($this->getDocumentRoot($hostname, $path), [
881: 'version' => $this->get_version($hostname, $path),
882: 'failed' => !$ret
883: ]);
884:
885: return $ret;
886: }
887:
888: /**
889: * Get next asset version
890: *
891: * @param string $name
892: * @param array $assetInfo
893: * @param string $lock
894: * @param string $type theme or plugin
895: * @return null|string
896: */
897: private function getNextVersionFromAsset(string $name, array $assetInfo, string $lock, string $type): ?string
898: {
899: if (!isset($assetInfo['version'])) {
900: error("Unable to query version for %s `%s', ignoring. Asset info: %s",
901: ucwords($type),
902: $name,
903: var_export($assetInfo, true)
904: );
905:
906: return null;
907: }
908:
909: $version = $assetInfo['version'];
910: $versions = $this->{$type . 'Versions'}($name) ?? [$assetInfo['version'], $assetInfo['max']];
911: $next = $this->windThroughVersions($version, $lock, $versions);
912: if ($next === null && end($versions) !== $version) {
913: info("%s `%s' already at maximal version `%s' for lock spec `%s'. " .
914: 'Newer versions available. Manually upgrade or disable version lock to ' .
915: 'update this component.',
916: ucwords($type), $name, $version, $lock
917: );
918: }
919:
920: return $next;
921: }
922:
923: /**
924: * Move pointer through versions finding the next suitable candidate
925: *
926: * @param string $cur
927: * @param null|string $lock
928: * @param array $versions
929: * @return string|null
930: */
931: private function windThroughVersions(string $cur, ?string $lock, array $versions): ?string
932: {
933: $maximal = $tmp = $cur;
934: do {
935: $tmp = $maximal;
936: $maximal = Versioning::nextSemanticVersion(
937: $tmp,
938: $versions,
939: $lock
940: );
941: } while ($maximal && $tmp !== $maximal);
942:
943: if ($maximal === $cur) {
944: return null;
945: }
946:
947: return $maximal;
948: }
949:
950: /**
951: * Update WordPress themes
952: *
953: * @param string $hostname subdomain or domain
954: * @param string $path optional path under hostname
955: * @param array $themes
956: * @return bool
957: */
958: public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
959: {
960: $docroot = $this->getAppRoot($hostname, $path);
961: if (!$docroot) {
962: return error('update failed');
963: }
964: $flags = [];
965: $lock = $this->getVersionLock($docroot);
966: $skiplist = $this->getSkiplist($docroot, 'theme');
967:
968: if (!$skiplist && !$themes && !$lock) {
969: $ret = $this->execCommand($docroot, 'theme update --all ' . implode(' ', $flags));
970: if (!$ret['success']) {
971: return error("theme update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
972: }
973:
974: return $ret['success'];
975: }
976:
977: $status = 1;
978: if (false === ($allthemeinfo = $this->theme_status($hostname, $path))) {
979: return false;
980: }
981: $themes = $themes ?: array_keys($allthemeinfo);
982: foreach ($themes as $theme) {
983: $version = null;
984: $name = $theme['name'] ?? $theme;
985: $themeInfo = $allthemeinfo[$name];
986: if ((isset($skiplist[$name]) || $themeInfo['current']) && !array_get((array)$theme, 'force')) {
987: continue;
988: }
989:
990: if (isset($theme['version'])) {
991: $version = $theme['version'];
992: } else if ($lock && !($version = $this->getNextVersionFromAsset($name, $themeInfo, $lock, 'theme'))) {
993: // see if 'next' will satisfy the requirement
994: continue;
995: }
996:
997: $cmd = 'theme update %(name)s';
998: $args = [
999: 'name' => $name
1000: ];
1001:
1002: // @XXX theme update --version=X.Y.Z NAME
1003: // bad themes (better-wp-security) will induce false positives on remote versions
1004: // if a version is specified, pass this explicitly to force an update
1005: // see wp-cli issue #370
1006:
1007: $cmdTmp = $cmd;
1008: if ($version) {
1009: $cmd .= ' --version=%(version)s';
1010: $args['version'] = $version;
1011: }
1012:
1013: $cmd .= ' ' . implode(' ', $flags);
1014: $ret = $this->execCommand($docroot, $cmd, $args);
1015:
1016: if (!$ret['success'] && $version) {
1017: warn(
1018: "Update failed for %s, falling back to versionless update: %s",
1019: $name,
1020: coalesce($ret['stderr'], $ret['stdout'])
1021: );
1022: $cmdTmp .= ' ' . implode(' ', $flags);
1023: $ret = $this->execCommand($docroot, $cmdTmp, $args);
1024: }
1025:
1026: if (!$ret['success']) {
1027: error("failed to update theme `%s': %s", $name, coalesce($ret['stderr'], $ret['stdout']));
1028: }
1029: $status &= $ret['success'];
1030: }
1031:
1032: return (bool)$status;
1033: }
1034:
1035: /**
1036: * Get update protection list
1037: *
1038: * @param string $docroot
1039: * @param string|null $type
1040: * @return array
1041: */
1042: protected function getSkiplist(string $docroot, ?string $type)
1043: {
1044: if ($type !== null && $type !== 'plugin' && $type !== 'theme') {
1045: error("Unrecognized skiplist type `%s'", $type);
1046:
1047: return [];
1048: }
1049: $skiplist = $this->skiplistContents($docroot);
1050:
1051: return array_flip(array_filter(array_map(static function ($line) use ($type) {
1052: if (false !== ($pos = strpos($line, ':'))) {
1053: if (!$type || strpos($line, $type . ':') === 0) {
1054: return substr($line, $pos + 1);
1055: }
1056:
1057: return;
1058: }
1059:
1060: return $line;
1061: }, $skiplist)));
1062: }
1063:
1064: private function skiplistContents(string $approot): array
1065: {
1066: $skipfile = $this->domain_fs_path($approot . '/' . self::ASSET_SKIPLIST);
1067: if (!file_exists($skipfile)) {
1068: return [];
1069: }
1070:
1071: return (array)file($skipfile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
1072: }
1073:
1074: /**
1075: * Update WordPress plugins
1076: *
1077: * @param string $hostname domain or subdomain
1078: * @param string $path optional path within host
1079: * @param array $plugins flat list of plugins or multi-dimensional of name, force, version
1080: * @return bool
1081: */
1082: public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
1083: {
1084: $docroot = $this->getAppRoot($hostname, $path);
1085: if (!$docroot) {
1086: return error('update failed');
1087: }
1088: $flags = [];
1089: $lock = $this->getVersionLock($docroot);
1090: $skiplist = $this->getSkiplist($docroot, 'plugin');
1091:
1092: if (!$plugins && !$skiplist && !$lock) {
1093: $ret = $this->execCommand($docroot, 'plugin update --all ' . implode(' ', $flags));
1094: if (!$ret['success']) {
1095: return error("plugin update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
1096: }
1097:
1098: return $ret['success'];
1099: }
1100:
1101: $status = 1;
1102: if (false === ($allplugininfo = $this->plugin_status($hostname, $path))) {
1103: return false;
1104: }
1105: $plugins = $plugins ?: array_keys($allplugininfo);
1106: foreach ($plugins as $plugin) {
1107:
1108: $version = null;
1109: $name = $plugin['name'] ?? $plugin;
1110: $pluginInfo = $allplugininfo[$name];
1111: if ((isset($skiplist[$name]) || $pluginInfo['current']) && !array_get((array)$plugin, 'force')) {
1112: continue;
1113: }
1114:
1115: if (isset($plugin['version'])) {
1116: $version = $plugin['version'];
1117: } else if ($lock && !($version = $this->getNextVersionFromAsset($name, $pluginInfo, $lock, 'plugin'))) {
1118: // see if 'next' will satisfy the requirement
1119: continue;
1120: }
1121:
1122: $cmd = 'plugin update %(name)s';
1123: $args = [
1124: 'name' => $name
1125: ];
1126: // @XXX plugin update --version=X.Y.Z NAME
1127: // bad plugins (better-wp-security) will induce false positives on remote versions
1128: // if a version is specified, pass this explicitly to force an update
1129: // see wp-cli issue #370
1130: //
1131: // confirm with third party checks
1132:
1133: $cmdTmp = $cmd;
1134: if ($version) {
1135: $cmd .= ' --version=%(version)s';
1136: $args['version'] = $version;
1137: }
1138: $cmd .= ' ' . implode(' ', $flags);
1139: $ret = $this->execCommand($docroot, $cmd, $args);
1140:
1141: if (!$ret['success'] && $version) {
1142: warn(
1143: "Update failed for %s, falling back to versionless update: %s",
1144: $name,
1145: coalesce($ret['stderr'], $ret['stdout'])
1146: );
1147: $cmdTmp .= ' ' . implode(' ', $flags);
1148: $ret = $this->execCommand($docroot, $cmdTmp, $args);
1149: }
1150:
1151: if (!$ret['success']) {
1152: error("failed to update plugin `%s': %s", $name, coalesce($ret['stderr'], $ret['stdout']));
1153: }
1154: $status &= $ret['success'];
1155: }
1156:
1157: return (bool)$status;
1158: }
1159:
1160: /**
1161: * Update WordPress to latest version
1162: *
1163: * @param string $hostname domain or subdomain under which WP is installed
1164: * @param string $path optional subdirectory
1165: * @param string $version
1166: * @return bool
1167: */
1168: public function update(string $hostname, string $path = '', string $version = null): bool
1169: {
1170: $docroot = $this->getAppRoot($hostname, $path);
1171: if (!$docroot) {
1172: return error('update failed');
1173: }
1174: $this->assertOwnershipSystemCheck($docroot);
1175:
1176: $cmd = 'core update';
1177: $args = [];
1178:
1179: if ($version) {
1180: if (!is_scalar($version) || strcspn($version, '.0123456789')) {
1181: return error('invalid version number, %s', $version);
1182: }
1183: $cmd .= ' --version=%(version)s';
1184: $args['version'] = $version;
1185:
1186: $ret = $this->execCommand($docroot, 'option get WPLANG');
1187: if (trim($ret['stdout']) === 'en') {
1188: // issue seen with Softaculous installing under "en" locale, which generates
1189: // an invalid update URI
1190: warn('Bugged WPLANG setting. Changing en to en_US');
1191: $this->execCommand($docroot, 'site switch-language en_US');
1192: }
1193: }
1194:
1195: $oldversion = $this->get_version($hostname, $path);
1196: $ret = $this->execCommand($docroot, $cmd, $args);
1197:
1198: if (!$ret['success']) {
1199: $output = coalesce($ret['stderr'], $ret['stdout']);
1200: if (str_starts_with($output, 'Error: Download failed.')) {
1201: return warn('Failed to fetch update - retry update later: %s', $output);
1202: }
1203:
1204: return error("update failed: `%s'", coalesce($ret['stderr'], $ret['stdout']));
1205: }
1206:
1207: // Sanity check as WP-CLI is known to fail while producing a 0 exit code
1208: if ($oldversion === $this->get_version($hostname, $path) &&
1209: !$this->is_current($oldversion, Versioning::asMajor($oldversion))) {
1210: return error('Failed to update WordPress - old version is same as new version - %s! ' .
1211: 'Diagnostics: (stderr) %s (stdout) %s', $oldversion, $ret['stderr'], $ret['stdout']);
1212: }
1213:
1214: info('updating WP database if necessary');
1215: $ret = $this->execCommand($docroot, 'core update-db');
1216: $this->shareOwnershipSystemCheck($docroot);
1217:
1218: if (!$ret['success']) {
1219: return warn('failed to update WP database - ' .
1220: 'login to WP admin panel to manually perform operation');
1221: }
1222:
1223: return $ret['success'];
1224: }
1225:
1226: /**
1227: * Get theme status
1228: *
1229: * Sample response:
1230: * [
1231: * hestia => [
1232: * version => 1.1.50
1233: * next => 1.1.51
1234: * current => false
1235: * max => 1.1.66
1236: * ]
1237: * ]
1238: *
1239: * @param string $hostname
1240: * @param string $path
1241: * @param string|null $theme
1242: * @return array|bool
1243: */
1244: public function theme_status(string $hostname, string $path = '', string $theme = null)
1245: {
1246: $docroot = $this->getAppRoot($hostname, $path);
1247: if (!$docroot) {
1248: return error('invalid WP location');
1249: }
1250:
1251: $matches = $this->assetListWrapper($docroot, 'theme', [
1252: 'name',
1253: 'status',
1254: 'version',
1255: 'update_version'
1256: ]);
1257:
1258: if (!$matches) {
1259: return false;
1260: }
1261:
1262: $themes = [];
1263: foreach ($matches as $match) {
1264: if (\in_array($match['status'], self::NON_UPDATEABLE_TYPES, true)) {
1265: continue;
1266: }
1267: $name = $match['name'];
1268: $version = $match['version'];
1269: if (!$versions = $this->themeVersions($name)) {
1270: // commercial themes
1271: if (empty($match['update_version'])) {
1272: $match['update_version'] = $match['version'];
1273: }
1274:
1275: $versions = [$match['version'], $match['update_version']];
1276: }
1277:
1278: $themes[$name] = [
1279: 'version' => $version,
1280: 'next' => Versioning::nextVersion($versions, $version),
1281: 'max' => $this->themeInfo($name)['version'] ?? end($versions)
1282: ];
1283: // dev version may be present
1284: $themes[$name]['current'] = version_compare((string)array_get($themes, "${name}.max",
1285: '99999999.999'), (string)$version, '<=') ?:
1286: (bool)Versioning::current($versions, $version);
1287: }
1288:
1289: return $theme ? $themes[$theme] ?? error("unknown theme `%s'", $theme) : $themes;
1290: }
1291:
1292: /**
1293: * Get theme versions
1294: *
1295: * @param string $theme
1296: * @return null|array
1297: */
1298: protected function themeVersions($theme): ?array
1299: {
1300: $info = $this->themeInfo($theme);
1301: if (!$info || empty($info['versions'])) {
1302: return null;
1303: }
1304: array_forget($info, 'versions.trunk');
1305:
1306: return array_keys($info['versions']);
1307: }
1308:
1309: /**
1310: * Get theme information
1311: *
1312: * @param string $theme
1313: * @return array|null
1314: */
1315: protected function themeInfo(string $theme): ?array
1316: {
1317: $cache = \Cache_Super_Global::spawn();
1318: $key = 'wp.tinfo-' . $theme;
1319: if (false !== ($data = $cache->get($key))) {
1320: return $data;
1321: }
1322: $url = str_replace('%theme%', $theme, static::THEME_VERSION_CHECK_URL);
1323: $info = [];
1324: $contents = silence(static function () use ($url) {
1325: return file_get_contents($url);
1326: });
1327: if (false !== $contents) {
1328: $info = (array)json_decode($contents, true);
1329: if (isset($info['versions'])) {
1330: uksort($info['versions'], 'version_compare');
1331: }
1332: } else {
1333: info("Theme `%s' detected as commercial. Using transient data.", $theme);
1334: }
1335: $cache->set($key, $info, 86400);
1336:
1337: return $info;
1338: }
1339:
1340: public function install_theme(string $hostname, string $path, string $theme, string $version = null): bool
1341: {
1342: $docroot = $this->getAppRoot($hostname, $path);
1343: if (!$docroot) {
1344: return error('invalid WP location');
1345: }
1346:
1347: $args = array(
1348: 'theme' => $theme
1349: );
1350: $cmd = 'theme install %(theme)s --activate';
1351: if ($version) {
1352: $cmd .= ' --version=%(version)s';
1353: $args['version'] = $version;
1354: }
1355: $ret = $this->execCommand($docroot, $cmd, $args);
1356: if (!$ret['success']) {
1357: return error("failed to install theme `%s': %s", $theme, coalesce($ret['stderr'], $ret['stdout']));
1358: }
1359: info("installed theme `%s'", $theme);
1360:
1361: return true;
1362: }
1363:
1364: /**
1365: * Relax permissions to allow write-access
1366: *
1367: * @param string $hostname
1368: * @param string $path
1369: * @return bool
1370: * @internal param string $mode
1371: */
1372: public function unfortify(string $hostname, string $path = ''): bool
1373: {
1374: return parent::unfortify($hostname, $path) && $this->setFsMethod($this->getAppRoot($hostname, $path), false);
1375: }
1376:
1377: /**
1378: * Install wp-cli if necessary
1379: *
1380: * @return bool
1381: * @throws \Exception
1382: */
1383: public function _housekeeping()
1384: {
1385: if (file_exists(Wpcli::BIN) && filemtime(Wpcli::BIN) < filemtime(__FILE__)) {
1386: unlink(Wpcli::BIN);
1387: }
1388:
1389: if (!file_exists(Wpcli::BIN)) {
1390: $url = Wpcli::DOWNLOAD_URL;
1391: $tmp = tempnam(storage_path('tmp'), 'wp-cli') . '.phar';
1392: $res = Util_HTTP::download($url, $tmp);
1393: if (!$res) {
1394: file_exists($tmp) && unlink($tmp);
1395:
1396: return error('failed to install wp-cli module');
1397: }
1398: try {
1399: (new \Phar($tmp))->getSignature();
1400: rename($tmp, Wpcli::BIN) && chmod(Wpcli::BIN, 0755);
1401: info('downloaded wp-cli');
1402: } catch (\UnexpectedValueException $e) {
1403: return error('WP-CLI signature failed, ignoring update');
1404: } finally {
1405: if (file_exists($tmp)) {
1406: unlink($tmp);
1407: }
1408: }
1409: // older platforms
1410: $local = $this->service_template_path('siteinfo') . Wpcli::BIN;
1411: if (!file_exists($local) && !copy(Wpcli::BIN, $local)) {
1412: return false;
1413: }
1414: chmod($local, 0755);
1415:
1416: }
1417:
1418: if (is_dir($this->service_template_path('siteinfo'))) {
1419: $link = $this->service_template_path('siteinfo') . '/usr/bin/wp-cli';
1420: $local = $this->service_template_path('siteinfo') . Wpcli::BIN;
1421: if (!is_link($link) || realpath($link) !== realpath($local)) {
1422: is_link($link) && unlink($link);
1423: $referent = $this->file_convert_absolute_relative($link, $local);
1424:
1425: return symlink($referent, $link);
1426: }
1427: }
1428:
1429: return true;
1430: }
1431:
1432: /**
1433: * Get all available WordPress versions
1434: *
1435: * @return array versions descending
1436: */
1437: public function get_versions(): array
1438: {
1439: $versions = $this->_getVersions();
1440:
1441: return array_reverse(array_column($versions, 'version'));
1442: }
1443:
1444: protected function mapFilesFromList(array $files, string $docroot): array
1445: {
1446: if (file_exists($this->domain_fs_path($docroot . '/wp-content'))) {
1447: return parent::mapFilesFromList($files, $docroot);
1448: }
1449: $path = $tmp = $docroot;
1450: // WP can allow relocation of assets, look for them
1451: $ret = \Module\Support\Webapps\PhpWrapper::instantiateContexted($this->getAuthContextFromDocroot($docroot))->exec(
1452: $docroot, '-r %(code)s', [
1453: '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");'
1454: ]
1455: );
1456:
1457: if ($ret['success']) {
1458: $tmp = $ret['stdout'];
1459: if (0 === strpos($tmp, $this->domain_fs_path() . '/')) {
1460: $tmp = $this->file_unmake_path($tmp);
1461: }
1462: }
1463:
1464: if ($path !== $tmp) {
1465: $relpath = $this->file_convert_absolute_relative($docroot . '/wp-content/', $tmp);
1466: foreach ($files as $k => $f) {
1467: if (0 !== strncmp($f, 'wp-content/', 11)) {
1468: continue;
1469: }
1470: $f = $relpath . substr($f, strlen('wp-content'));
1471: $files[$k] = $f;
1472: }
1473: }
1474:
1475: return parent::mapFilesFromList($files, $docroot);
1476: }
1477:
1478: /**
1479: * Get latest WP release
1480: *
1481: * @return string
1482: */
1483: protected function _getLastestVersion()
1484: {
1485: $versions = $this->_getVersions();
1486: if (!$versions) {
1487: return null;
1488: }
1489:
1490: return $versions[0]['version'];
1491: }
1492:
1493: /**
1494: * Get all current major versions
1495: *
1496: * @return array
1497: */
1498: protected function _getVersions()
1499: {
1500: $key = 'wp.versions';
1501: $cache = Cache_Super_Global::spawn();
1502: if (false !== ($ver = $cache->get($key))) {
1503: return $ver;
1504: }
1505: $url = self::VERSION_CHECK_URL;
1506: $contents = file_get_contents($url);
1507: if (!$contents) {
1508: return array();
1509: }
1510: $versions = json_decode($contents, true);
1511: $versions = $versions['offers'];
1512: if (isset($versions[1]['version'], $versions[0]['version'])
1513: && $versions[0]['version'] === $versions[1]['version']) {
1514: // WordPress sends most current + version tree
1515: array_shift($versions);
1516: }
1517: $cache->set($key, $versions, 43200);
1518:
1519: return $versions;
1520: }
1521:
1522:
1523: /**
1524: * Get basic summary of assets
1525: *
1526: * @param string $hostname
1527: * @param string $path
1528: * @return array
1529: */
1530: public function asset_summary(string $hostname, string $path = ''): array
1531: {
1532: if (!$approot = $this->getAppRoot($hostname, $path)) {
1533: return [];
1534: }
1535:
1536: $plugin = $this->assetListWrapper($approot, 'plugin', ['name', 'status', 'version', 'description', 'update_version']);
1537: $theme = $this->assetListWrapper($approot, 'theme', ['name', 'status', 'version', 'description', 'update_version']);
1538: $skippedtheme = $this->getSkiplist($approot, 'theme');
1539: $skippedplugin = $this->getSkiplist($approot, 'plugin');
1540: $merged = [];
1541: foreach (['plugin', 'theme'] as $type) {
1542: $skipped = ${'skipped' . $type};
1543: $assets = (array)${$type};
1544: usort($assets, static function ($a1, $a2) {
1545: return strnatcmp($a1['name'], $a2['name']);
1546: });
1547: foreach ($assets as &$asset) {
1548: if (\in_array($asset['status'], self::NON_UPDATEABLE_TYPES, true)) {
1549: continue;
1550: }
1551: $name = $asset['name'];
1552: $asset['skipped'] = isset($skipped[$name]);
1553: $asset['active'] = $asset['status'] !== 'inactive';
1554: $asset['type'] = $type;
1555: $merged[] = $asset;
1556: }
1557: unset($asset);
1558: }
1559: return $merged;
1560: }
1561:
1562: /**
1563: * Skip updating an asset
1564: *
1565: * @param string $hostname
1566: * @param string $path
1567: * @param string $name
1568: * @param string|null $type
1569: * @return bool
1570: */
1571: public function skip_asset(string $hostname, string $path, string $name, ?string $type): bool
1572: {
1573: if (!$approot = $this->getAppRoot($hostname, $path)) {
1574: return error("App root for `%s'/`%s' does not exist", $hostname, $path);
1575: }
1576:
1577: $contents = implode("\n", $this->skiplistContents($approot));
1578: $contents .= "\n" . $type . ($type ? ':' : '') . $name;
1579:
1580: return $this->file_put_file_contents("${approot}/" . self::ASSET_SKIPLIST, ltrim($contents));
1581: }
1582:
1583: /**
1584: * Permit updates of an asset
1585: *
1586: * @param string $hostname
1587: * @param string $path
1588: * @param string $name
1589: * @param string|null $type
1590: * @return bool
1591: */
1592: public function unskip_asset(string $hostname, string $path, string $name, ?string $type): bool
1593: {
1594: if (!$approot = $this->getAppRoot($hostname, $path)) {
1595: return error("App root for `%s'/`%s' does not exist", $hostname, $path);
1596: }
1597:
1598: $assets = $this->getSkiplist($approot, $type);
1599:
1600: if (!isset($assets[$name])) {
1601: return warn("%(type)s `%(asset)s' not present in skiplist", ['type' => $type, 'asset' => $name]);
1602: }
1603:
1604: $skiplist = array_flip($this->skiplistContents($approot));
1605: unset($skiplist["${type}:${name}"],$skiplist[$name]);
1606: return $this->file_put_file_contents("${approot}/" . self::ASSET_SKIPLIST, implode("\n", array_keys($skiplist)));
1607: }
1608:
1609:
1610: public function asset_skipped(string $hostname, string $path, string $name, ?string $type): bool
1611: {
1612: if (!$approot = $this->getAppRoot($hostname, $path)) {
1613: return error("App root for `%s'/`%s' does not exist", $hostname, $path);
1614: }
1615:
1616: $assets = $this->getSkiplist($approot, $type);
1617: return isset($assets[$name]);
1618: }
1619:
1620: /**
1621: * Duplicate a WordPress instance
1622: *
1623: * @param string $shostname
1624: * @param string $spath
1625: * @param string $dhostname
1626: * @param string $dpath
1627: * @param array $opts clean: (bool) purge target directory
1628: * @return bool
1629: */
1630: public function duplicate(string $shostname, string $spath, string $dhostname, string $dpath, array $opts = []): bool
1631: {
1632: if (!$this->valid($shostname, $spath)) {
1633: return error("%(hostname)s/%(path)s does not contain a valid %(app)s install",
1634: ['hostname' => $shostname, 'path' => $spath, 'app' => static::APP_NAME]
1635: );
1636: }
1637:
1638: return (bool)serial(function () use ($spath, $dpath, $dhostname, $shostname, $opts) {
1639: $sapproot = $this->getAppRoot($shostname, $spath);
1640: $dapproot = $this->getAppRoot($dhostname, $dpath);
1641: // nesting directories is permitted, denesting will fail in checkDocroot() below
1642: // otherwise add reciprocal strpos() check
1643: if ($sapproot === $dapproot || 0 === strpos("${dapproot}/", "${sapproot}/")) {
1644: return error("Source `%(source)s' and target `%(target)s' are the same or nested",
1645: ['source' => $sapproot, 'target' => $dapproot]);
1646: }
1647:
1648: if (!empty($opts['clean']) && is_dir($this->domain_fs_path($dapproot))) {
1649: if ($this->webapp_valid($dhostname, $dpath)) {
1650: $this->webapp_uninstall($dhostname, $dpath);
1651: } else {
1652: $this->file_delete($dapproot, true);
1653: }
1654: }
1655:
1656: $this->checkDocroot($dapproot);
1657:
1658: // @todo $opts['link-uploads']
1659: $this->file_copy("${sapproot}/", $dapproot, true);
1660: $db = DatabaseGenerator::mysql($this->getAuthContext(), $dhostname);
1661: $db->create();
1662: $db->autoRollback = true;
1663: $oldDbConfig = $this->db_config($shostname, $spath);
1664: $this->mysql_clone($oldDbConfig['db'], $db->database);
1665: $sapp = Loader::fromDocroot('wordpress', $sapproot, $this->getAuthContext());
1666: $dapp = Loader::fromDocroot('wordpress', $dapproot, $this->getAuthContext());
1667:
1668: $vals = array_diff_key(
1669: $this->get_reconfigurable($shostname, $spath, $sapp->getReconfigurables()),
1670: array_flip($dapp::TRANSIENT_RECONFIGURABLES)
1671: );
1672: info("Reconfiguring %s", implode(", ", array_key_map(static function ($k, $v) {
1673: if (is_bool($v)) {
1674: $v = $v ? "true" : "false";
1675: }
1676: return "$k => $v";
1677: }, $vals)));
1678: $dapp->reconfigure($vals);
1679: $this->updateConfiguration($dapproot, [
1680: 'DB_NAME' => $db->database,
1681: 'DB_USER' => $db->username,
1682: 'DB_PASSWORD' => $db->password,
1683: 'DB_HOST' => $db->hostname,
1684: ]);
1685: $db->autoRollback = false;
1686: $cli = Wpcli::instantiateContexted($this->getAuthContext());
1687: $cli->exec($dapproot, 'config shuffle-salts');
1688: $dapp->reconfigure(['migrate' => $dhostname . '/' . $dpath]);
1689:
1690: if ($dapp->hasGit()) {
1691: $git = Git::instantiateContexted(
1692: $this->getAuthContext(), [
1693: $dapp->getAppRoot(),
1694: MetaManager::factory($this->getAuthContext())->get($dapp->getDocumentRoot())
1695: ]
1696: );
1697: $git->remove();
1698: $git->createRepository();
1699: }
1700:
1701: return null !== $this->webapp_discover($dhostname, $dpath);
1702: });
1703: }
1704:
1705: /**
1706: * Install a WP-CLI package
1707: *
1708: * @param string $package
1709: * @return bool
1710: */
1711: public function install_package(string $package): bool
1712: {
1713: $cli = ComposerWrapper::instantiateContexted($this->getAuthContext());
1714: if (!$this->file_exists('~/.wp-cli/packages')) {
1715: $this->file_create_directory('~/.wp-cli/packages', 493, true);
1716: $cli->exec('~/.wp-cli/packages', 'init -n --name=local/wp-cli-packages -sdev --repository=https://wp-cli.org/package-index/');
1717: }
1718: $ret = $cli->exec('~/.wp-cli/packages', 'require %s', [$package]);
1719:
1720:
1721: return $ret['success'] ?:
1722: error("Failed to install %s: %s", $package, coalesce($ret['stderr'], $ret['stdout']));
1723: }
1724:
1725: /**
1726: * WP-CLI package installed
1727: *
1728: * @param string $package
1729: * @return bool
1730: */
1731: public function package_installed(string $package): bool
1732: {
1733: $cli = Wpcli::instantiateContexted($this->getAuthContext());
1734: $ret = $cli->exec(null, 'package path %s', [$package]);
1735: return $ret['success'];
1736: }
1737:
1738: /**
1739: * Uninstall WP-CLI package
1740: *
1741: * @param string $package
1742: * @return bool
1743: */
1744: public function uninstall_package(string $package): bool
1745: {
1746: $cli = ComposerWrapper::instantiateContexted($this->getAuthContext());
1747: $ret = $cli->exec('~/.wp-cli/packages', 'remove %s', [$package]);
1748: return $ret['success'] ?:
1749: error("Failed to uninstall %s: %s", $package, coalesce($ret['stderr'], $ret['stdout']));
1750: }
1751:
1752: /**
1753: * Apply WP-CLI directive
1754: *
1755: * @param string $command directive
1756: * @param array|string $args formatted args
1757: * @param string $hostname hostname
1758: * @param string $path subpath
1759: * @return mixed hash of paths or single arraycomprised of @see pman:run() + ['hostname', 'path']
1760: *
1761: * Sample usage:
1762: *
1763: * `wordpress:cli "plugin uninstall dolly"`
1764: * `wordpress:cli "plugin uninstall %s" ["dolly"]`
1765: * - Remove dolly plugin from all WP sites
1766: * `wordpress:cli "core verify-checksums" "" domain.com`
1767: * - Run verify-checksums on core distribution against domain.com
1768: * `wordpress:cli "--json plugin list"`
1769: * - Report all plugins encoded in JSON
1770: *
1771: */
1772: public function cli(string $command, $args = [], string $hostname = null, string $path = ''): array
1773: {
1774: if (!$hostname) {
1775: $apps = (new \Module\Support\Webapps\Finder($this->getAuthContext()))->getApplicationsByType($this->getModule());
1776: } else {
1777: $docroot = $this->getDocumentRoot($hostname, $path);
1778: $apps = [$docroot => ['path' => $path, 'hostname' => $hostname]];
1779: }
1780:
1781: $wpcli = Wpcli::instantiateContexted($this->getAuthContext());
1782: $processed = [];
1783: foreach ($apps as $info) {
1784: if (!$this->valid($info['hostname'], $info['path'] ?? '')) {
1785: debug("%(host)/%(path)s is not valid %(type)s, skipping", [
1786: 'host' => $info['hostname'],
1787: 'path' => $info['path'] ?? '',
1788: 'type' => $this->getModule()
1789: ]);
1790: }
1791: $appRoot = $this->getAppRoot($info['hostname'], $info['path']);
1792: $ret = $wpcli->exec($appRoot, $command, (array)$args);
1793:
1794: $processed[$appRoot] = array_only($info, ['hostname', 'path']) + $ret;
1795: }
1796:
1797: return $hostname ? array_pop($processed) : $processed;
1798: }
1799: }
1800: