1: | <?php |
2: | declare(strict_types=1); |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
12: | |
13: | |
14: | |
15: | use Module\Support\Webapps\PhpWrapper; |
16: | use Opcenter\Versioning; |
17: | |
18: | |
19: | |
20: | |
21: | |
22: | |
23: | class Joomla_Module extends \Module\Support\Webapps |
24: | { |
25: | |
26: | const APP_NAME = 'Joomla!'; |
27: | |
28: | |
29: | |
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: | |
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: | |
70: | |
71: | |
72: | |
73: | public function __construct() |
74: | { |
75: | |
76: | parent::__construct(); |
77: | |
78: | } |
79: | |
80: | |
81: | |
82: | |
83: | |
84: | |
85: | |
86: | |
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: | |
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: | |
223: | |
224: | |
225: | |
226: | |
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: | |
249: | |
250: | |
251: | |
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: | |
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: | |
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: | |
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: | |
320: | file_put_contents($file, Symfony\Component\Yaml\Yaml::dump($opts, 2, 2)); |
321: | |
322: | return substr($file, strlen($domainfsprefix)); |
323: | } |
324: | |
325: | |
326: | |
327: | |
328: | |
329: | |
330: | |
331: | |
332: | |
333: | |
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: | |
371: | |
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: | |
385: | |
386: | |
387: | |
388: | |
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: | |
413: | |
414: | |
415: | |
416: | |
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: | |
465: | |
466: | |
467: | |
468: | |
469: | |
470: | |
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: | |
482: | |
483: | |
484: | |
485: | |
486: | |
487: | |
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: | |
508: | |
509: | |
510: | |
511: | |
512: | |
513: | |
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: | |
544: | |
545: | |
546: | |
547: | |
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: | |
566: | |
567: | |
568: | |
569: | |
570: | |
571: | |
572: | public function update_all(string $hostname, string $path = '', string $version = null): bool |
573: | { |
574: | $ret = ($this->update($hostname, $path, $version)) |
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: | |
587: | |
588: | |
589: | |
590: | |
591: | |
592: | |
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: | |
625: | |
626: | |
627: | |
628: | |
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', |
640: | '/libraries/joomla/version.php', |
641: | '/includes/version.php,' |
642: | ); |
643: | $file = $path . '/administrator/manifests/files/joomla.xml'; |
644: | if (file_exists($file)) { |
645: | |
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: | |
687: | |
688: | |
689: | |
690: | |
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: | |
737: | |
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: | |
763: | |
764: | |
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: | |
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: | |
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: | |
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: | |
846: | |
847: | return json_decode(json_encode((array)$content), true); |
848: | } |
849: | |
850: | |
851: | |
852: | |
853: | |
854: | |
855: | |
856: | |
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: | |
901: | |
902: | |
903: | |
904: | |
905: | |
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: | |
914: | |
915: | |
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: | } |