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:
215: $this->notifyInstalled($hostname, $path, $opts);
216:
217: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
218: ['app' => static::APP_NAME, 'email' => $opts['email']]);
219: }
220:
221: /**
222: * Get referred name
223: *
224: * Referred to as Joomla!, but internally let's track as "joomla"
225: *
226: * @return string
227: */
228: protected function getInternalName(): string
229: {
230: return 'joomla';
231: }
232:
233: private function cliFromVersion(string $version, string $poolVersion = null): string
234: {
235: $selections = [
236: dirname(self::JOOMLA_CLI) . '/joomlatools-1.6.0-1.phar',
237: self::JOOMLA_CLI
238: ];
239: $choice = version_compare($version, '3.5.0', '<') ? 0 : 1;
240: if ($poolVersion && Versioning::compare($poolVersion, '7.3.0', '<')) {
241: return $selections[0];
242: }
243:
244: return $selections[$choice];
245: }
246:
247: /**
248: * Look for manifest presence in v3.5+
249: *
250: * @param string $path
251: * @return string
252: */
253: private function assertCliTypeFromInstall(string $path): string
254: {
255: return $this->file_exists($path . '/administrator/manifests/files/joomla.xml') ? '999.999.999' : '3.4.99999';
256: }
257:
258: private function _exec(?string $path, $cmd, array $args = array())
259: {
260: $wrapper = PhpWrapper::instantiateContexted($this->getAuthContext());
261: $jtcli = $this->cliFromVersion($args['version'] ?? $this->assertCliTypeFromInstall($path), $this->php_pool_version_from_path((string)$path));
262: $ret = $wrapper->exec($path, $jtcli . ' --no-interaction ' . $cmd, $args);
263:
264: if (!strncmp($ret['stdout'], 'Error:', strlen('Error:'))) {
265: // move stdout to stderr on error for consistency
266: $ret['success'] = false;
267: if (!$ret['stderr']) {
268: $ret['stderr'] = $ret['stdout'];
269: }
270: } else if (!$ret['success'] && !$ret['stderr']) {
271: $ret['stderr'] = $ret['stdout'];
272: }
273:
274: return $ret;
275: }
276:
277: private function _fixMySQLSchema($docroot)
278: {
279: if ($this->sql_mysql_version() >= 50503) {
280: // utf8mb4 supported
281: return true;
282: }
283: $prefix = $this->domain_shadow_path();
284: $glob = $prefix . $docroot . '/{_,}installation/sql/mysql/*.sql';
285: foreach (glob($glob, GLOB_BRACE) as $f) {
286: $f = substr($f, strlen($prefix));
287: $contents = $this->file_get_file_contents($f);
288: $matches = 0;
289: $contents = str_replace('utf8mb4', 'utf8', $contents, $matches);
290: $this->file_put_file_contents($f, $contents, true);
291: }
292:
293: return true;
294: }
295:
296: private function _generateConfig($docroot, $opts)
297: {
298: $domainfsprefix = $this->domain_fs_path();
299: $file = tempnam($domainfsprefix . '/' . sys_get_temp_dir(), 'joomla');
300: chmod($file, 0644);
301: $tz = date('T');
302: $fullpath = ($this->php_jailed() ? '' : $this->domain_fs_path()) . $docroot;
303: $opts = array(
304: // Apache handles compression
305: 'gzip' => false,
306: 'debug' => 0,
307: 'ftp_host' => 'localhost',
308: 'ftp_user' => $this->username . '@' . $this->domain,
309: 'ftp_root' => $docroot,
310: 'sitename' => $opts['title'],
311: 'offset' => $tz,
312: 'tmp_path' => $fullpath . '/tmp',
313: 'log_path' => $fullpath . '/logs',
314: 'sendmail' => '/usr/sbin/sendmail',
315: 'mailer' => 'mail',
316: 'smtpport' => 587,
317: 'force_ssl' => !empty($opts['ssl']) ? 2 : 0,
318: );
319: // pure PHP code
320: file_put_contents($file, Symfony\Component\Yaml\Yaml::dump($opts, 2, 2));
321:
322: return substr($file, strlen($domainfsprefix));
323: }
324:
325: /**
326: * Change Joomla admin credentials
327: *
328: * common fields include: username, password, name, email
329: *
330: * @param string $hostname
331: * @param string $path
332: * @param array $fields
333: * @return bool
334: */
335: public function change_admin(string $hostname, string $path, array $fields): bool
336: {
337: $docroot = $this->getAppRoot($hostname, $path);
338: if (!$docroot) {
339: return warn('failed to change administrator information');
340: }
341: $admin = $this->get_admin($hostname, $path);
342:
343: if (!$admin) {
344: return error('cannot determine admin of Joomla install');
345: }
346: $dbconfig = $this->db_config($hostname, $path);
347: $conn = $this->_connectDB($dbconfig);
348: if (!$conn) {
349: return error("unable to connect to Joomla! database `%s'", $dbconfig['db']);
350: }
351: $fields = array_merge(array(
352: 'password' => null,
353: 'username' => null,
354: 'name' => null,
355: 'email' => null
356: ), $fields);
357: if ($fields['password']) {
358: $fields['password'] = $this->_saltedPassword($docroot, $fields['password']);
359: }
360: $filtered = array_filter($fields, static function ($f) {
361: return null !== $f;
362: });
363:
364: $args = array();
365: $builtQuery = array();
366: foreach ($filtered as $k => $v) {
367: $builtQuery[] = "$k = ?";
368: $args[] = &$filtered[$k];
369: }
370: //$args = array_map(function($a) { return $a; }, $filtered);
371: // plop admin user as part of WHERE username = ?
372: $args[] = &$admin;
373: $q = 'UPDATE ' . $dbconfig['prefix'] . 'users SET ' . implode(', ', $builtQuery) . ' WHERE username = ?';
374: $stmt = $conn->prepare($q);
375: $types = str_repeat('s', count($args));
376: array_unshift($args, $types);
377: call_user_func_array(array($stmt, 'bind_param'), $args);
378: $rs = $stmt->execute();
379:
380: return $rs && $stmt->affected_rows > 0;
381: }
382:
383: /**
384: * Get the primary admin for a Joomla instance
385: *
386: * @param string $hostname
387: * @param null|string $path
388: * @return string|null admin or false on failure
389: */
390: public function get_admin(string $hostname, string $path = ''): ?string
391: {
392: $dbconfig = $this->db_config($hostname, $path);
393: $mysqli = $this->_connectDB($dbconfig);
394: if (!$mysqli) {
395: error('cannot get admin user - failed to connect to database');
396:
397: return null;
398: }
399: $q = 'select id, username FROM ' . $dbconfig['prefix'] . 'users ORDER BY registerDate, id ASC limit 1';
400: $rs = $mysqli->query($q);
401: if (!$rs || $rs->num_rows < 1) {
402: warn('failed to enumerate Joomla administrative users');
403:
404: return null;
405: }
406:
407: return $rs->fetch_object()->username;
408:
409: }
410:
411: /**
412: * Get database configuration for a blog
413: *
414: * @param string $hostname domain or subdomain of joomla
415: * @param string $path optional path
416: * @return array|bool
417: */
418: public function db_config(string $hostname, string $path = '')
419: {
420: $docroot = $this->getAppRoot($hostname, $path);
421: if (!$docroot) {
422: return error('failed to determine Joomla');
423: }
424:
425: $j = $this->_getConfiguration($docroot);
426:
427: return array(
428: 'user' => $j['user'],
429: 'password' => $j['password'],
430: 'db' => $j['db'],
431: 'prefix' => $j['dbprefix'],
432: 'host' => $j['host']
433: );
434: }
435:
436: private function _getConfiguration($docroot)
437: {
438:
439: $code = 'include("./configuration.php"); $j = new JConfig(); print serialize($j);';
440: $cmd = 'cd %(path)s && php -d mysqli.default_socket=' . escapeshellarg(ini_get('mysqli.default_socket')) . ' -r %(code)s';
441: $ret = $this->pman_run($cmd, array('path' => $docroot, 'code' => $code));
442: if (!$ret['success']) {
443: return error("failed to obtain Joomla configuration for `%s'", $docroot);
444: }
445:
446: return get_object_vars(\Util_PHP::unserialize(trim($ret['stdout'])));
447: }
448:
449: private function _connectDB($dbconfig)
450: {
451: static $conn;
452: if ($conn) {
453: return $conn;
454: }
455: $conn = new mysqli($dbconfig['host'], $dbconfig['user'], $dbconfig['password'], $dbconfig['db']);
456: if ($conn->connect_error) {
457: return false;
458: }
459:
460: return $conn;
461: }
462:
463: /**
464: * Generate a Joomla!-compatible salted password
465: *
466: * Supposedly compatible with 2.x and 3.x, only tested on 3.x
467: *
468: * @param string $docroot docroot path
469: * @param string $password
470: * @return string salted password
471: */
472: private function _saltedPassword($docroot, $password): string
473: {
474: $salt = \Opcenter\Auth\Password::generate(32);
475: $hash = md5($password . $salt);
476:
477: return $hash . ':' . $salt;
478: }
479:
480: /**
481: * Install and activate plugin
482: *
483: * @param string $hostname domain or subdomain of joomla install
484: * @param string $path optional path component of joomla install
485: * @param string $plugin plugin name
486: * @param string $version optional plugin version
487: * @return bool
488: */
489: public function install_plugin(string $hostname, string $path, string $plugin, string $version = ''): bool
490: {
491: $docroot = $this->getAppRoot($hostname, $path);
492: if (!$docroot) {
493: return error('invalid Joomla location');
494: }
495:
496: $args = array($plugin);
497: $ret = $this->_exec($docroot, 'plugin install %s --activate', $args);
498: if (!$ret['success']) {
499: return error("failed to install plugin `%s': %s", $plugin, $ret['stderr']);
500: }
501: info("installed plugin `%s'", $plugin);
502:
503: return true;
504: }
505:
506: /**
507: * Uninstall a plugin
508: *
509: * @param string $hostname
510: * @param string $path
511: * @param string $plugin plugin name
512: * @param bool $force delete even if plugin activated
513: * @return bool
514: */
515: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
516: {
517: $docroot = $this->getAppRoot($hostname, $path);
518: if (!$docroot) {
519: return error('invalid Joomla location');
520: }
521:
522: $args = array($plugin);
523: $cmd = 'plugin uninstall %s';
524: if ($force) {
525: $cmd .= ' --deactivate';
526: }
527: $ret = $this->_exec($docroot, $cmd, $args);
528:
529: if (!$ret['stdout'] || !strncmp($ret['stdout'], 'Warning:', strlen('Warning:'))) {
530: return error("failed to uninstall plugin `%s': %s", $plugin, $ret['stderr']);
531: }
532: info("uninstalled plugin `%s'", $plugin);
533:
534: return true;
535: }
536:
537: public function plugin_status(string $hostname, string $path = '', string $plugin = null): array
538: {
539: return [];
540: }
541:
542: /**
543: * Recovery mode to disable all plugins
544: *
545: * @param string $hostname subdomain or domain of Joomla
546: * @param string $path optional path
547: * @return bool
548: */
549: public function disable_all_plugins(string $hostname, string $path = ''): bool
550: {
551: $docroot = $this->getAppRoot($hostname, $path);
552: if (!$docroot) {
553: return error('failed to determine path');
554: }
555:
556: $ret = $this->_exec($docroot, 'plugin deactivate --all --skip-plugins');
557: if (!$ret['success']) {
558: return error('failed to deactivate all plugins: %s', $ret['stderr']);
559: }
560:
561: return info('plugin deactivation successful: %s', $ret['stdout']);
562: }
563:
564: /**
565: * Update core, plugins, and themes atomically
566: *
567: * @param string $hostname subdomain or domain
568: * @param string $path optional path under hostname
569: * @param string $version
570: * @return bool
571: */
572: public function update_all(string $hostname, string $path = '', string $version = null): bool
573: {
574: $ret = ($this->update($hostname, $path, $version)/* && $this->update_plugins($hostname, $path)*/)
575: || error('failed to update all components');
576:
577: parent::setInfo($this->getDocumentRoot($hostname, $path), [
578: 'version' => $this->get_version($hostname, $path),
579: 'failed' => !$ret
580: ]);
581:
582: return $ret;
583: }
584:
585: /**
586: * Update Joomla! to latest version
587: *
588: * @param string $hostname domain or subdomain under which Joomla is installed
589: * @param string $path optional subdirectory
590: * @param string $version
591: * @return bool
592: * @throws \HTTP_Request2_Exception
593: */
594: public function update(string $hostname, string $path = '', string $version = null): bool
595: {
596: if (!IS_CLI) {
597: return $this->query('joomla_update', $hostname, $path, $version);
598: }
599:
600: $docroot = $this->getAppRoot($hostname, $path);
601: if (!$docroot) {
602: return error('update failed');
603: }
604:
605: $ret = $this->update_real($docroot, $version);
606:
607: parent::setInfo($docroot, [
608: 'version' => $this->get_version($hostname, $path) ?? $version,
609: 'failed' => !$ret
610: ]);
611:
612: $mode = array_get(
613: $this->getOptions($docroot),
614: 'fortify',
615: self::DEFAULT_FORTIFY_MODE
616: );
617: $this->fortify($hostname, $path, $mode);
618: info('Joomla updated, fortification set to %s', $mode);
619:
620: return info('Upgrade partially completed. Login to Joomla! admin portal to finish upgrade.');
621: }
622:
623: /**
624: * Get installed version
625: *
626: * @param string $hostname
627: * @param string $path
628: * @return string|null version number
629: */
630: public function get_version(string $hostname, string $path = ''): ?string
631: {
632: if (!$this->valid($hostname, $path)) {
633: return null;
634: }
635: $docroot = $this->getAppRoot($hostname, $path);
636: $fsroot = $this->domain_fs_path();
637: $path = $fsroot . $docroot;
638: $versigs = array(
639: '/libraries/cms/version/version.php', // 3.x, 2.5.x,
640: '/libraries/joomla/version.php', // 1.7.x allegedly
641: '/includes/version.php,' // what I found in 1.7.5
642: );
643: $file = $path . '/administrator/manifests/files/joomla.xml';
644: if (file_exists($file)) {
645: // quickest route on v3.5+
646: $xml = simplexml_load_string(file_get_contents($file));
647: $version = data_get($xml, 'version');
648: if ($version) {
649: return (string)$version[0];
650: }
651: }
652: if (!defined('JPATH_PLATFORM')) {
653: define('JPATH_PLATFORM', 'goddamn sanity checks');
654: }
655: if (!defined('_JEXEC')) {
656: define('_JEXEC', 'this is also a PITA');
657: }
658: $version = null;
659: foreach ($versigs as $sig) {
660: $mypath = $path . $sig;
661: if (!file_exists($mypath)) {
662: continue;
663: }
664: $code = "define('JPATH_PLATFORM', 'foo'); define('_JEXEC', 'bar'); include_once './$sig'; class_exists('JVersion') or exit(1); " .
665: 'print (new JVersion)->getShortVersion();';
666: $ret = $this->pman_run('cd %(docroot)s && php -r %(code)s', [
667: 'docroot' => $docroot,
668: 'path' => $sig,
669: 'code' => $code
670: ]);
671: if ($ret['success']) {
672: return trim($ret['output']);
673: }
674: break;
675: }
676: if (null === $version) {
677: error('cannot determine Joomla! version - incomplete install?');
678:
679: return null;
680: }
681:
682: return $version;
683: }
684:
685: /**
686: * Location is a valid Joomla install
687: *
688: * @param string $hostname or $docroot
689: * @param string $path
690: * @return bool
691: */
692: public function valid(string $hostname, string $path = ''): bool
693: {
694: if ($hostname[0] === '/') {
695: $docroot = $hostname;
696: } else {
697: $docroot = $this->getAppRoot($hostname, $path);
698: if (!$docroot) {
699: return false;
700: }
701: }
702:
703: return $this->file_exists($docroot . '/libraries/joomla') || $this->file_exists($docroot . '/cli/joomla.php');
704: }
705:
706: protected function update_real(string $docroot, string $version = null): bool
707: {
708: if ($version) {
709: if (!is_scalar($version) || strcspn($version, '.0123456789')) {
710: return error('invalid version number, %s', $version);
711: }
712: if (!in_array($version, $this->get_versions())) {
713: return error("unknown version `%s'", $version);
714: }
715: } else {
716: $version = $this->getLatestVersion();
717: }
718:
719: $replace = array(
720: 'version' => $version
721: );
722:
723: $uri = preg_replace_callback(Regex::LAZY_SUB, static function ($m) use ($replace) {
724: return $replace[$m[1]];
725: }, self::UPDATE_URI);
726:
727: $user = $this->getDocrootUser($docroot);
728:
729: if (!parent::download($uri, $docroot)) {
730: return error('failed to update Joomla! - download failed');
731: }
732: if ($user !== $this->username) {
733: $this->file_chown($docroot, $user, true);
734: }
735:
736: // as a prereq, joomlaupdate component must be updated as well
737: // PHP runs jailed, may not have cache plugin installed, disable it
738: $cfgfile = $docroot . '/configuration.php';
739: $config = $this->file_get_file_contents($cfgfile);
740: $newconfig = preg_replace('/public\s+\$cache_handler\s*=[^;]+;/', '', $config);
741: $this->file_put_file_contents($cfgfile, $newconfig);
742: if (!$this->_updateJoomlaUpdatePlugin($docroot, $user, $version)) {
743: warn('Upgrade incomplete - failed to fetch Joomla! Update extension. Login to Joomla! admin portal to finish.');
744: } else {
745: warn('Login to Joomla! administrative panel to complete upgrade');
746: }
747: $this->file_put_file_contents($cfgfile, $config, true);
748:
749: return true;
750: }
751:
752: public function get_versions(): array
753: {
754: if (!IS_CLI) {
755: return $this->query('joomla_get_versions');
756: }
757:
758: return $this->_getVersions();
759: }
760:
761: /**
762: * Get all current major versions
763: *
764: * @return array
765: */
766: protected function _getVersions()
767: {
768: $key = 'joomla.versions';
769: $cache = Cache_Super_Global::spawn();
770: if (false !== ($ver = $cache->get($key))) {
771: return $ver;
772: }
773: // retain backward compatibility with older releases
774: $oldCli = $this->cliFromVersion('1.0');
775: $proc = Util_Process::exec(
776: 'php ' . $oldCli . ' --repo=%(repo)s --refresh versions',
777: array(
778: 'repo' => self::JOOMLA_MIRROR
779: )
780: );
781: if (!$proc['success'] || !preg_match_all(Regex::JOOMLA_VERSIONS, $proc['stdout'], $matches)) {
782: error('failed to fetch Joomla versions: %s', $proc['stderr']);
783:
784: return [];
785: }
786: $versions = $matches[1];
787: usort($versions, 'version_compare');
788: $cache->set($key, $versions, 43200);
789:
790: return $versions;
791: }
792:
793: private function _updateJoomlaUpdatePlugin($docroot, $user, $version)
794: {
795: $juext = $this->get_plugin_info('com_joomlaupdate');
796: if (!$juext) {
797: return false;
798: }
799:
800:
801: $updatever = (string)(
802: array_get($juext, 'update.targetplatform.@attributes.version') ??
803: array_get($juext, 'update.0.targetplatform.@attributes.version'));
804: if (version_compare((string)$version, $updatever, '<')) {
805: return true;
806: }
807:
808: // grab head if multiple updates exist
809: $uri = array_get($juext, 'update.downloads.downloadurl') ??
810: array_get($juext, 'update.0.downloads.downloadurl');
811: $path = $docroot . '/tmp/com_joomlaupdate';
812: if ($this->file_exists($path)) {
813: return false;
814: }
815:
816: $this->file_create_directory($path);
817: if (!parent::download($uri, $path)) {
818: return false;
819: }
820: $this->file_chown($path, $user, true);
821:
822: $ret = $this->_exec($docroot, 'extension:installfile -- . %(plugin)s', array('plugin' => $path));
823: $this->file_delete($path, true);
824:
825: return $ret['success'];
826: }
827:
828: public function get_plugin_info($plugin, $ver = null)
829: {
830: $replace = array(
831: 'extension' => $plugin,
832: 'ver' => $ver
833: );
834: // @todo determine plugin versioning
835: $uri = preg_replace_callback(Regex::LAZY_SUB, static function ($m) use ($replace) {
836: return $replace[$m[1]];
837: 'http://update.joomla.org/core/extensions/%extension%.xml';
838: }, self::JOOMLA_MODULE_XML . '?ver=%ver%');
839: $content = silence(static function () use ($uri) {
840: return simplexml_load_string(file_get_contents($uri), 'SimpleXMLElement', LIBXML_NOCDATA);
841: });
842: if (!$content) {
843: return false;
844: }
845: // @todo I don't like this, but SimpleXML is not an acceptable public
846: // return type either
847: return json_decode(json_encode((array)$content), true);
848: }
849:
850: /**
851: * Update Joomla! plugins
852: *
853: * @param string $hostname domain or subdomain
854: * @param string $path optional path within host
855: * @param array $plugins
856: * @return bool
857: */
858: public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool
859: {
860: $docroot = $this->getAppRoot($hostname, $path);
861: if (!$docroot) {
862: return error('update failed');
863: }
864:
865: $cmd = 'plugin update';
866: $args = array();
867: if (!$plugins) {
868: $cmd .= ' --all';
869: } else {
870: for ($i = 0, $n = count($plugins); $i < $n; $i++) {
871: $plugin = $plugins[$i];
872: $version = null;
873: if (isset($plugin['version'])) {
874: $version = $plugin['version'];
875: }
876: if (isset($plugin['name'])) {
877: $plugin = $plugin['name'];
878: }
879:
880: $name = 'p' . $i;
881:
882: $cmd .= ' %(' . $name . ')s';
883: $args[$name] = $plugin;
884: if ($version) {
885: $cmd .= ' --version=%(' . $name . 'v)s';
886: $args[$name . 'v'] = $version;
887: }
888: }
889: }
890:
891: $ret = $this->_exec($docroot, $cmd, $args);
892: if (!$ret['success']) {
893: return error("plugin update failed: `%s'", $ret['stderr']);
894: }
895:
896: return $ret['success'];
897: }
898:
899: /**
900: * Update Joomla! themes
901: *
902: * @param string $hostname subdomain or domain
903: * @param string $path optional path under hostname
904: * @param array $themes
905: * @return bool
906: */
907: public function update_themes(string $hostname, string $path = '', array $themes = array()): bool
908: {
909: return error('not implemented yet');
910: }
911:
912: /**
913: * Install joomlatools if necessary
914: *
915: * @return bool
916: */
917: public function _housekeeping(): bool
918: {
919: $versions = [
920: 'joomlatools-1.6.0-1.phar' => 'joomlatools-1.6.0-1.phar',
921: 'joomlatools-' . self::JOOMLA_CLI_VERSION . '.phar' => basename(self::JOOMLA_CLI)
922: ];
923:
924: foreach ($versions as $full => $short) {
925: $src = resource_path('storehouse') . '/' . $full;
926: $dest = dirname(self::JOOMLA_CLI) . '/' . $short;
927: if (is_file($dest) && sha1_file($src) === sha1_file($dest)) {
928: continue;
929: }
930:
931: copy($src, $dest);
932: chmod($dest, 0755);
933: info('Copied %s to PEAR', $full);
934: }
935:
936: return true;
937: }
938: }