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