|    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:  | 	} |