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