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