| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 11: | |
| 12: | |
| 13: | |
| 14: | |
| 15: | use Module\Support\Webapps\App\Type\Discourse\Launcher; |
| 16: | use Module\Support\Webapps\Passenger; |
| 17: | use Module\Support\Webapps\PathManager; |
| 18: | use Module\Support\Webapps\Traits\PublicRelocatable; |
| 19: | use Module\Support\Webapps\VersionFetcher\Github; |
| 20: | use Opcenter\Net\Port; |
| 21: | |
| 22: | |
| 23: | |
| 24: | |
| 25: | |
| 26: | |
| 27: | |
| 28: | |
| 29: | class Discourse_Module extends \Module\Support\Webapps |
| 30: | { |
| 31: | use PublicRelocatable { |
| 32: | getAppRoot as getAppRootReal; |
| 33: | } |
| 34: | |
| 35: | |
| 36: | const MINIMUM_INTERPRETERS = [ |
| 37: | '0' => '2.4.2', |
| 38: | '2.2.0.beta5' => '2.5.2', |
| 39: | '2.5.0' => '2.6.5', |
| 40: | '2.6.0' => '2.7.2', |
| 41: | '3.0.0' => '3.1.3', |
| 42: | '3.1.0' => '3.2.0' |
| 43: | ]; |
| 44: | |
| 45: | |
| 46: | |
| 47: | const NODE_VERSIONS = [ |
| 48: | '0' => '8', |
| 49: | '2.4' => '10', |
| 50: | '2.5' => '14', |
| 51: | '2.6' => '15', |
| 52: | '2.8' => '16', |
| 53: | '3.2' => '18.20' |
| 54: | ]; |
| 55: | |
| 56: | const APP_NAME = 'Discourse'; |
| 57: | const DEFAULT_VERSION_LOCK = 'minor'; |
| 58: | const DISCOURSE_REPO = 'https://github.com/discourse/discourse.git'; |
| 59: | |
| 60: | public function __construct() |
| 61: | { |
| 62: | parent::__construct(); |
| 63: | $this->exportedFunctions['restart'] = PRIVILEGE_SITE | PRIVILEGE_USER; |
| 64: | } |
| 65: | |
| 66: | public function plugin_status(string $hostname, string $path = '', string $plugin = null) |
| 67: | { |
| 68: | return error('not supported'); |
| 69: | } |
| 70: | |
| 71: | public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool |
| 72: | { |
| 73: | return error('not supported'); |
| 74: | } |
| 75: | |
| 76: | public function disable_all_plugins(string $hostname, string $path = ''): bool |
| 77: | { |
| 78: | return error('not supported'); |
| 79: | } |
| 80: | |
| 81: | |
| 82: | |
| 83: | |
| 84: | |
| 85: | |
| 86: | |
| 87: | |
| 88: | public function next_version(string $version, string $maximalbranch = '99999999.99999999.99999999'): ?string |
| 89: | { |
| 90: | return parent::next_version($version, $maximalbranch); |
| 91: | } |
| 92: | |
| 93: | |
| 94: | |
| 95: | |
| 96: | public function reconfigure(string $hostname, string $path, $param, $value = null): bool |
| 97: | { |
| 98: | return parent::reconfigure($hostname, $path, $param, $value); |
| 99: | } |
| 100: | |
| 101: | |
| 102: | |
| 103: | |
| 104: | public function reconfigurables(string $hostname, string $path = ''): array |
| 105: | { |
| 106: | return parent::reconfigurables($hostname, $path); |
| 107: | } |
| 108: | |
| 109: | |
| 110: | |
| 111: | |
| 112: | |
| 113: | |
| 114: | |
| 115: | public function get_configuration(string $hostname, string $path, $fields): array |
| 116: | { |
| 117: | if (!IS_CLI) { |
| 118: | return $this->query('discourse_get_configuration', $hostname, $path, $fields); |
| 119: | } |
| 120: | $config = $this->getAppRoot($hostname, $path) . '/config/discourse.conf'; |
| 121: | $stat = $this->file_stat($config); |
| 122: | |
| 123: | if (!$stat['can_read']) { |
| 124: | error("Path %(path)s unreadable", ['path' => $config]); |
| 125: | return []; |
| 126: | } |
| 127: | |
| 128: | $map = \Opcenter\Map::read($this->domain_fs_path($config), 'inifile')->section(null); |
| 129: | $values = []; |
| 130: | foreach ((array)$fields as $k) { |
| 131: | $values[$k] = $map->fetch($k); |
| 132: | } |
| 133: | if (\count($values) === 1) { |
| 134: | return array_pop($values); |
| 135: | } |
| 136: | |
| 137: | return $values; |
| 138: | } |
| 139: | |
| 140: | |
| 141: | |
| 142: | |
| 143: | |
| 144: | |
| 145: | |
| 146: | |
| 147: | protected function getAppRoot(string $hostname, string $path = ''): ?string |
| 148: | { |
| 149: | return $this->getAppRootReal($hostname, $path); |
| 150: | } |
| 151: | |
| 152: | |
| 153: | |
| 154: | |
| 155: | |
| 156: | |
| 157: | |
| 158: | |
| 159: | |
| 160: | |
| 161: | |
| 162: | public function install(string $hostname, string $path = '', array $opts = array()): bool |
| 163: | { |
| 164: | if (posix_geteuid() && !IS_CLI) { |
| 165: | return $this->query('discourse_install', $hostname, $path, $opts); |
| 166: | } |
| 167: | |
| 168: | if (!$this->pgsql_enabled()) { |
| 169: | return error('%(what)s must be enabled to install %(app)s', ['what' => 'PostgreSQL', 'app' => static::APP_NAME]); |
| 170: | } |
| 171: | if (!SSH_USER_DAEMONS) { |
| 172: | return error('[ssh] => user_daemons must be set to true in config.ini'); |
| 173: | } |
| 174: | $available = null; |
| 175: | if (!$this->hasMemoryAllowance(1536, $available)) { |
| 176: | return error("Discourse requires at least 1.5 GB memory, `%s' MB provided for account", $available); |
| 177: | } |
| 178: | if (!$this->hasStorageAllowance(2048, $available)) { |
| 179: | return error('Discourse requires ~2 GB storage. Only %.2f MB free.', $available); |
| 180: | } |
| 181: | |
| 182: | if ($this->getServiceValue('cgroup', 'enabled') && ($limit = $this->getServiceValue('cgroup', |
| 183: | 'proclimit') ?: 100) < 100) { |
| 184: | return error("Resource limits enforced. proclimit `%d' is below minimum value 100. Change via cgroup,proclimit", |
| 185: | $limit); |
| 186: | } |
| 187: | |
| 188: | if (!$this->crontab_permitted()) { |
| 189: | return error('%(app)s requires %(service)s service to be enabled', [ |
| 190: | 'app' => self::APP_NAME, 'service' => 'crontab' |
| 191: | ]); |
| 192: | } |
| 193: | |
| 194: | if (!$this->crontab_enabled() && !$this->crontab_start()) { |
| 195: | return error('Failed to enable task scheduling'); |
| 196: | } |
| 197: | |
| 198: | if (!empty($opts['maxmind']) && !preg_match('/^[a-zA-Z0-9_]{4,}$/', $opts['maxmind'])) { |
| 199: | return error('A MaxMind GeoLite2 key is required.'); |
| 200: | } |
| 201: | |
| 202: | if (!isset($opts['mode'])) { |
| 203: | $opts['mode'] = 'apache'; |
| 204: | } |
| 205: | |
| 206: | if ($opts['mode'] !== 'standalone' && $opts['mode'] !== 'nginx' && $opts['mode'] !== 'apache') { |
| 207: | return error("Unknown Discourse mode `%s'", $opts['mode']); |
| 208: | } |
| 209: | |
| 210: | |
| 211: | |
| 212: | |
| 213: | |
| 214: | |
| 215: | |
| 216: | if ($path) { |
| 217: | return error('Discourse may only be installed directly on a subdomain or domain without a child path, e.g. https://discourse.domain.com but not https://domain.com/discourse'); |
| 218: | } |
| 219: | |
| 220: | if (!($docroot = $this->getDocumentRoot($hostname, $path))) { |
| 221: | return error("failed to normalize path for `%s'", $hostname); |
| 222: | } |
| 223: | |
| 224: | if (!$this->parseInstallOptions($opts, $hostname, $path)) { |
| 225: | return false; |
| 226: | } |
| 227: | |
| 228: | $rubyVersion = \Opcenter\Versioning::satisfy($opts['version'], self::MINIMUM_INTERPRETERS); |
| 229: | if (!($rubyVersion = $this->validateRuby($rubyVersion, $opts['user'] ?? null))) { |
| 230: | return false; |
| 231: | } |
| 232: | |
| 233: | $args['version'] = $opts['version']; |
| 234: | $db = \Module\Support\Webapps\DatabaseGenerator::pgsql($this->getAuthContext(), $hostname); |
| 235: | $db->connectionLimit = max($db->connectionLimit, 15); |
| 236: | |
| 237: | if (!$db->create()) { |
| 238: | return false; |
| 239: | } |
| 240: | |
| 241: | $context = null; |
| 242: | $wrapper = $this->getApnscpFunctionInterceptorFromDocroot($docroot, $context); |
| 243: | $oldex = \Error_Reporter::exception_upgrade(); |
| 244: | try { |
| 245: | $wrapper->git_clone(static::DISCOURSE_REPO, $docroot, |
| 246: | [ |
| 247: | 'recursive' => null, |
| 248: | 'depth' => 1, |
| 249: | 'branch' => 'v' . $opts['version'] |
| 250: | ]); |
| 251: | $this->ruby_make_default($rubyVersion, $docroot); |
| 252: | $bundler = 'bundler:"< 2"'; |
| 253: | if (version_compare($args['version'], '2.3.8', '>=')) { |
| 254: | $bundler = 'bundler:"~> 2.2"'; |
| 255: | if (version_compare($args['version'], '3.1.0', '<')) { |
| 256: | $bundler = 'bundler:"<= 2.4.22"'; |
| 257: | } |
| 258: | } |
| 259: | $wrapper->ruby_do($rubyVersion, $docroot, 'gem install -E --no-document passenger ' . $bundler); |
| 260: | |
| 261: | $bundleFlags = '--deployment --without test development'; |
| 262: | |
| 263: | if (version_compare($args['version'], '2.5.0', '>=')) { |
| 264: | $wrapper->ruby_do($rubyVersion, $docroot, 'bundle config set deployment true'); |
| 265: | $wrapper->ruby_do($rubyVersion, $docroot, 'bundle config set without "test development"'); |
| 266: | $bundleFlags = ''; |
| 267: | } |
| 268: | |
| 269: | if (version_compare($args['version'], '2.8.10', '>=') && version_compare($rubyVersion, '3.1.3', '<')) { |
| 270: | $wrapper->ruby_do($rubyVersion, $docroot, 'gem update --system 3.2.28 --no-doc'); |
| 271: | } |
| 272: | |
| 273: | $wrapper->ruby_do('', $docroot, 'bundle install ' . $bundleFlags . ' -j' . max(4, (int)NPROC + 1)); |
| 274: | |
| 275: | $wrapper->file_put_file_contents($wrapper->user_get_home() . '/.rbenv-usergems/' . $rubyVersion . '/bin/renice', |
| 276: | "#!/bin/sh\nexec /bin/true"); |
| 277: | $wrapper->file_chmod($wrapper->user_get_home() . '/.rbenv-usergems/' . $rubyVersion . '/bin/renice', 755); |
| 278: | $this->applyPatches($wrapper, $docroot, $args['version']); |
| 279: | $extensions = ['pg_trgm', 'hstore']; |
| 280: | if (version_compare($args['version'], '3.0', '>=')) { |
| 281: | $extensions[] = 'unaccent'; |
| 282: | } |
| 283: | foreach ($extensions as $extension) { |
| 284: | $this->pgsql_add_extension($db->database, $extension); |
| 285: | } |
| 286: | if (!$wrapper->crontab_user_permitted($opts['user'] ?? $this->username)) { |
| 287: | if (!$this->crontab_permit_user($opts['user'] ?? $this->username)) { |
| 288: | return error("failed to enable task scheduling for `%s'", $opts['user'] ?? $this->username); |
| 289: | } |
| 290: | warn("Task scheduling enabled for user `%s'", $opts['user'] ?? $this->username); |
| 291: | } |
| 292: | } catch (\apnscpException $e) { |
| 293: | if (array_get($opts, 'hold')) { |
| 294: | return false; |
| 295: | } |
| 296: | info('removing temporary files'); |
| 297: | $this->file_delete($docroot, true); |
| 298: | $db->rollback(); |
| 299: | return error('failed to install Discourse %s: %s', $args['version'], $e->getMessage()); |
| 300: | } finally { |
| 301: | \Error_Reporter::exception_upgrade($oldex); |
| 302: | } |
| 303: | |
| 304: | $opts['url'] = rtrim($hostname . '/' . $path, '/'); |
| 305: | |
| 306: | if (null === ($docroot = $this->remapPublic($hostname, $path))) { |
| 307: | |
| 308: | return error("Failed to remap Discourse to public/, manually remap from `%s' - Discourse setup is incomplete!", |
| 309: | $docroot); |
| 310: | } |
| 311: | |
| 312: | $docroot = $this->getDocumentRoot($hostname, $path); |
| 313: | $approot = $this->getAppRoot($hostname, $path); |
| 314: | |
| 315: | $config = $approot . '/config/discourse.conf'; |
| 316: | $wrapper->file_copy($approot . '/config/discourse_defaults.conf', $config); |
| 317: | |
| 318: | $configurables = [ |
| 319: | 'db_name' => $db->database, |
| 320: | 'db_username' => $db->username, |
| 321: | 'db_password' => $db->password, |
| 322: | 'hostname' => $hostname, |
| 323: | 'db_host' => $db->hostname, |
| 324: | 'developer_emails' => $opts['email'], |
| 325: | 'load_mini_profiler' => false |
| 326: | ]; |
| 327: | |
| 328: | if (!empty($opts['maxmind'])) { |
| 329: | $configurables['maxmind_license_key'] = $opts['maxmind']; |
| 330: | } |
| 331: | |
| 332: | |
| 333: | $this->set_configuration($hostname, $path, $configurables); |
| 334: | |
| 335: | if (version_compare($args['version'], '3.0.0', '>=')) { |
| 336: | $this->createMailUser($hostname, $path); |
| 337: | } |
| 338: | |
| 339: | $redispass = \Opcenter\Auth\Password::generate(32); |
| 340: | if ($wrapper->redis_exists($this->domain)) { |
| 341: | warn("Existing Redis profile named `%s' found - removing", $this->domain); |
| 342: | $wrapper->redis_delete($this->domain); |
| 343: | } |
| 344: | $wrapper->redis_create($this->domain, ['requirepass' => $redispass]); |
| 345: | $redisconfig = $wrapper->redis_config($this->domain); |
| 346: | |
| 347: | $vars = [ |
| 348: | 'redis_port' => $redisconfig['port'], |
| 349: | 'redis_host' => '127.0.0.1', |
| 350: | 'redis_password' => $redisconfig['requirepass'], |
| 351: | 'db_pool' => 7 |
| 352: | ]; |
| 353: | $this->set_configuration($hostname, $path, $vars); |
| 354: | |
| 355: | |
| 356: | |
| 357: | |
| 358: | $exold = \Error_Reporter::exception_upgrade(); |
| 359: | try { |
| 360: | $nodeVersion = $this->validateNode((string)$opts['version'], $wrapper); |
| 361: | $this->node_make_default($nodeVersion, $approot); |
| 362: | $this->assetsCompile($hostname, $path, 'production'); |
| 363: | $this->migrate($approot); |
| 364: | if (version_compare($opts['version'], '2.4.0', '<')) { |
| 365: | $this->launchSidekiq($approot, 'production'); |
| 366: | |
| 367: | $passenger = Passenger::instantiateContexted($context, [$approot, 'ruby']); |
| 368: | $passenger->createLayout(); |
| 369: | $passenger->setEngine('standalone'); |
| 370: | |
| 371: | $passenger->setProcessConcurrency(0); |
| 372: | $passenger->setMaxPoolSize(3); |
| 373: | $passenger->setMinInstances(3); |
| 374: | $passenger->setEnvironment([ |
| 375: | 'RUBY_GLOBAL_METHOD_CACHE_SIZE' => 131072, |
| 376: | 'LD_PRELOAD' => '/usr/lib64/libjemalloc.so.1', |
| 377: | 'RUBY_GC_HEAP_GROWTH_MAX_SLOTS' => 40000, |
| 378: | 'RUBY_GC_HEAP_INIT_SLOTS' => 400000, |
| 379: | 'RUBY_GC_HEAP_OLDOBJECT_LIMIT_FACTOR' => 1.5 |
| 380: | ]); |
| 381: | $this->file_put_file_contents($approot . '/Passengerfile.json', |
| 382: | $passenger->getExecutableConfiguration()); |
| 383: | $passenger->start(); |
| 384: | } else { |
| 385: | $handler = Launcher::instantiateContexted($context, |
| 386: | [$approot]); |
| 387: | $handler->create(Port::firstFree($this->getAuthContext())); |
| 388: | } |
| 389: | } catch (\apnscpException $e) { |
| 390: | dlog($e->getTraceAsString()); |
| 391: | |
| 392: | return error('Error encountered during housekeeping. Discourse may be incomplete: %s', |
| 393: | $e->getMessage()); |
| 394: | } finally { |
| 395: | \Error_Reporter::exception_upgrade($exold); |
| 396: | } |
| 397: | |
| 398: | if (version_compare($opts['version'], '2.4.0', '>=')) { |
| 399: | $launcher = Launcher::instantiateContexted($context, [$approot]); |
| 400: | $launcher->start(); |
| 401: | $command = $launcher->getCommand(); |
| 402: | $rules = 'RewriteEngine On' . "\n" . |
| 403: | 'RewriteCond %{REQUEST_FILENAME} !-f' . "\n" . |
| 404: | 'RewriteRule ^(.*)$ http://localhost:' . $launcher->getPort() . '/$1 [P,L,QSA]' . "\n"; |
| 405: | } else { |
| 406: | $command = $passenger->getExecutable(); |
| 407: | $this->pman_run($command); |
| 408: | $rules = $passenger->getDirectives(); |
| 409: | } |
| 410: | if (!isset($passenger) || $passenger->getEngine() !== 'apache') { |
| 411: | $args = [ |
| 412: | '@reboot', |
| 413: | null, |
| 414: | null, |
| 415: | null, |
| 416: | null, |
| 417: | $command |
| 418: | ]; |
| 419: | if (!($wrapper->crontab_exists(...$args) || $wrapper->crontab_add_job(...$args))) { |
| 420: | warn('Failed to create job to start Discourse on boot. Command: %s', $command); |
| 421: | } |
| 422: | } |
| 423: | |
| 424: | |
| 425: | if (!empty($opts['ssl'])) { |
| 426: | $rules = 'RequestHeader set X-Forwarded-Proto expr=%{REQUEST_SCHEME}' . "\n" . |
| 427: | $rules; |
| 428: | } |
| 429: | if (!$this->file_put_file_contents($approot . '/public/.htaccess', |
| 430: | '# Enable caching' . "\n" . |
| 431: | 'UnsetEnv no-cache' . "\n" . |
| 432: | |
| 433: | |
| 434: | 'DirectoryIndex disabled' . "\n" . |
| 435: | $rules |
| 436: | )) { |
| 437: | return error('failed to create .htaccess control - Discourse is not properly setup'); |
| 438: | } |
| 439: | $this->notifyInstalled($hostname, $path, $opts); |
| 440: | |
| 441: | return info('%(app)s installed - confirmation email with login info sent to %(email)s', |
| 442: | ['app' => static::APP_NAME, 'email' => $opts['email']]); |
| 443: | } |
| 444: | |
| 445: | |
| 446: | |
| 447: | |
| 448: | |
| 449: | |
| 450: | |
| 451: | |
| 452: | |
| 453: | |
| 454: | private function createMailUser(string $hostname, string $path = ''): void |
| 455: | { |
| 456: | if (version_compare($this->get_version($hostname, $path), '3.0.0', '<')) { |
| 457: | return; |
| 458: | } |
| 459: | if (!$this->email_enabled()) { |
| 460: | warn("Mail disabled on account. Manual SMTP configuration required to config/discourse.conf"); |
| 461: | return; |
| 462: | } |
| 463: | |
| 464: | $cfg = $this->get_configuration($hostname, $path, ['smtp_user_name', 'smtp_address']); |
| 465: | if (array_get($cfg, 'smtp_address') && $cfg['smtp_user_name']) { |
| 466: | return; |
| 467: | } |
| 468: | |
| 469: | $user = 'discourse-' . \Opcenter\Auth\Password::generate(8, 'a-z'); |
| 470: | $password = \Opcenter\Auth\Password::generate(16); |
| 471: | if (!$this->user_add($user, $password, 'Discourse email user - ' . $hostname, 0, [ |
| 472: | 'smtp' => true, |
| 473: | 'cp' => false, |
| 474: | 'ssh' => false, |
| 475: | 'ftp' => false, |
| 476: | 'imap' => false |
| 477: | ])) |
| 478: | { |
| 479: | warn("Failed to create SMTP user for Discourse. Manual configuration of SMTP required"); |
| 480: | return; |
| 481: | } |
| 482: | $this->set_configuration($hostname, $path, [ |
| 483: | 'smtp_user_name' => "$user@$hostname", |
| 484: | 'smtp_password' => $password, |
| 485: | 'smtp_address' => 'localhost', |
| 486: | 'smtp_port' => 587, |
| 487: | 'smtp_enable_start_tls' => 'false', |
| 488: | ]); |
| 489: | |
| 490: | |
| 491: | } |
| 492: | |
| 493: | private function deleteMailUser(string $hostname, string $path = ''): void |
| 494: | { |
| 495: | $cfg = $this->get_configuration($hostname, $path, ['smtp_user_name', 'smtp_address']); |
| 496: | if (array_get($cfg, 'smtp_address') !== 'localhost' || !str_contains($cfg['smtp_user_name'], "@$hostname")) { |
| 497: | return; |
| 498: | } |
| 499: | |
| 500: | $user = strtok($cfg['smtp_user_name'], '@'); |
| 501: | if (!($pwd = $this->user_getpwnam($user)) || !str_starts_with($pwd['gecos'], "Discourse email user")) { |
| 502: | return; |
| 503: | } |
| 504: | $this->user_delete($user, true); |
| 505: | } |
| 506: | |
| 507: | |
| 508: | |
| 509: | |
| 510: | |
| 511: | |
| 512: | |
| 513: | protected function checkVersion(array &$options): bool |
| 514: | { |
| 515: | if (!parent::checkVersion($options)) { |
| 516: | return false; |
| 517: | } |
| 518: | $version = array_get($options, 'version'); |
| 519: | |
| 520: | |
| 521: | $redisVersion = $this->redis_version(); |
| 522: | foreach(['2.4.0' => '4.0.0', '3.0.0' => '6.2.0'] as $discourseVersion => $redisReq) { |
| 523: | if (version_compare($version, $discourseVersion, '<')) { |
| 524: | return true; |
| 525: | } |
| 526: | |
| 527: | if (version_compare($redisVersion, $redisReq, '<')) { |
| 528: | return error('%(app)s %(version)s+ requires %(pkgname)s %(pkgver)s+. ' . |
| 529: | '%(pkgname)s %(pkginstver)s installed in FST', [ |
| 530: | 'app' => self::APP_NAME, |
| 531: | 'version' => $version, |
| 532: | 'pkgname' => 'Redis', |
| 533: | 'pkgver' => $redisReq, |
| 534: | 'pkginstver' => $redisVersion |
| 535: | ]); |
| 536: | } |
| 537: | } |
| 538: | |
| 539: | return true; |
| 540: | } |
| 541: | |
| 542: | |
| 543: | |
| 544: | |
| 545: | |
| 546: | |
| 547: | |
| 548: | |
| 549: | protected function validateRuby(string $version = 'lts', string $user = null): ?string |
| 550: | { |
| 551: | debug("Validating Ruby %s installed", $version); |
| 552: | if ($user) { |
| 553: | $afi = \apnscpFunctionInterceptor::factory(Auth::context($user, $this->site)); |
| 554: | } |
| 555: | $wrapper = $afi ?? $this; |
| 556: | |
| 557: | |
| 558: | if (!$exists = $wrapper->ruby_installed($version, '>=')) { |
| 559: | if (!$version = $wrapper->ruby_install(\Opcenter\Versioning::asMinor($version))) { |
| 560: | error('failed to install Ruby %s', $version); |
| 561: | return null; |
| 562: | } |
| 563: | } else { |
| 564: | debug("Ruby %(found)s satisfies request %(wanted)s", ['found' => $exists, 'wanted' => $version]); |
| 565: | |
| 566: | $version = $exists; |
| 567: | } |
| 568: | |
| 569: | $ret = $wrapper->ruby_do($version, null, 'gem install --no-document -E passenger'); |
| 570: | if (!$ret['success']) { |
| 571: | error('failed to install Passenger gem: %s', $ret['stderr'] ?? 'UNKNOWN ERROR'); |
| 572: | return null; |
| 573: | } |
| 574: | $home = $this->user_get_home($user); |
| 575: | $stat = $this->file_stat($home); |
| 576: | if (!$stat || !$this->file_chmod($home, decoct($stat['permissions']) | 0001)) { |
| 577: | error("failed to query user home directory `%s' for user `%s'", $home, $user); |
| 578: | return null; |
| 579: | } |
| 580: | |
| 581: | return $version; |
| 582: | } |
| 583: | |
| 584: | |
| 585: | |
| 586: | |
| 587: | |
| 588: | |
| 589: | |
| 590: | |
| 591: | public function get_version(string $hostname, string $path = ''): ?string |
| 592: | { |
| 593: | if (!$this->valid($hostname, $path)) { |
| 594: | return null; |
| 595: | } |
| 596: | $approot = $this->getAppRoot($hostname, $path); |
| 597: | $wrapper = $this->getApnscpFunctionInterceptorFromDocroot($approot); |
| 598: | $ret = $wrapper->ruby_do(null, $approot, |
| 599: | 'ruby -e \'require "./%(path)s" ; puts Discourse::VERSION::STRING;\'', |
| 600: | ['path' => 'lib/version.rb'] |
| 601: | ); |
| 602: | |
| 603: | return $ret['success'] ? trim($ret['output']) : null; |
| 604: | } |
| 605: | |
| 606: | |
| 607: | |
| 608: | |
| 609: | |
| 610: | |
| 611: | |
| 612: | |
| 613: | public function valid(string $hostname, string $path = ''): bool |
| 614: | { |
| 615: | if (0 === strncmp($hostname, '/', 1)) { |
| 616: | if (!($path = realpath($this->domain_fs_path($hostname)))) { |
| 617: | return false; |
| 618: | } |
| 619: | $approot = \dirname($path); |
| 620: | } else { |
| 621: | $approot = $this->getAppRoot($hostname, $path); |
| 622: | if (!$approot) { |
| 623: | return false; |
| 624: | } |
| 625: | $approot = $this->domain_fs_path($approot); |
| 626: | } |
| 627: | |
| 628: | return file_exists($approot . '/lib/discourse.rb'); |
| 629: | } |
| 630: | |
| 631: | public function set_configuration(string $hostname, string $path, array $params = []) |
| 632: | { |
| 633: | if (!IS_CLI) { |
| 634: | return $this->query('discourse_set_configuration', $hostname, $path, $params); |
| 635: | } |
| 636: | $config = $this->getAppRoot($hostname, $path) . '/config/discourse.conf'; |
| 637: | $stat = $this->file_stat($config); |
| 638: | if ($stat && !$stat['can_write']) { |
| 639: | return error("Path %(path)s unreadable", ['path' => $config]); |
| 640: | } |
| 641: | |
| 642: | $ini = \Opcenter\Map::load($this->domain_fs_path($config), 'wd', 'inifile')->section(null); |
| 643: | clearstatcache(true, $this->domain_fs_path($config)); |
| 644: | if (!str_starts_with(realpath($this->domain_fs_path($config)), $this->domain_fs_path('/'))) { |
| 645: | $ini->close(); |
| 646: | fatal("Unsafe path"); |
| 647: | } |
| 648: | |
| 649: | foreach ($params as $k => $v) { |
| 650: | $ini[$k] = $v; |
| 651: | } |
| 652: | |
| 653: | return $ini->save(); |
| 654: | } |
| 655: | |
| 656: | |
| 657: | |
| 658: | |
| 659: | |
| 660: | |
| 661: | |
| 662: | |
| 663: | |
| 664: | private function applyPatches(\apnscpFunctionInterceptor $wrapper, string $approot, string $version): void |
| 665: | { |
| 666: | if (version_compare('2.5.0', $version, '>')) { |
| 667: | return; |
| 668: | } |
| 669: | |
| 670: | $patch = '/0001-Rack-Lint-InputWrapper-lacks-size-method.patch'; |
| 671: | if (version_compare('3.0.0', $version, '<=')) { |
| 672: | $patch = '/0001-Rack-Lint-InputWrapper-lacks-size-method-3.0.patch'; |
| 673: | } else if (version_compare('2.8.0', $version, '<=')) { |
| 674: | $patch = '/0001-Rack-Lint-InputWrapper-lacks-size-method-2.8.patch'; |
| 675: | } |
| 676: | $path = PathManager::storehouse('discourse') . $patch; |
| 677: | $wrapper->file_put_file_contents($approot . '/0001.patch', file_get_contents($path)); |
| 678: | $ret = $wrapper->pman_run('cd %s && (git apply 0001.patch ; rm -f 0001.patch)', [$approot]); |
| 679: | |
| 680: | if (!$ret['success']) { |
| 681: | warn("Failed to apply Rack input patch: %s", $ret['stderr']); |
| 682: | } |
| 683: | } |
| 684: | |
| 685: | |
| 686: | |
| 687: | |
| 688: | |
| 689: | |
| 690: | |
| 691: | |
| 692: | private function migrate(string $approot, string $appenv = 'production'): bool |
| 693: | { |
| 694: | return $this->rake($approot, 'db:migrate', ['RAILS_ENV' => $appenv]); |
| 695: | } |
| 696: | |
| 697: | private function rake(string $approot, string $task, array $env): bool |
| 698: | { |
| 699: | |
| 700: | |
| 701: | |
| 702: | |
| 703: | $ret = $this->_exec( |
| 704: | $approot, |
| 705: | "ulimit -v unlimited ; nvm exec /bin/bash -ic 'rbenv exec bundle exec rake -j" . min(4, (int)NPROC + 1) . " $task'", |
| 706: | [ |
| 707: | [], |
| 708: | $env |
| 709: | ], |
| 710: | ); |
| 711: | |
| 712: | return $ret['success'] ?: error("failed Rake task `%s': %s", $task, |
| 713: | coalesce($ret['stderr'], $ret['stdout'])); |
| 714: | } |
| 715: | |
| 716: | private function _exec(?string $path, $cmd, array $args = array()) |
| 717: | { |
| 718: | |
| 719: | if (!is_array($args)) { |
| 720: | $args = func_get_args(); |
| 721: | array_shift($args); |
| 722: | } |
| 723: | |
| 724: | $baseArgs = [ |
| 725: | 0 => [], |
| 726: | 1 => ['RAILS_ENV' => 'production'], |
| 727: | 2 => [] |
| 728: | ]; |
| 729: | $args = array_key_map(static function ($k, $v) use ($args) { |
| 730: | return ($args[$k] ?? []) + $v; |
| 731: | }, $baseArgs); |
| 732: | |
| 733: | $user = $this->username; |
| 734: | if ($path) { |
| 735: | $cmd = 'cd %(path)s && /bin/bash -ic -- ' . escapeshellarg($cmd); |
| 736: | $args[0]['path'] = $path; |
| 737: | $user = $this->file_stat($path)['owner'] ?? $this->username; |
| 738: | } |
| 739: | $args[2]['user'] = $user; |
| 740: | $ret = $this->pman_run($cmd, ...$args); |
| 741: | if (!strncmp(coalesce($ret['stderr'], $ret['stdout']), 'Error:', strlen('Error:'))) { |
| 742: | |
| 743: | $ret['success'] = false; |
| 744: | if (!$ret['stderr']) { |
| 745: | $ret['stderr'] = $ret['stdout']; |
| 746: | } |
| 747: | |
| 748: | } |
| 749: | |
| 750: | return $ret; |
| 751: | } |
| 752: | |
| 753: | |
| 754: | |
| 755: | |
| 756: | |
| 757: | |
| 758: | |
| 759: | |
| 760: | protected function launchSidekiq(string $approot, string $mode = 'production'): bool |
| 761: | { |
| 762: | if ($this->sidekiqRunning($approot)) { |
| 763: | return true; |
| 764: | } |
| 765: | $job = [ |
| 766: | '@reboot', |
| 767: | null, |
| 768: | null, |
| 769: | null, |
| 770: | null, |
| 771: | '/bin/bash -ic ' . |
| 772: | escapeshellarg($this->getSidekiqJob($approot, 'production')) |
| 773: | ]; |
| 774: | if (!$this->crontab_exists(...$job)) { |
| 775: | $this->crontab_add_job(...$job); |
| 776: | } |
| 777: | $ret = $this->_exec( |
| 778: | $approot, |
| 779: | $this->getSidekiqCommand($approot), |
| 780: | [ |
| 781: | [ |
| 782: | 'approot' => $approot |
| 783: | ], |
| 784: | [ |
| 785: | 'RAILS_ENV' => $mode |
| 786: | ] |
| 787: | ] |
| 788: | ); |
| 789: | |
| 790: | return $ret['success'] ?: error('Failed to launch Sidekiq, check log/sidekiq.log'); |
| 791: | } |
| 792: | |
| 793: | protected function sidekiqRunning(string $approot): ?int |
| 794: | { |
| 795: | $pidfile = $approot . '/tmp/sidekiq.pid'; |
| 796: | if (!$this->file_exists($pidfile)) { |
| 797: | return null; |
| 798: | } |
| 799: | |
| 800: | $pid = (int)$this->file_get_file_contents($pidfile); |
| 801: | |
| 802: | return \Opcenter\Process::pidMatches($pid, 'ruby') ? $pid : null; |
| 803: | } |
| 804: | |
| 805: | |
| 806: | |
| 807: | |
| 808: | |
| 809: | |
| 810: | |
| 811: | |
| 812: | private function getSidekiqJob(string $approot, $env = 'production') |
| 813: | { |
| 814: | return 'cd ' . $approot . ' && env RAILS_ENV=production ' . $this->getSidekiqCommand($approot); |
| 815: | } |
| 816: | |
| 817: | |
| 818: | |
| 819: | |
| 820: | |
| 821: | |
| 822: | |
| 823: | private function getSidekiqCommand(string $approot) |
| 824: | { |
| 825: | return 'bundle exec sidekiq -L log/sidekiq.log -P tmp/sidekiq.pid -q critical -q low -q default -d -c5'; |
| 826: | } |
| 827: | |
| 828: | |
| 829: | |
| 830: | |
| 831: | |
| 832: | |
| 833: | |
| 834: | |
| 835: | |
| 836: | |
| 837: | private function assetsCompile(string $hostname, string $path = '', string $appenv = 'production'): bool |
| 838: | { |
| 839: | $approot = $this->getAppRoot($hostname, $path); |
| 840: | $wrapper = $this->getApnscpFunctionInterceptorFromDocroot($approot); |
| 841: | |
| 842: | $discourseVersion = $this->get_version($hostname, $path); |
| 843: | if (null === $discourseVersion) { |
| 844: | return error("Failed to discover Discourse version in `%s'/`%s'", $hostname, $path); |
| 845: | } |
| 846: | $nodeVersion = $this->validateNode($discourseVersion, $wrapper); |
| 847: | $wrapper->node_make_default($nodeVersion, $approot); |
| 848: | |
| 849: | |
| 850: | $packages = ['yarn']; |
| 851: | if (version_compare($discourseVersion, '2.6', '>=')) { |
| 852: | $packages = array_merge($packages, ['terser', 'uglify-js']); |
| 853: | } else { |
| 854: | $packages = array_merge($packages, ['uglify-js@2']); |
| 855: | } |
| 856: | $ret = $wrapper->node_do($nodeVersion, null, 'npm install --no-save -g ' . implode(' ', $packages)); |
| 857: | if (!$ret['success']) { |
| 858: | return error('Failed to install preliminary packages: %s', $ret['error']); |
| 859: | } |
| 860: | |
| 861: | $ret = $this->_exec($approot, 'nvm exec ' . $nodeVersion . ' yarn install'); |
| 862: | |
| 863: | if (!$ret['success']) { |
| 864: | return error('Failed to install packages: %s', $ret['error']); |
| 865: | } |
| 866: | if (version_compare($discourseVersion, '3.1.0', '<')) { |
| 867: | $this->fixupMaxMind($wrapper, $approot); |
| 868: | } |
| 869: | $env = [ |
| 870: | 'RAILS_ENV' => $appenv, |
| 871: | 'NODE_VERSION' => $nodeVersion |
| 872: | ]; |
| 873: | return $this->rake($approot, 'assets:clean', $env) && $this->rake($approot, 'assets:precompile', $env); |
| 874: | } |
| 875: | |
| 876: | |
| 877: | |
| 878: | |
| 879: | |
| 880: | |
| 881: | |
| 882: | |
| 883: | private function validateNode(string $version, \apnscpFunctionInterceptor $wrapper): string |
| 884: | { |
| 885: | $nodeVersion = \Opcenter\Versioning::satisfy($version, self::NODE_VERSIONS); |
| 886: | debug("Validating Node %s installed", $nodeVersion); |
| 887: | if (!$wrapper->node_installed($nodeVersion)) { |
| 888: | $wrapper->node_install($nodeVersion); |
| 889: | } |
| 890: | |
| 891: | return $nodeVersion; |
| 892: | } |
| 893: | |
| 894: | |
| 895: | |
| 896: | |
| 897: | |
| 898: | |
| 899: | |
| 900: | |
| 901: | |
| 902: | |
| 903: | private function fixupMaxMind(apnscpFunctionInterceptor $wrapper, string $approot): bool |
| 904: | { |
| 905: | $path = "{$approot}/lib/discourse_ip_info.rb"; |
| 906: | $template = file_get_contents(resource_path('storehouse/discourse/discourse_ip_info.rb')); |
| 907: | return $wrapper->file_put_file_contents($path, $template); |
| 908: | } |
| 909: | |
| 910: | public function build() |
| 911: | { |
| 912: | if (!is_debug()) { |
| 913: | return true; |
| 914: | } |
| 915: | $approot = $this->getAppRoot($this->domain, ''); |
| 916: | $docroot = $this->getDocumentRoot($this->domain, ''); |
| 917: | $context = null; |
| 918: | |
| 919: | $wrapper = $this->getApnscpFunctionInterceptorFromDocroot($docroot, $context); |
| 920: | $passenger = Passenger::instantiateContexted($context, [$approot, 'ruby']); |
| 921: | $passenger->createLayout(); |
| 922: | $passenger->setEngine('standalone'); |
| 923: | $command = $passenger->getExecutableConfiguration(); |
| 924: | |
| 925: | echo $command, "\n"; |
| 926: | dd($passenger->getExecutable(), $passenger->getDirectives()); |
| 927: | |
| 928: | |
| 929: | } |
| 930: | |
| 931: | public function restart(string $hostname, string $path = ''): bool |
| 932: | { |
| 933: | if (!$approot = $this->getAppRoot($hostname, $path)) { |
| 934: | return false; |
| 935: | } |
| 936: | $user = $this->getDocrootUser($approot); |
| 937: | $ctx = \Auth::context($user, $this->site); |
| 938: | $launcher = Launcher::instantiateContexted($ctx); |
| 939: | if ($launcher->exists()) { |
| 940: | return $launcher->restart(); |
| 941: | } |
| 942: | |
| 943: | return Passenger::instantiateContexted($ctx, |
| 944: | [$approot, 'ruby'])->restart(); |
| 945: | } |
| 946: | |
| 947: | |
| 948: | |
| 949: | |
| 950: | |
| 951: | |
| 952: | |
| 953: | |
| 954: | |
| 955: | |
| 956: | public function install_plugin( |
| 957: | string $hostname, |
| 958: | string $path, |
| 959: | string $plugin, |
| 960: | string $version = 'stable' |
| 961: | ): bool { |
| 962: | return error('not supported'); |
| 963: | } |
| 964: | |
| 965: | |
| 966: | |
| 967: | |
| 968: | |
| 969: | |
| 970: | |
| 971: | |
| 972: | |
| 973: | public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool |
| 974: | { |
| 975: | $approot = $this->getAppRoot($hostname, $path); |
| 976: | |
| 977: | $version = (string)$this->get_version($hostname, $path); |
| 978: | $wrapper = $this->getApnscpFunctionInterceptorFromDocroot($approot); |
| 979: | if ($wrapper !== $this->getApnscpFunctionInterceptor()) { |
| 980: | $wrapper->discourse_uninstall($hostname, $path, 'proc'); |
| 981: | } else if ($delete !== 'proc') { |
| 982: | $this->getApnscpFunctionInterceptor()->discourse_uninstall($hostname, $path, 'proc'); |
| 983: | } |
| 984: | if ($delete === 'proc') { |
| 985: | $this->kill($hostname, $path); |
| 986: | |
| 987: | if (version_compare($version, '2.4.0', '<')) { |
| 988: | $this->pman_run('cd %(approot)s && /bin/bash -ic %(cmd)s', |
| 989: | ['approot' => $approot, 'cmd' => 'rbenv exec passenger stop']); |
| 990: | } |
| 991: | |
| 992: | if ($this->redis_exists($hostname)) { |
| 993: | $this->redis_delete($hostname); |
| 994: | } |
| 995: | |
| 996: | $this->killSidekiq($approot); |
| 997: | foreach ($this->crontab_filter_by_command($approot) as $job) { |
| 998: | $this->crontab_delete_job( |
| 999: | $job['minute'], |
| 1000: | $job['hour'], |
| 1001: | $job['day_of_month'], |
| 1002: | $job['month'], |
| 1003: | $job['day_of_week'], |
| 1004: | $job['cmd'] |
| 1005: | ); |
| 1006: | } |
| 1007: | |
| 1008: | return true; |
| 1009: | } |
| 1010: | $this->deleteMailUser($hostname, $path); |
| 1011: | |
| 1012: | return parent::uninstall($hostname, $path, $delete); |
| 1013: | } |
| 1014: | |
| 1015: | protected function killSidekiq(string $approot): bool |
| 1016: | { |
| 1017: | if (null === ($pid = $this->sidekiqRunning($approot))) { |
| 1018: | return false; |
| 1019: | } |
| 1020: | |
| 1021: | return $this->pman_kill($pid); |
| 1022: | } |
| 1023: | |
| 1024: | |
| 1025: | |
| 1026: | |
| 1027: | |
| 1028: | |
| 1029: | |
| 1030: | |
| 1031: | public function is_current(string $version = null, string $branchcomp = null) |
| 1032: | { |
| 1033: | return parent::is_current($version, $branchcomp); |
| 1034: | } |
| 1035: | |
| 1036: | |
| 1037: | |
| 1038: | |
| 1039: | |
| 1040: | |
| 1041: | |
| 1042: | |
| 1043: | |
| 1044: | |
| 1045: | |
| 1046: | public function change_admin(string $hostname, string $path, array $fields): bool |
| 1047: | { |
| 1048: | if ( !IS_CLI) { |
| 1049: | return $this->query('discourse_change_admin', $hostname, $path, $fields); |
| 1050: | } |
| 1051: | |
| 1052: | $docroot = $this->getAppRoot($hostname, $path); |
| 1053: | if (!$docroot) { |
| 1054: | return warn('failed to change administrator information'); |
| 1055: | } |
| 1056: | |
| 1057: | $admin = $this->get_admin($hostname, $path); |
| 1058: | |
| 1059: | if (!$admin) { |
| 1060: | return error('cannot determine admin of Discourse install'); |
| 1061: | } |
| 1062: | |
| 1063: | if (isset($fields['password'])) { |
| 1064: | if (!\Opcenter\Auth\Password::strong($fields['password'])) { |
| 1065: | return false; |
| 1066: | } |
| 1067: | $config = Opcenter\Map::read($this->domain_fs_path($docroot . '/config/application.rb'), |
| 1068: | 'inifile')->section(null)->quoted(true); |
| 1069: | $itr = (int)($config['config.pbkdf2_iterations'] ?? 64000); |
| 1070: | $algo = $config['config.pbkdf2_algorithm'] ?? 'sha256'; |
| 1071: | $fields['salt'] = bin2hex(random_bytes(16)); |
| 1072: | $fields['password_hash'] = hash_pbkdf2($algo, $fields['password'], $fields['salt'], $itr); |
| 1073: | } |
| 1074: | |
| 1075: | if (isset($fields['username'])) { |
| 1076: | $fields['username_lower'] = strtolower($fields['username']); |
| 1077: | } |
| 1078: | if (isset($fields['name'])) { |
| 1079: | $fields['name'] = $fields['name']; |
| 1080: | } |
| 1081: | |
| 1082: | if (!$db = $this->connectDB($hostname, $path)) { |
| 1083: | return false; |
| 1084: | } |
| 1085: | |
| 1086: | if (!empty($fields['email'])) { |
| 1087: | if (!preg_match(Regex::EMAIL, $fields['email'])) { |
| 1088: | return error("Invalid email address `%s'", $fields['email']); |
| 1089: | } |
| 1090: | $db->query('UPDATE user_emails SET email = ' . $db->quote($fields['email']) . " WHERE user_id = 1 AND \"primary\" = 't'"); |
| 1091: | } |
| 1092: | $q = 'UPDATE users SET id = id'; |
| 1093: | foreach (['password_hash', 'salt', 'username', 'username_lower', 'name'] as $field) { |
| 1094: | if (!isset($fields[$field])) { |
| 1095: | continue; |
| 1096: | } |
| 1097: | $q .= ", {$field} = '" . $db->quote($fields[$field]) . "'"; |
| 1098: | } |
| 1099: | $q .= ' WHERE id = 1'; |
| 1100: | if (!$db->exec($q)) { |
| 1101: | return error("Failed to change admin user `%s'", $admin); |
| 1102: | } |
| 1103: | if (isset($fields['email'])) { |
| 1104: | info('user login changed to %s', $fields['email']); |
| 1105: | } |
| 1106: | if (isset($fields['password'])) { |
| 1107: | info("user `%s' password changed", $fields['email'] ?? $admin); |
| 1108: | } |
| 1109: | |
| 1110: | return true; |
| 1111: | } |
| 1112: | |
| 1113: | |
| 1114: | |
| 1115: | |
| 1116: | |
| 1117: | |
| 1118: | |
| 1119: | |
| 1120: | public function get_admin(string $hostname, string $path = ''): ?string |
| 1121: | { |
| 1122: | if (!$pgsql = $this->connectDB($hostname, $path)) { |
| 1123: | return null; |
| 1124: | } |
| 1125: | |
| 1126: | $rs = $pgsql->query('SELECT username FROM users WHERE id = 1'); |
| 1127: | if (!$rs || $rs->rowCount() < 1) { |
| 1128: | return null; |
| 1129: | } |
| 1130: | |
| 1131: | return $rs->fetchObject()->username; |
| 1132: | } |
| 1133: | |
| 1134: | |
| 1135: | |
| 1136: | |
| 1137: | |
| 1138: | |
| 1139: | |
| 1140: | |
| 1141: | |
| 1142: | public function update_all(string $hostname, string $path = '', string $version = null): bool |
| 1143: | { |
| 1144: | return $this->update($hostname, $path, $version) || error('failed to update all components'); |
| 1145: | } |
| 1146: | |
| 1147: | |
| 1148: | |
| 1149: | |
| 1150: | |
| 1151: | |
| 1152: | |
| 1153: | |
| 1154: | |
| 1155: | public function update(string $hostname, string $path = '', string $version = null): bool |
| 1156: | { |
| 1157: | $approot = $this->getAppRoot($hostname, $path); |
| 1158: | if (!$approot) { |
| 1159: | return error('update failed'); |
| 1160: | } |
| 1161: | |
| 1162: | $oldVersion = $this->get_version($hostname, $path); |
| 1163: | if (!$version) { |
| 1164: | $version = \Opcenter\Versioning::nextVersion($this->get_versions(), |
| 1165: | $oldVersion); |
| 1166: | } else if (!\Opcenter\Versioning::valid($version)) { |
| 1167: | return error('invalid version number, %s', $version); |
| 1168: | } |
| 1169: | |
| 1170: | if (!$this->git_valid($approot)) { |
| 1171: | parent::setInfo($this->getDocumentRoot($hostname, $path), [ |
| 1172: | 'failed' => true |
| 1173: | ]); |
| 1174: | |
| 1175: | return error('Cannot upgrade Discourse - not a valid git repository'); |
| 1176: | } |
| 1177: | |
| 1178: | if (version_compare($oldVersion, '3.0', '<') && version_compare($version, '3.0', '>=')) { |
| 1179: | $this->pgsql_add_extension($this->db_config($hostname, $path)['db'], 'unaccent'); |
| 1180: | } |
| 1181: | $wrapper = $this->getApnscpFunctionInterceptorFromDocroot($approot); |
| 1182: | $minimum = null; |
| 1183: | if (!$this->versionCheck($approot, $version, $minimum)) { |
| 1184: | parent::setInfo($this->getDocumentRoot($hostname, $path), [ |
| 1185: | 'failed' => true |
| 1186: | ]); |
| 1187: | |
| 1188: | return error("Configured Ruby version `%(found)s' does not meet minimum requirement `%(min)s' for Discourse v%(discourse_ver)s", [ |
| 1189: | 'found' => $wrapper->ruby_version_from_path($approot), |
| 1190: | 'min' => $minimum, |
| 1191: | 'discourse_ver' => $version |
| 1192: | ]); |
| 1193: | } |
| 1194: | |
| 1195: | $wrapper->git_fetch($approot); |
| 1196: | $wrapper->git_fetch($approot, ['tags' => null, 'force' => null]); |
| 1197: | if ($wrapper->file_exists($approot . '/lib/discourse_ip_info.rb')) { |
| 1198: | $wrapper->git_checkout($approot, null, ['lib/discourse_ip_info.rb']); |
| 1199: | } |
| 1200: | |
| 1201: | $ret = $wrapper->git_checkout($approot, "v{$version}"); |
| 1202: | $this->applyPatches($wrapper, $approot, $version); |
| 1203: | |
| 1204: | |
| 1205: | if (version_compare($version, '2.8.10', '>=') && version_compare($oldVersion, '2.8.10', '<')) { |
| 1206: | $wrapper->ruby_do(null, $approot, 'gem update --system --no-doc'); |
| 1207: | } |
| 1208: | |
| 1209: | if ($ret) { |
| 1210: | |
| 1211: | $wrapper->ruby_do('', $approot, 'bundle install -j' . min(4, (int)NPROC + 1)); |
| 1212: | $this->migrate($approot); |
| 1213: | $this->update_plugins($hostname, $path); |
| 1214: | if (!$this->assetsCompile($hostname, $path)) { |
| 1215: | warn('Failed to compile assets'); |
| 1216: | } |
| 1217: | } |
| 1218: | |
| 1219: | if (!\Opcenter\Versioning::compare($version, $newver = $this->get_version($hostname, $path))) { |
| 1220: | report("Upgrade failed, reported version `%s' is not requested version `%s'", $newver, $version); |
| 1221: | } |
| 1222: | parent::setInfo($this->getDocumentRoot($hostname, $path), [ |
| 1223: | 'version' => $version, |
| 1224: | 'failed' => !$ret |
| 1225: | ]); |
| 1226: | |
| 1227: | if (!$ret) { |
| 1228: | return error('failed to update Discourse'); |
| 1229: | } |
| 1230: | |
| 1231: | if (version_compare($version, '3.0.0', '>=')) { |
| 1232: | $this->createMailUser($hostname, $path); |
| 1233: | } |
| 1234: | |
| 1235: | return $this->restart($hostname, $path); |
| 1236: | } |
| 1237: | |
| 1238: | |
| 1239: | |
| 1240: | |
| 1241: | |
| 1242: | |
| 1243: | public function get_versions(): array |
| 1244: | { |
| 1245: | $versions = $this->_getVersions(); |
| 1246: | |
| 1247: | return array_column($versions, 'version'); |
| 1248: | } |
| 1249: | |
| 1250: | |
| 1251: | |
| 1252: | |
| 1253: | |
| 1254: | |
| 1255: | private function _getVersions(): array |
| 1256: | { |
| 1257: | $key = 'discourse.versions'; |
| 1258: | $cache = Cache_Super_Global::spawn(); |
| 1259: | if (false !== ($ver = $cache->get($key))) { |
| 1260: | return (array)$ver; |
| 1261: | } |
| 1262: | $versions = (new Github)->setMode('tags')->fetch('discourse/discourse'); |
| 1263: | $cache->set($key, $versions, 43200); |
| 1264: | |
| 1265: | return $versions; |
| 1266: | } |
| 1267: | |
| 1268: | |
| 1269: | |
| 1270: | |
| 1271: | |
| 1272: | |
| 1273: | |
| 1274: | |
| 1275: | |
| 1276: | private function versionCheck(string $approot, string $discourseVersion, &$minVersion = null): bool |
| 1277: | { |
| 1278: | $wrapper = $this->getApnscpFunctionInterceptorFromDocroot($approot); |
| 1279: | $version = $wrapper->ruby_version_from_path($approot); |
| 1280: | |
| 1281: | $minVersion = \Opcenter\Versioning::satisfy($discourseVersion, self::MINIMUM_INTERPRETERS); |
| 1282: | |
| 1283: | if (version_compare($version, $minVersion, '>=')) { |
| 1284: | return true; |
| 1285: | } |
| 1286: | |
| 1287: | |
| 1288: | foreach ($wrapper->ruby_list() as $version) { |
| 1289: | if (version_compare($version, $minVersion, '>=')) { |
| 1290: | info("Changed default Ruby interpreter to `%(version)s' on `%(path)s'", [ |
| 1291: | 'version' => $version, 'path' => $approot |
| 1292: | ]); |
| 1293: | $wrapper->ruby_make_default($version, $approot); |
| 1294: | return true; |
| 1295: | } |
| 1296: | } |
| 1297: | |
| 1298: | return false; |
| 1299: | } |
| 1300: | |
| 1301: | |
| 1302: | |
| 1303: | |
| 1304: | |
| 1305: | |
| 1306: | |
| 1307: | |
| 1308: | |
| 1309: | public function update_plugins(string $hostname, string $path = '', array $plugins = array()): bool |
| 1310: | { |
| 1311: | if (!$approot = $this->getAppRoot($hostname, $path)) { |
| 1312: | return false; |
| 1313: | } |
| 1314: | return $this->rake($approot, 'plugin:pull_compatible_all', ['LOAD_PLUGINS' => 0, 'RAILS_ENV' => 'production']); |
| 1315: | } |
| 1316: | |
| 1317: | |
| 1318: | |
| 1319: | |
| 1320: | |
| 1321: | |
| 1322: | |
| 1323: | |
| 1324: | |
| 1325: | public function update_themes(string $hostname, string $path = '', array $themes = array()): bool |
| 1326: | { |
| 1327: | return error('not implemented'); |
| 1328: | } |
| 1329: | |
| 1330: | |
| 1331: | |
| 1332: | |
| 1333: | public function has_fortification(string $hostname, string $path = '', string $mode = null): bool |
| 1334: | { |
| 1335: | return false; |
| 1336: | } |
| 1337: | |
| 1338: | |
| 1339: | |
| 1340: | |
| 1341: | public function fortification_modes(string $hostname, string $path = ''): array |
| 1342: | { |
| 1343: | return parent::fortification_modes($hostname, $path); |
| 1344: | } |
| 1345: | |
| 1346: | |
| 1347: | |
| 1348: | |
| 1349: | |
| 1350: | |
| 1351: | |
| 1352: | |
| 1353: | |
| 1354: | |
| 1355: | public function fortify(string $hostname, string $path = '', string $mode = 'max', $args = []): bool |
| 1356: | { |
| 1357: | return error('not implemented'); |
| 1358: | } |
| 1359: | |
| 1360: | |
| 1361: | |
| 1362: | |
| 1363: | |
| 1364: | |
| 1365: | |
| 1366: | |
| 1367: | |
| 1368: | public function unfortify(string $hostname, string $path = ''): bool |
| 1369: | { |
| 1370: | return error('not implemented'); |
| 1371: | } |
| 1372: | |
| 1373: | |
| 1374: | |
| 1375: | |
| 1376: | |
| 1377: | |
| 1378: | public function _housekeeping() |
| 1379: | { |
| 1380: | |
| 1381: | |
| 1382: | return true; |
| 1383: | } |
| 1384: | |
| 1385: | public function theme_status(string $hostname, string $path = '', string $theme = null) |
| 1386: | { |
| 1387: | return parent::theme_status($hostname, $path, $theme); |
| 1388: | } |
| 1389: | |
| 1390: | public function install_theme(string $hostname, string $path, string $theme, string $version = null): bool |
| 1391: | { |
| 1392: | return parent::install_theme($hostname, $path, $theme, $version); |
| 1393: | } |
| 1394: | |
| 1395: | |
| 1396: | |
| 1397: | |
| 1398: | |
| 1399: | |
| 1400: | |
| 1401: | |
| 1402: | |
| 1403: | private function createAdmin(string $hostname, string $path): bool |
| 1404: | { |
| 1405: | if (!$approot = $this->getAppRoot($hostname, $path)) { |
| 1406: | return false; |
| 1407: | } |
| 1408: | if (!$db = $this->connectDB($hostname, $path)) { |
| 1409: | return error('Failed to connect to Discourse database'); |
| 1410: | } |
| 1411: | if ($db->query('SELECT FROM users WHERE id = 1')->rowCount() > 0) { |
| 1412: | return warn('Admin user (id = 1) already present, not creating'); |
| 1413: | } |
| 1414: | $hash = hash('sha256', (string)random_int(PHP_INT_MIN, PHP_INT_MAX)); |
| 1415: | $sth = $db->prepare('INSERT INTO users (id, admin, created_at, updated_at, trust_level, username, username_lower, password_hash, salt, ip_address) VALUES(1, \'t\', NOW(), NOW(), 1, :user, LOWER(:user), :hash, :salt, :ip);'); |
| 1416: | $r1 = $sth->execute([ |
| 1417: | 'user' => $this->username, |
| 1418: | 'hash' => hash_hmac('sha256', (string)random_int(PHP_INT_MIN, PHP_INT_MAX), $hash), |
| 1419: | 'salt' => substr($hash, 0, 32), |
| 1420: | 'ip' => \Auth::client_ip() |
| 1421: | ]); |
| 1422: | $sth = $db->prepare('INSERT INTO user_emails (id, user_id, created_at, updated_at, email, "primary") VALUES(1, 1, NOW(), NOW(), :email, \'t\')'); |
| 1423: | |
| 1424: | return $r1 && $sth->execute(['email' => $this->common_get_email()]); |
| 1425: | } |
| 1426: | |
| 1427: | private function connectDB(string $hostname, string $path): ?PDO |
| 1428: | { |
| 1429: | $dbconfig = $this->db_config($hostname, $path); |
| 1430: | if (empty($dbconfig['user'])) { |
| 1431: | return null; |
| 1432: | } |
| 1433: | |
| 1434: | try { |
| 1435: | return \Module\Support\Webapps::connectorFromCredentials($dbconfig); |
| 1436: | } catch (PDOException $e) { |
| 1437: | return null; |
| 1438: | } |
| 1439: | } |
| 1440: | |
| 1441: | |
| 1442: | |
| 1443: | |
| 1444: | |
| 1445: | |
| 1446: | |
| 1447: | |
| 1448: | public function db_config(string $hostname, string $path = '') |
| 1449: | { |
| 1450: | if (!IS_CLI) { |
| 1451: | return $this->query('discourse_db_config', $hostname, $path); |
| 1452: | } |
| 1453: | |
| 1454: | $approot = $this->getAppRoot($hostname, $path); |
| 1455: | |
| 1456: | if (!$approot) { |
| 1457: | error('failed to determine Discourse app root - ' . $approot); |
| 1458: | |
| 1459: | return []; |
| 1460: | } |
| 1461: | $config = $approot . '/config/discourse.conf'; |
| 1462: | |
| 1463: | if (!file_exists($this->domain_fs_path($config))) { |
| 1464: | error('failed to locate Discourse config in ' . $approot); |
| 1465: | |
| 1466: | return []; |
| 1467: | } |
| 1468: | $ini = \Opcenter\Map::load($this->domain_fs_path($config), 'r', 'inifile')->section(null); |
| 1469: | |
| 1470: | return [ |
| 1471: | 'db' => $ini['db_name'], |
| 1472: | 'host' => $ini['db_host'], |
| 1473: | 'user' => $ini['db_username'], |
| 1474: | 'password' => $ini['db_password'], |
| 1475: | 'prefix' => '', |
| 1476: | 'type' => 'pgsql' |
| 1477: | ]; |
| 1478: | } |
| 1479: | } |