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