1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use Module\Support\Webapps\PhpWrapper;
16: use Opcenter\Versioning;
17:
18: /**
19: * Joomla! management
20: *
21: * @package core
22: */
23: class Joomla_Module extends \Module\Support\Webapps
24: {
25:
26: const APP_NAME = 'Joomla!';
27:
28:
29: // primary domain document root
30: const JOOMLA_CLI = '/usr/share/pear/joomlatools.phar';
31:
32: const UPDATE_URI = 'https://github.com/joomla/joomla-cms/releases/download/%version%/Joomla_%version%-Stable-Update_Package.zip';
33:
34: // after installation apply the following fortification profile
35: const DEFAULT_FORTIFY_MODE = 'max';
36:
37: const JOOMLA_CLI_VERSION = '2.0.3';
38: const JOOMLA_MODULE_XML = 'http://update.joomla.org/core/extensions/%extension%.xml';
39:
40: const DEFAULT_VERSION_LOCK = 'major';
41:
42: const VERSION_MINIMUMS = [
43: '0' => '5.6',
44: '4.0.0' => '7.2.4',
45: '5.0.0' => '8.1.0'
46: ];
47: const JOOMLA_MIRROR = 'https://github.com/joomla/joomla-cms';
48: protected $aclList = array(
49: 'min' => array(
50: 'cache',
51: 'tmp',
52: 'administrator',
53: 'logs',
54: 'media',
55: 'images',
56: 'plugins',
57: 'log'
58: ),
59: 'max' => array(
60: 'cache',
61: 'tmp',
62: 'administrator/cache',
63: 'logs',
64: 'log'
65: )
66: );
67:
68: /**
69: * void __construct(void)
70: *
71: * @ignore
72: */
73: public function __construct()
74: {
75:
76: parent::__construct();
77:
78: }
79:
80: /**
81: * Install Joomla! into a pre-existing location
82: *
83: * @param string $hostname domain or subdomain to install Joomla!
84: * @param string $path optional path under hostname
85: * @param array $opts additional install options
86: * @return bool
87: */
88: public function install(string $hostname, string $path = '', array $opts = array()): bool
89: {
90: if (!$this->mysql_enabled()) {
91: return error('%(what)s must be enabled to install %(app)s',
92: ['what' => 'MySQL', 'app' => static::APP_NAME]);
93: }
94:
95: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
96: return false;
97: }
98:
99: $docroot = $this->getAppRoot($hostname, $path);
100: $version = $opts['version'];
101:
102: if (!isset($opts['title'])) {
103: $opts['title'] = '';
104: }
105:
106: $this->_exec(null, 'versions --refresh', ['version' => $version]);
107: $fqdn = $this->web_normalize_hostname($hostname);
108:
109: if (count(explode('.', $version)) < 3) {
110: $version = Versioning::asPatch($version);
111: }
112:
113: $required = Versioning::satisfy($opts['version'], static::VERSION_MINIMUMS);
114: if (Opcenter\Versioning::compare($local = $this->php_pool_version_from_path($docroot), $required, '<')) {
115: return error("%(app)s requires %(what)s %(version)s. Version %(detected)s detected", [
116: 'app' => static::APP_NAME, 'what' => 'PHP', 'version' => $required, 'detected' => $local]);
117: }
118:
119: $args = array(
120: 'mode' => 'site:install',
121: 'docroot' => $docroot,
122: 'site' => $fqdn,
123: 'repo' => self::JOOMLA_MIRROR
124: );
125:
126: if (!$this->file_exists($docroot)) {
127: $this->file_create_directory($docroot, 0755, true);
128: }
129:
130: $args['version'] = $version;
131: if (isset($opts['sampledata'])) {
132: $data = $opts['sampledata'];
133: if ($data !== 'blog' && $data !== 'default' && $data !== 'brochure' &&
134: $data !== 'learn' && $data !== 'testing'
135: ) {
136: return error("unknown sample data `%s'", $data);
137: }
138: $args['sampledata'] = '--sampledata=' . $data;
139: } else {
140: $args['sampledata'] = null;
141: }
142:
143: $db = \Module\Support\Webapps\DatabaseGenerator::mysql($this->getAuthContext(), $hostname);
144: if (!$db->create()) {
145: return false;
146: }
147:
148: $args['dbhost'] = $db->hostname;
149: $args['dbuser'] = $db->username;
150: $args['dbpass'] = $db->password;
151: $args['dbname'] = $db->database;
152: $args['dbdriver'] = 'mysqli';
153:
154: $ret = $this->_exec($docroot,
155: 'site:download --www=%(docroot)s --use-webroot-dir --release %(version)s -- ""', $args);
156:
157: $this->_fixMySQLSchema($docroot);
158: if ($ret['success']) {
159: $ret = $this->_exec($docroot,
160: 'database:install --skip-exists-check --mysql-host=%(dbhost)s --mysql-login=%(dbuser)s:%(dbpass)s ' .
161: '--mysql-database=%(dbname)s --mysql-driver=%(dbdriver)s %(sampledata)s ' .
162: '--www=%(docroot)s -- ""', $args);
163: if ($ret['success']) {
164: $file = $this->_generateConfig($docroot, $opts);
165: $args['tmpfile'] = $file;
166:
167: $ret = $this->_exec($docroot,
168: 'site:configure --options=%(tmpfile)s --mysql-host=%(dbhost)s --mysql-login=%(dbuser)s:%(dbpass)s ' .
169: '--mysql-database=%(dbname)s --mysql-driver=%(dbdriver)s ' .
170: '--www=%(docroot)s -- ""', $args);
171: unlink($this->domain_fs_path() . $file);
172: }
173: }
174:
175: if (!$ret['success']) {
176: if (!empty($opts['keep'])) {
177: return error('failed to install Joomla');
178: }
179: error('failed to install Joomla - removing temporary files: %s', $ret['stderr']);
180: $this->file_delete($docroot, true);
181: $db->rollback();
182:
183: return false;
184: }
185:
186: $this->fixRewriteBase($docroot);
187: $this->removeInvalidDirectives($docroot);
188: $autogenpw = false;
189: if (!isset($opts['password'])) {
190: $autogenpw = true;
191: $opts['password'] = \Opcenter\Auth\Password::generate(10);
192: info("autogenerated password `%s'", $opts['password']);
193: }
194:
195: info("setting admin user to `%s'", $opts['user']);
196:
197: $adminargs = array(
198: 'username' => $opts['user'],
199: 'password' => $opts['password'],
200: 'email' => $opts['email']
201: );
202:
203: if (!$this->change_admin($hostname, $path, $adminargs)) {
204: warn('failed to set admin user, using default admin/admin combination');
205: }
206:
207: $opts['url'] = rtrim($fqdn . '/' . $path, '/');
208:
209: // by default, let's only open up ACLs to the bare minimum
210: $this->file_touch($docroot . '/.htaccess');
211: if (!$this->file_exists($docroot . '/logs')) {
212: $this->file_create_directory($docroot . '/logs');
213: }
214: info('fortification mode set to %s', strtoupper($fortifymode));
215:
216: $this->notifyInstalled($hostname, $path, $opts);
217:
218: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
219: ['app' => static::APP_NAME, 'email' => $opts['email']]);
220: }
221:
222: /**
223: * Get referred name
224: *
225: * Referred to as Joomla!, but internally let's track as "joomla"
226: *
227: * @return string
228: */
229: protected function getInternalName(): string
230: {
231: return 'joomla';
232: }
233:
234: private function cliFromVersion(string $version, string $poolVersion = null): string
235: {
236: $selections = [
237: dirname(self::JOOMLA_CLI) . '/joomlatools-1.6.0-1.phar',
238: self::JOOMLA_CLI
239: ];
240: $choice = version_compare($version, '3.5.0', '<') ? 0 : 1;
241: if ($poolVersion && Versioning::compare($poolVersion, '7.3.0', '<')) {
242: return $selections[0];
243: }
244:
245: return $selections[$choice];
246: }
247:
248: /**
249: * Look for manifest presence in v3.5+
250: *
251: * @param string $path
252: * @return string
253: */
254: private function assertCliTypeFromInstall(string $path): string
255: {
256: return $this->file_exists($path . '/administrator/manifests/files/joomla.xml') ? '999.999.999' : '3.4.99999';
257: }
258:
259: private function _exec(?string $path, $cmd, array $args = array())
260: {
261: $wrapper = PhpWrapper::instantiateContexted($this->getAuthContext());
262: $jtcli = $this->cliFromVersion($args['version'] ?? $this->assertCliTypeFromInstall($path), $this->php_pool_version_from_path((string)$path));
263: $ret = $wrapper->exec($path, $jtcli . ' --no-interaction ' . $cmd, $args);
264:
265: if (!strncmp($ret['stdout'], 'Error:', strlen('Error:'))) {
266: // move stdout to stderr on error for consistency
267: $ret['success'] = false;
268: if (!$ret['stderr']) {
269: $ret['stderr'] = $ret['stdout'];
270: }
271: } else if (!$ret['success'] && !$ret['stderr']) {
272: $ret['stderr'] = $ret['stdout'];
273: }
274:
275: return $ret;
276: }
277:
278: private function _fixMySQLSchema($docroot)
279: {
280: if ($this->sql_mysql_version() >= 50503) {
281: // utf8mb4 supported
282: return true;
283: }
284: $prefix = $this->domain_shadow_path();
285: $glob = $prefix . $docroot . '/{_,}installation/sql/mysql/*.sql';
286: foreach (glob($glob, GLOB_BRACE) as $f) {
287: $f = substr($f, strlen($prefix));
288: $contents = $this->file_get_file_contents($f);
289: $matches = 0;
290: $contents = str_replace('utf8mb4', 'utf8', $contents, $matches);
291: $this->file_put_file_contents($f, $contents, true);
292: }
293:
294: return true;
295: }
296:
297: private function _generateConfig($docroot, $opts)
298: {
299: $domainfsprefix = $this->domain_fs_path();
300: $file = tempnam($domainfsprefix . '/' . sys_get_temp_dir(), 'joomla');
301: chmod($file, 0644);
302: $tz = date('T');
303: $fullpath = ($this->php_jailed() ? '' : $this->domain_fs_path()) . $docroot;
304: $opts = array(
305: // Apache handles compression
306: 'gzip' => false,
307: 'debug' => 0,
308: 'ftp_host' => 'localhost',
309: 'ftp_user' => $this->username . '@' . $this->domain,
310: 'ftp_root' => $docroot,
311: 'sitename' => $opts['title'],
312: 'offset' => $tz,
313: 'tmp_path' => $fullpath . '/tmp',
314: 'log_path' => $fullpath . '/logs',
315: 'sendmail' => '/usr/sbin/sendmail',
316: 'mailer' => 'mail',
317: 'smtpport' => 587,
318: 'force_ssl' => !empty($opts['ssl']) ? 2 : 0,
319: );
320: // pure PHP code
321: file_put_contents($file, Symfony\Component\Yaml\Yaml::dump($opts, 2, 2));
322:
323: return substr($file, strlen($domainfsprefix));
324: }
325:
326: /**
327: * Change Joomla admin credentials
328: *
329: * common fields include: username, password, name, email
330: *
331: * @param string $hostname
332: * @param string $path
333: * @param array $fields
334: * @return bool
335: */
336: public function change_admin(string $hostname, string $path, array $fields): bool
337: {
338: $docroot = $this->getAppRoot($hostname, $path);
339: if (!$docroot) {
340: return warn('failed to change administrator information');
341: }
342: $admin = $this->get_admin($hostname, $path);
343:
344: if (!$admin) {
345: return error('cannot determine admin of Joomla install');
346: }
347: $dbconfig = $this->db_config($hostname, $path);
348: $conn = $this->_connectDB($dbconfig);
349: if (!$conn) {
350: return error("unable to connect to Joomla! database `%s'", $dbconfig['db']);
351: }
352: $fields = array_merge(array(
353: 'password' => null,
354: 'username' => null,
355: 'name' => null,
356: 'email' => null
357: ), $fields);
358: if ($fields['password']) {
359: $fields['password'] = $this->_saltedPassword($docroot, $fields['password']);
360: }
361: $filtered = array_filter($fields, static function ($f) {
362: return null !== $f;
363: });
364:
365: $args = array();
366: $builtQuery = array();
367: foreach ($filtered as $k => $v) {
368: $builtQuery[] = "$k = ?";
369: $args[] = &$filtered[$k];
370: }
371: //$args = array_map(function($a) { return $a; }, $filtered);
372: // plop admin user as part of WHERE username = ?
373: $args[] = &$admin;
374: $q = 'UPDATE ' . $dbconfig['prefix'] . 'users SET ' . implode(', ', $builtQuery) . ' WHERE username = ?';
375: $stmt = $conn->prepare($q);
376: $types = str_repeat('s', count($args));
377: array_unshift($args, $types);
378: call_user_func_array(array($stmt, 'bind_param'), $args);
379: $rs = $stmt->execute();
380:
381: return $rs && $stmt->affected_rows > 0;
382: }
383:
384: /**
385: * Get the primary admin for a Joomla instance
386: *
387: * @param string $hostname
388: * @param null|string $path
389: * @return string|null admin or false on failure
390: */
391: public function get_admin(string $hostname, string $path = ''): ?string
392: {
393: $dbconfig = $this->db_config($hostname, $path);
394: $mysqli = $this->_connectDB($dbconfig);
395: if (!$mysqli) {
396: error('cannot get admin user - failed to connect to database');
397:
398: return null;
399: }
400: $q = 'select id, username FROM ' . $dbconfig['prefix'] . 'users ORDER BY registerDate, id ASC limit 1';
401: $rs = $mysqli->query($q);
402: if (!$rs || $rs->num_rows < 1) {
403: warn('failed to enumerate Joomla administrative users');
404:
405: return null;
406: }
407:
408: return $rs->fetch_object()->username;
409:
410: }
411:
412: /**
413: * Get database configuration for a blog
414: *
415: * @param string $hostname domain or subdomain of joomla
416: * @param string $path optional path
417: * @return array|bool
418: */
419: public function db_config(string $hostname, string $path = '')
420: {
421: $docroot = $this->getAppRoot($hostname, $path);
422: if (!$docroot) {
423: return error('failed to determine Joomla');
424: }
425:
426: $j = $this->_getConfiguration($docroot);
427:
428: return array(
429: 'user' => $j['user'],
430: 'password' => $j['password'],
431: 'db' => $j['db'],
432: 'prefix' => $j['dbprefix'],
433: 'host' => $j['host']
434: );
435: }
436:
437: private function _getConfiguration($docroot)
438: {
439:
440: $code = 'include("./configuration.php"); $j = new JConfig(); print serialize($j);';
441: $cmd = 'cd %(path)s && php -d mysqli.default_socket=' . escapeshellarg(ini_get('mysqli.default_socket')) . ' -r %(code)s';
442: $ret = $this->pman_run($cmd, array('path' => $docroot, 'code' => $code));
443: if (!$ret['success']) {
444: return error("failed to obtain Joomla configuration for `%s'", $docroot);
445: }
446:
447: return get_object_vars(\Util_PHP::unserialize(trim($ret['stdout'])));
448: }
449:
450: private function _connectDB($dbconfig)
451: {
452: static $conn;
453: if ($conn) {
454: return $conn;
455: }
456: $conn = new mysqli($dbconfig['host'], $dbconfig['user'], $dbconfig['password'], $dbconfig['db']);
457: if ($conn->connect_error) {
458: return false;
459: }
460:
461: return $conn;
462: }
463:
464: /**
465: * Generate a Joomla!-compatible salted password
466: *
467: * Supposedly compatible with 2.x and 3.x, only tested on 3.x
468: *
469: * @param string $docroot docroot path
470: * @param string $password
471: * @return string salted password
472: */
473: private function _saltedPassword($docroot, $password): string
474: {
475: $salt = \Opcenter\Auth\Password::generate(32);
476: $hash = md5($password . $salt);
477:
478: return $hash . ':' . $salt;
479: }
480:
481: /**
482: * Install and activate plugin
483: *
484: * @param string $hostname domain or subdomain of joomla install
485: * @param string $path optional path component of joomla install
486: * @param string $plugin plugin name
487: * @param string $version optional plugin version
488: * @return bool
489: */
490: public function install_plugin(string $hostname, string $path, string $plugin, string $version = ''): bool
491: {
492: $docroot = $this->getAppRoot($hostname, $path);
493: if (!$docroot) {
494: return error('invalid Joomla location');
495: }
496:
497: $args = array($plugin);
498: $ret = $this->_exec($docroot, 'plugin install %s --activate', $args);
499: if (!$ret['success']) {
500: return error("failed to install plugin `%s': %s", $plugin, $ret['stderr']);
501: }
502: info("installed plugin `%s'", $plugin);
503:
504: return true;
505: }
506:
507: /**
508: * Uninstall a plugin
509: *
510: * @param string $hostname
511: * @param string $path
512: * @param string $plugin plugin name
513: * @param bool $force delete even if plugin activated
514: * @return bool
515: */
516: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
517: {
518: $docroot = $this->getAppRoot($hostname, $path);
519: if (!$docroot) {
520: return error('invalid Joomla location');
521: }
522:
523: $args = array($plugin);
524: $cmd = 'plugin uninstall %s';
525: if ($force) {
526: $cmd .= ' --deactivate';
527: }
528: $ret = $this->_exec($docroot, $cmd, $args);
529:
530: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
531: return error("failed to uninstall plugin `%s': %s", $plugin, $ret['stderr']);
532: }
533: info("uninstalled plugin `%s'", $plugin);
534:
535: return true;
536: }
537:
538: public function plugin_status(string $hostname, string $path = '', string $plugin = null): array
539: {
540: return [];
541: }
542:
543: /**
544: * Recovery mode to disable all plugins
545: *
546: * @param string $hostname subdomain or domain of Joomla
547: * @param string $path optional path
548: * @return bool
549: */
550: public function disable_all_plugins(string $hostname, string $path = ''): bool
551: {
552: $docroot = $this->getAppRoot($hostname, $path);
553: if (!$docroot) {
554: return error('failed to determine path');
555: }
556:
557: $ret = $this->_exec($docroot, 'plugin deactivate --all --skip-plugins');
558: if (!$ret['success']) {
559: return error('failed to deactivate all plugins: %s', $ret['stderr']);
560: }
561:
562: return info('plugin deactivation successful: %s', $ret['stdout']);
563: }
564:
565: /**
566: * Update core, plugins, and themes atomically
567: *
568: * @param string $hostname subdomain or domain
569: * @param string $path optional path under hostname
570: * @param string $version
571: * @return bool
572: */
573: public function update_all(string $hostname, string $path = '', string $version = null): bool
574: {
575: $ret = ($this->update($hostname, $path, $version)/* && $this->update_plugins($hostname, $path)*/)
576: || error('failed to update all components');
577:
578: parent::setInfo($this->getDocumentRoot($hostname, $path), [
579: 'version' => $this->get_version($hostname, $path),
580: 'failed' => !$ret
581: ]);
582:
583: return $ret;
584: }
585:
586: /**
587: * Update Joomla! to latest version
588: *
589: * @param string $hostname domain or subdomain under which Joomla is installed
590: * @param string $path optional subdirectory
591: * @param string $version
592: * @return bool
593: * @throws \HTTP_Request2_Exception
594: */
595: public function update(string $hostname, string $path = '', string $version = null): bool
596: {
597: if (!IS_CLI) {
598: return $this->query('joomla_update', $hostname, $path, $version);
599: }
600:
601: $docroot = $this->getAppRoot($hostname, $path);
602: if (!$docroot) {
603: return error('update failed');
604: }
605:
606: $ret = $this->update_real($docroot, $version);
607:
608: parent::setInfo($docroot, [
609: 'version' => $this->get_version($hostname, $path) ?? $version,
610: 'failed' => !$ret
611: ]);
612:
613: $mode = array_get(
614: $this->getOptions($docroot),
615: 'fortify',
616: self::DEFAULT_FORTIFY_MODE
617: );
618: $this->fortify($hostname, $path, $mode);
619: info('Joomla updated, fortification set to %s', $mode);
620:
621: return info('Upgrade partially completed. Login to Joomla! admin portal to finish upgrade.');
622: }
623:
624: /**
625: * Get installed version
626: *
627: * @param string $hostname
628: * @param string $path
629: * @return string|null version number
630: */
631: public function get_version(string $hostname, string $path = ''): ?string
632: {
633: if (!$this->valid($hostname, $path)) {
634: return null;
635: }
636: $docroot = $this->getAppRoot($hostname, $path);
637: $fsroot = $this->domain_fs_path();
638: $path = $fsroot . $docroot;
639: $versigs = array(
640: '/libraries/cms/version/version.php', // 3.x, 2.5.x,
641: '/libraries/joomla/version.php', // 1.7.x allegedly
642: '/includes/version.php,' // what I found in 1.7.5
643: );
644: $file = $path . '/administrator/manifests/files/joomla.xml';
645: if (file_exists($file)) {
646: // quickest route on v3.5+
647: $xml = simplexml_load_string(file_get_contents($file));
648: $version = data_get($xml, 'version');
649: if ($version) {
650: return (string)$version[0];
651: }
652: }
653: if (!defined('JPATH_PLATFORM')) {
654: define('JPATH_PLATFORM', 'goddamn sanity checks');
655: }
656: if (!defined('_JEXEC')) {
657: define('_JEXEC', 'this is also a PITA');
658: }
659: $version = null;
660: foreach ($versigs as $sig) {
661: $mypath = $path . $sig;
662: if (!file_exists($mypath)) {
663: continue;
664: }
665: $code = "define('JPATH_PLATFORM', 'foo'); define('_JEXEC', 'bar'); include_once './$sig'; class_exists('JVersion') or exit(1); " .
666: 'print (new JVersion)->getShortVersion();';
667: $ret = $this->pman_run('cd %(docroot)s && php -r %(code)s', [
668: 'docroot' => $docroot,
669: 'path' => $sig,
670: 'code' => $code
671: ]);
672: if ($ret['success']) {
673: return trim($ret['output']);
674: }
675: break;
676: }
677: if (null === $version) {
678: error('cannot determine Joomla! version - incomplete install?');
679:
680: return null;
681: }
682:
683: return $version;
684: }
685:
686: /**
687: * Location is a valid Joomla install
688: *
689: * @param string $hostname or $docroot
690: * @param string $path
691: * @return bool
692: */
693: public function valid(string $hostname, string $path = ''): bool
694: {
695: if ($hostname[0] === '/') {
696: $docroot = $hostname;
697: } else {
698: $docroot = $this->getAppRoot($hostname, $path);
699: if (!$docroot) {
700: return false;
701: }
702: }
703:
704: return $this->file_exists($docroot . '/libraries/joomla') || $this->file_exists($docroot . '/cli/joomla.php');
705: }
706:
707: protected function update_real(string $docroot, string $version = null): bool
708: {
709: if ($version) {
710: if (!is_scalar($version) || strcspn($version, '.0123456789')) {
711: return error('invalid version number, %s', $version);
712: }
713: if (!in_array($version, $this->get_versions())) {
714: return error("unknown version `%s'", $version);
715: }
716: } else {
717: $version = $this->getLatestVersion();
718: }
719:
720: $replace = array(
721: 'version' => $version
722: );
723:
724: $uri = preg_replace_callback(Regex::LAZY_SUB, static function ($m) use ($replace) {
725: return $replace[$m[1]];
726: }, self::UPDATE_URI);
727:
728: $user = $this->getDocrootUser($docroot);
729:
730: if (!parent::download($uri, $docroot)) {
731: return error('failed to update Joomla! - download failed');
732: }
733: if ($user !== $this->username) {
734: $this->file_chown($docroot, $user, true);
735: }
736:
737: // as a prereq, joomlaupdate component must be updated as well
738: // PHP runs jailed, may not have cache plugin installed, disable it
739: $cfgfile = $docroot . '/configuration.php';
740: $config = $this->file_get_file_contents($cfgfile);
741: $newconfig = preg_replace('/public\s+\$cache_handler\s*=[^;]+;/', '', $config);
742: $this->file_put_file_contents($cfgfile, $newconfig);
743: if (!$this->_updateJoomlaUpdatePlugin($docroot, $user, $version)) {
744: warn('Upgrade incomplete - failed to fetch Joomla! Update extension. Login to Joomla! admin portal to finish.');
745: } else {
746: warn('Login to Joomla! administrative panel to complete upgrade');
747: }
748: $this->file_put_file_contents($cfgfile, $config, true);
749:
750: return true;
751: }
752:
753: public function get_versions(): array
754: {
755: if (!IS_CLI) {
756: return $this->query('joomla_get_versions');
757: }
758:
759: return $this->_getVersions();
760: }
761:
762: /**
763: * Get all current major versions
764: *
765: * @return array
766: */
767: protected function _getVersions()
768: {
769: $key = 'joomla.versions';
770: $cache = Cache_Super_Global::spawn();
771: if (false !== ($ver = $cache->get($key))) {
772: return $ver;
773: }
774: // retain backward compatibility with older releases
775: $oldCli = $this->cliFromVersion('1.0');
776: $proc = Util_Process::exec(
777: 'php ' . $oldCli . ' --repo=%(repo)s --refresh versions',
778: array(
779: 'repo' => self::JOOMLA_MIRROR
780: )
781: );
782: if (!$proc['success'] || !preg_match_all(Regex::JOOMLA_VERSIONS, $proc['stdout'], $matches)) {
783: error('failed to fetch Joomla versions: %s', $proc['stderr']);
784:
785: return [];
786: }
787: $versions = $matches[1];
788: usort($versions, 'version_compare');
789: $cache->set($key, $versions, 43200);
790:
791: return $versions;
792: }
793:
794: private function _updateJoomlaUpdatePlugin($docroot, $user, $version)
795: {
796: $juext = $this->get_plugin_info('com_joomlaupdate');
797: if (!$juext) {
798: return false;
799: }
800:
801:
802: $updatever = (string)(
803: array_get($juext, 'update.targetplatform.@attributes.version') ??
804: array_get($juext, 'update.0.targetplatform.@attributes.version'));
805: if (version_compare((string)$version, $updatever, '<')) {
806: return true;
807: }
808:
809: // grab head if multiple updates exist
810: $uri = array_get($juext, 'update.downloads.downloadurl') ??
811: array_get($juext, 'update.0.downloads.downloadurl');
812: $path = $docroot . '/tmp/com_joomlaupdate';
813: if ($this->file_exists($path)) {
814: return false;
815: }
816:
817: $this->file_create_directory($path);
818: if (!parent::download($uri, $path)) {
819: return false;
820: }
821: $this->file_chown($path, $user, true);
822:
823: $ret = $this->_exec($docroot, 'extension:installfile -- . %(plugin)s', array('plugin' => $path));
824: $this->file_delete($path, true);
825:
826: return $ret['success'];
827: }
828:
829: public function get_plugin_info($plugin, $ver = null)
830: {
831: $replace = array(
832: 'extension' => $plugin,
833: 'ver' => $ver
834: );
835: // @todo determine plugin versioning
836: $uri = preg_replace_callback(Regex::LAZY_SUB, static function ($m) use ($replace) {
837: return $replace[$m[1]];
838: 'http://update.joomla.org/core/extensions/%extension%.xml';
839: }, self::JOOMLA_MODULE_XML . '?ver=%ver%');
840: $content = silence(static function () use ($uri) {
841: return simplexml_load_string(file_get_contents($uri), 'SimpleXMLElement', LIBXML_NOCDATA);
842: });
843: if (!$content) {
844: return false;
845: }
846: // @todo I don't like this, but SimpleXML is not an acceptable public
847: // return type either
848: return json_decode(json_encode((array)$content), true);
849: }
850:
851: /**
852: * Update Joomla! plugins
853: *
854: * @param string $hostname domain or subdomain
855: * @param string $path optional path within host
856: * @param array $plugins
857: * @return bool
858: */
859: public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
860: {
861: $docroot = $this->getAppRoot($hostname, $path);
862: if (!$docroot) {
863: return error('update failed');
864: }
865:
866: $cmd = 'plugin update';
867: $args = array();
868: if (!$plugins) {
869: $cmd .= ' --all';
870: } else {
871: for ($i = 0, $n = count($plugins); $i < $n; $i++) {
872: $plugin = $plugins[$i];
873: $version = null;
874: if (isset($plugin['version'])) {
875: $version = $plugin['version'];
876: }
877: if (isset($plugin['name'])) {
878: $plugin = $plugin['name'];
879: }
880:
881: $name = 'p' . $i;
882:
883: $cmd .= ' %(' . $name . ')s';
884: $args[$name] = $plugin;
885: if ($version) {
886: $cmd .= ' --version=%(' . $name . 'v)s';
887: $args[$name . 'v'] = $version;
888: }
889: }
890: }
891:
892: $ret = $this->_exec($docroot, $cmd, $args);
893: if (!$ret['success']) {
894: return error("plugin update failed: `%s'", $ret['stderr']);
895: }
896:
897: return $ret['success'];
898: }
899:
900: /**
901: * Update Joomla! themes
902: *
903: * @param string $hostname subdomain or domain
904: * @param string $path optional path under hostname
905: * @param array $themes
906: * @return bool
907: */
908: public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
909: {
910: return error('not implemented yet');
911: }
912:
913: /**
914: * Install joomlatools if necessary
915: *
916: * @return bool
917: */
918: public function _housekeeping(): bool
919: {
920: $versions = [
921: 'joomlatools-1.6.0-1.phar' => 'joomlatools-1.6.0-1.phar',
922: 'joomlatools-' . self::JOOMLA_CLI_VERSION . '.phar' => basename(self::JOOMLA_CLI)
923: ];
924:
925: foreach ($versions as $full => $short) {
926: $src = resource_path('storehouse') . '/' . $full;
927: $dest = dirname(self::JOOMLA_CLI) . '/' . $short;
928: if (is_file($dest) && sha1_file($src) === sha1_file($dest)) {
929: continue;
930: }
931:
932: copy($src, $dest);
933: chmod($dest, 0755);
934: info('Copied %s to PEAR', $full);
935: }
936:
937: return true;
938: }
939: }
940: