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: | $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: | |
228: | |
229: | |
230: | |
231: | |
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: | |
254: | |
255: | |
256: | |
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: | |
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: | |
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: | |
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: | |
325: | file_put_contents($file, Symfony\Component\Yaml\Yaml::dump($opts, 2, 2)); |
326: | |
327: | return substr($file, strlen($domainfsprefix)); |
328: | } |
329: | |
330: | |
331: | |
332: | |
333: | |
334: | |
335: | |
336: | |
337: | |
338: | |
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: | |
376: | |
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: | |
390: | |
391: | |
392: | |
393: | |
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: | |
418: | |
419: | |
420: | |
421: | |
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: | |
470: | |
471: | |
472: | |
473: | |
474: | |
475: | |
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: | |
487: | |
488: | |
489: | |
490: | |
491: | |
492: | |
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: | |
513: | |
514: | |
515: | |
516: | |
517: | |
518: | |
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: | |
549: | |
550: | |
551: | |
552: | |
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: | |
571: | |
572: | |
573: | |
574: | |
575: | |
576: | |
577: | public function update_all(string $hostname, string $path = '', string $version = null): bool |
578: | { |
579: | $ret = ($this->update($hostname, $path, $version)) |
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: | |
592: | |
593: | |
594: | |
595: | |
596: | |
597: | |
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: | |
630: | |
631: | |
632: | |
633: | |
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', |
645: | '/libraries/joomla/version.php', |
646: | '/includes/version.php,' |
647: | ); |
648: | $file = $path . '/administrator/manifests/files/joomla.xml'; |
649: | if (file_exists($file)) { |
650: | |
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: | |
692: | |
693: | |
694: | |
695: | |
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: | |
742: | |
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: | |
768: | |
769: | |
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: | |
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: | |
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: | |
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: | |
851: | |
852: | return json_decode(json_encode((array)$content), true); |
853: | } |
854: | |
855: | |
856: | |
857: | |
858: | |
859: | |
860: | |
861: | |
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: | |
906: | |
907: | |
908: | |
909: | |
910: | |
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: | |
919: | |
920: | |
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: | } |