1: | <?php |
2: | declare(strict_types=1); |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
12: | |
13: | |
14: | |
15: | use Module\Support\Webapps\App\Type\Discourse\Launcher; |
16: | use Module\Support\Webapps\Passenger; |
17: | use Module\Support\Webapps\PathManager; |
18: | use Module\Support\Webapps\Traits\PublicRelocatable; |
19: | use Module\Support\Webapps\VersionFetcher\Github; |
20: | use Opcenter\Net\Port; |
21: | |
22: | |
23: | |
24: | |
25: | |
26: | |
27: | |
28: | |
29: | class Discourse_Module extends \Module\Support\Webapps |
30: | { |
31: | use PublicRelocatable { |
32: | getAppRoot as getAppRootReal; |
33: | } |
34: | |
35: | |
36: | const MINIMUM_INTERPRETERS = [ |
37: | '0' => '2.4.2', |
38: | '2.2.0.beta5' => '2.5.2', |
39: | '2.5.0' => '2.6.5', |
40: | '2.6.0' => '2.7.2', |
41: | '3.0.0' => '3.1.3', |
42: | '3.1.0' => '3.2.0' |
43: | ]; |
44: | |
45: | |
46: | |
47: | const NODE_VERSIONS = [ |
48: | '0' => '8', |
49: | '2.4' => '10', |
50: | '2.5' => '14', |
51: | '2.6' => '15', |
52: | '2.8' => '16' |
53: | ]; |
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: | |
82: | |
83: | |
84: | |
85: | |
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: | |
94: | |
95: | public function reconfigure(string $hostname, string $path, $param, $value = null): bool |
96: | { |
97: | return parent::reconfigure($hostname, $path, $param, $value); |
98: | } |
99: | |
100: | |
101: | |
102: | |
103: | public function reconfigurables(string $hostname, string $path = ''): array |
104: | { |
105: | return parent::reconfigurables($hostname, $path); |
106: | } |
107: | |
108: | |
109: | |
110: | |
111: | |
112: | |
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: | |
141: | |
142: | |
143: | |
144: | |
145: | |
146: | protected function getAppRoot(string $hostname, string $path = ''): ?string |
147: | { |
148: | return $this->getAppRootReal($hostname, $path); |
149: | } |
150: | |
151: | |
152: | |
153: | |
154: | |
155: | |
156: | |
157: | |
158: | |
159: | |
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: | |
210: | |
211: | |
212: | |
213: | |
214: | |
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: | |
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: | |
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: | |
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: | |
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: | |
432: | |
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: | |
446: | |
447: | |
448: | |
449: | |
450: | |
451: | |
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: | |
508: | |
509: | |
510: | |
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: | |
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: | |
543: | |
544: | |
545: | |
546: | |
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: | |
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: | |
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: | |
585: | |
586: | |
587: | |
588: | |
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: | |
607: | |
608: | |
609: | |
610: | |
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: | |
657: | |
658: | |
659: | |
660: | |
661: | |
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: | |
686: | |
687: | |
688: | |
689: | |
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: | |
699: | |
700: | |
701: | |
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: | |
718: | if (!is_array($args)) { |
719: | $args = func_get_args(); |
720: | array_shift($args); |
721: | } |
722: | |
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: | |
742: | $ret['success'] = false; |
743: | if (!$ret['stderr']) { |
744: | $ret['stderr'] = $ret['stdout']; |
745: | } |
746: | |
747: | } |
748: | |
749: | return $ret; |
750: | } |
751: | |
752: | |
753: | |
754: | |
755: | |
756: | |
757: | |
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: | |
806: | |
807: | |
808: | |
809: | |
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: | |
818: | |
819: | |
820: | |
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: | |
829: | |
830: | |
831: | |
832: | |
833: | |
834: | |
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: | |
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: | |
875: | |
876: | |
877: | |
878: | |
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: | |
893: | |
894: | |
895: | |
896: | |
897: | |
898: | |
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: | |
940: | |
941: | |
942: | |
943: | |
944: | |
945: | |
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: | |
958: | |
959: | |
960: | |
961: | |
962: | |
963: | |
964: | public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool |
965: | { |
966: | $approot = $this->getAppRoot($hostname, $path); |
967: | |
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: | |
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: | |
1017: | |
1018: | |
1019: | |
1020: | |
1021: | |
1022: | public function is_current(string $version = null, string $branchcomp = null) |
1023: | { |
1024: | return parent::is_current($version, $branchcomp); |
1025: | } |
1026: | |
1027: | |
1028: | |
1029: | |
1030: | |
1031: | |
1032: | |
1033: | |
1034: | |
1035: | |
1036: | |
1037: | public function change_admin(string $hostname, string $path, array $fields): bool |
1038: | { |
1039: | if ( !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: | |
1106: | |
1107: | |
1108: | |
1109: | |
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: | |
1127: | |
1128: | |
1129: | |
1130: | |
1131: | |
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: | |
1140: | |
1141: | |
1142: | |
1143: | |
1144: | |
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: | |
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: | |
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: | |
1231: | |
1232: | |
1233: | |
1234: | public function get_versions(): array |
1235: | { |
1236: | $versions = $this->_getVersions(); |
1237: | |
1238: | return array_column($versions, 'version'); |
1239: | } |
1240: | |
1241: | |
1242: | |
1243: | |
1244: | |
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: | |
1261: | |
1262: | |
1263: | |
1264: | |
1265: | |
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: | |
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: | |
1294: | |
1295: | |
1296: | |
1297: | |
1298: | |
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: | |
1310: | |
1311: | |
1312: | |
1313: | |
1314: | |
1315: | |
1316: | public function update_themes(string $hostname, string $path = '', array $themes = array()): bool |
1317: | { |
1318: | return error('not implemented'); |
1319: | } |
1320: | |
1321: | |
1322: | |
1323: | |
1324: | public function has_fortification(string $hostname, string $path = '', string $mode = null): bool |
1325: | { |
1326: | return false; |
1327: | } |
1328: | |
1329: | |
1330: | |
1331: | |
1332: | public function fortification_modes(string $hostname, string $path = ''): array |
1333: | { |
1334: | return parent::fortification_modes($hostname, $path); |
1335: | } |
1336: | |
1337: | |
1338: | |
1339: | |
1340: | |
1341: | |
1342: | |
1343: | |
1344: | |
1345: | |
1346: | public function fortify(string $hostname, string $path = '', string $mode = 'max', $args = []): bool |
1347: | { |
1348: | return error('not implemented'); |
1349: | } |
1350: | |
1351: | |
1352: | |
1353: | |
1354: | |
1355: | |
1356: | |
1357: | |
1358: | |
1359: | public function unfortify(string $hostname, string $path = ''): bool |
1360: | { |
1361: | return error('not implemented'); |
1362: | } |
1363: | |
1364: | |
1365: | |
1366: | |
1367: | |
1368: | |
1369: | public function _housekeeping() |
1370: | { |
1371: | |
1372: | |
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: | |
1388: | |
1389: | |
1390: | |
1391: | |
1392: | |
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: | |
1434: | |
1435: | |
1436: | |
1437: | |
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: | |