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