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