1: <?php
2: /**
3: * +------------------------------------------------------------+
4: * | apnscp |
5: * +------------------------------------------------------------+
6: * | Copyright (c) Apis Networks |
7: * +------------------------------------------------------------+
8: * | Licensed under Artistic License 2.0 |
9: * +------------------------------------------------------------+
10: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
11: * +------------------------------------------------------------+
12: */
13:
14: use Module\Support\Webapps as WebappsAlias;
15: use Module\Support\Webapps\App\Loader;
16: use Module\Support\Webapps\App\Type\Vanilla\ConstantFetch;
17: use Module\Support\Webapps\App\Type\Vanilla\Handler;
18: use Module\Support\Webapps\App\Type\Vanilla\Walker;
19: use Module\Support\Webapps\Composer;
20: use Module\Support\Webapps\DatabaseGenerator;
21: use Module\Support\Webapps\VersionFetcher\Github;
22: use Opcenter\Auth\Password;
23: use Opcenter\Auth\Shadow;
24: use Opcenter\Versioning;
25:
26: /**
27: * Vanilla management
28: *
29: * @package core
30: */
31: class Vanilla_Module extends Composer
32: {
33: // remain on 20xx versions
34: const DEFAULT_VERSION_LOCK = 'major';
35: const APP_NAME = Handler::NAME;
36: const VALIDITY_FILE = 'library/Vanilla/';
37: protected $aclList = [
38: 'min' => [
39: 'cache',
40: 'conf',
41: 'dist',
42: 'uploads'
43: ],
44: 'max' => [
45: 'cache',
46: 'uploads'
47: ]
48: ];
49:
50: /**
51: * Install Vanilla into a pre-existing location
52: *
53: * @param string $hostname domain or subdomain to install Vanilla
54: * @param string $path optional path under hostname
55: * @param array $opts additional install options
56: * @return bool
57: */
58: public function install(string $hostname, string $path = '', array $opts = array()): bool
59: {
60: if (!$this->mysql_enabled()) {
61: return error('%(what)s must be enabled to install %(app)s',
62: ['what' => 'MySQL', 'app' => static::APP_NAME]);
63: }
64:
65: if (!version_compare($this->php_version(), '7', '>=')) {
66: return error('%s requires PHP7', Handler::NAME);
67: }
68:
69: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
70: return false;
71: }
72:
73: $docroot = $approot = $this->getDocumentRoot($hostname, $path);
74:
75: if (!$this->downloadVersion($approot, $opts['version'])) {
76: return error("Failed to download %(app)s", ['app' => Handler::NAME]);
77: }
78:
79: $oldex = \Error_Reporter::exception_upgrade();
80:
81: try {
82: $svc = \Opcenter\SiteConfiguration::shallow($this->getAuthContext());
83: $htaccess = (string)(new \Opcenter\Provisioning\ConfigurationWriter('@webapp(vanilla)::templates.htaccess',
84: $svc))->compile([
85: 'afi' => $this->getApnscpFunctionInterceptor(),
86: 'hostname' => $hostname,
87: 'docroot' => $docroot
88: ]);
89: $this->getApnscpFunctionInterceptorFromDocroot($approot)->file_put_file_contents("{$approot}/.htaccess", (string)$htaccess);
90: $db = DatabaseGenerator::mysql($this->getAuthContext(), $hostname);
91: if (!$db->create()) {
92: return false;
93: }
94:
95: $this->fortify($hostname, $path, 'min');
96: $this->file_put_file_contents("{$approot}/version.json", json_encode([
97: 'x-version-scheme' => '{Release version}-{? SNAPSHOT if it\'s a dev build}',
98: 'version' => (string)$opts['version']
99: ]));
100:
101: $http = \HTTP\SelfReferential::instantiateContexted($this->getAuthContext(), [
102: $hostname,
103: $this->site_ip_address()
104: ]);
105:
106: info("setting admin user to `%s'", $opts['user']);
107:
108: if (!isset($opts['password'])) {
109: $opts['password'] = Password::generate();
110: info("autogenerated password `%s'", $opts['password']);
111: }
112:
113: try {
114: $http->post("{$path}/dashboard/setup.json", [
115: 'Database-dot-Host' => $db->hostname,
116: 'Database-dot-Name' => $db->database,
117: 'Database-dot-User' => $db->username,
118: 'Database-dot-Password' => $db->password,
119: 'Garden-dot-Title' => $opts['title'] ?? 'Vanilla Forums',
120: 'Email' => $opts['email'],
121: 'Name' => $opts['user'],
122: 'Password' => $opts['password'],
123: 'PasswordMatch' => $opts['password']
124: ]);
125: // give HTTP request time to finalize destructor
126: unset($http);
127: do {
128: usleep(250000);
129: $i = ($i ?? 0) + 1;
130: } while (!$this->file_exists("{$approot}/conf/config.php") && $i <= 40);
131: } catch (\GuzzleHttp\Exception\ClientException $e) {
132: $response = json_decode($e->getResponse()->getBody()->getContents(), true);
133: error("Self-request failed (status: %(status)d): %(err)s", [
134: 'status' => $e->getResponse()->getStatusCode(),
135: 'err' => $response['Exception']
136: ]);
137: }
138:
139: $this->fixRewriteBase($docroot);
140: $fqdn = $this->web_normalize_hostname($hostname);
141: $opts['uri'] = rtrim($fqdn . '/' . $path, '/');
142: $opts['proto'] = empty($opts['ssl']) ? 'http://' : 'https://';
143: } catch (\apnscpException $e) {
144: $this->file_delete($approot, true);
145: if (isset($db)) {
146: $db->rollback();
147: }
148:
149: return error('Failed to install %(app)s: %(err)s', ['app' => Handler::NAME, 'err' => $e->getMessage()]);
150: } finally {
151: \Error_Reporter::exception_upgrade($oldex);
152: }
153:
154: // permit pool read, inhibit Apache read
155: if ($this->php_jailed()) {
156: $this->file_chmod("{$approot}/version.json", 640);
157: }
158:
159: $this->notifyInstalled($hostname, $path, $opts);
160:
161: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
162: ['app' => static::APP_NAME, 'email' => $opts['email']]);
163: }
164:
165: private function versionMeta(string $version): ?array
166: {
167: return array_first($this->_getVersions(), static function ($meta) use ($version) {
168: return strtok($meta['version'], ' ') === $version;
169: });
170: }
171:
172: private function downloadVersion(string $approot, string $version): bool
173: {
174: if (null === ($meta = $this->versionMeta($version))) {
175: return error(":webapp_missing_download", "Cannot locate %(app)s version %(version)s", [
176: 'app' => self::APP_NAME,
177: 'version' => $version
178: ]);
179: }
180: $dlUrl = array_first($meta['assets'], static function ($asset) {
181: return substr($asset['name'], -4) === '.zip';
182: });
183: $dlUrl = $dlUrl['browser_download_url'] ?? null;
184: if (null === $dlUrl) {
185: return error("@todo");
186: }
187:
188: return $this->download($dlUrl, "{$approot}/") &&
189: $this->file_copy("{$approot}/package/", $approot, true) &&
190: $this->file_delete("{$approot}/package", true);
191: }
192:
193: protected function checkVersion(array &$options): bool
194: {
195: if (!isset($options['version'])) {
196: $versions = $this->get_versions();
197: $options['version'] = array_pop($versions);
198: }
199:
200: $phpversion = $this->php_pool_get_version();
201:
202: $cap = null;
203: if (Opcenter\Versioning::compare($phpversion, '7.4.0', '<')) {
204: $cap = '2021.003';
205: }
206:
207: if ($cap && version_compare($options['version'], $cap, '>=')) {
208: info("PHP version `%(version)s' detected, capping %(app)s to %(capver)s", [
209: 'version' => $phpversion, 'app' => $this->getAppName(), 'capver' => $cap]
210: );
211: $options['version'] = $cap;
212: }
213:
214: return true;
215: }
216:
217: /**
218: * Get installed version
219: *
220: * @param string $hostname
221: * @param string $path
222: * @return string version number
223: */
224: public function get_version(string $hostname, string $path = ''): ?string
225: {
226: if (!$this->valid($hostname, $path)) {
227: return null;
228: }
229:
230: $approot = $this->getAppRoot($hostname, $path);
231: $parser = ConstantFetch::instantiateContexted($this->getAuthContextFromDocroot($approot), [
232: "{$approot}/environment.php"
233: ]);
234:
235: if ('$version' !== ($version = $parser->get('APPLICATION_VERSION'))) {
236: return $version;
237: }
238:
239: if ($this->file_exists($path = "{$approot}/version.json")) {
240: if (null !== ($version = json_decode($this->file_get_file_contents($path))?->version)) {
241: return $version;
242: }
243: }
244:
245: if (!$this->file_exists("{$approot}/cache/version.php")) {
246: return null;
247: }
248:
249: $ast = Walker::instantiateContexted($this->getAuthContextFromDocroot($approot), ["{$approot}/cache/version.php"]);
250: $version = $ast->first(static function (PhpParser\Node $node) {
251: return $node instanceof \PhpParser\Node\Stmt\Return_;
252: });
253:
254: return $version?->expr->value;
255:
256: }
257:
258: /**
259: * Get Vanilla framework versions
260: *
261: * @return array
262: */
263: public function get_versions(): array
264: {
265: return array_column($this->_getVersions(), 'version');
266: }
267:
268: protected function _getVersions(string $name = null): array
269: {
270: $key = 'vanilla.versions';
271: $cache = Cache_Super_Global::spawn();
272: if (false !== ($ver = $cache->get($key))) {
273: return (array)$ver;
274: }
275:
276: $versions = (new Github)->setMode('releases')->fetch('apisnetworks/vanilla-rebuilds');
277: $cache->set($key, $versions, 43200);
278:
279: return $versions;
280: }
281:
282:
283: /**
284: * Get database configuration for a blog
285: *
286: * @param string $hostname domain or subdomain of wp blog
287: * @param string $path optional path
288: * @return array|bool
289: */
290: public function db_config(string $hostname, string $path = '')
291: {
292: $this->web_purge();
293: $approot = $this->getAppRoot($hostname, $path);
294: if (!$approot) {
295: return error('failed to determine Vanilla');
296: }
297:
298: try {
299: $walker = Walker::instantiateContexted(
300: $this->getAuthContextFromDocroot($approot),
301: ["{$approot}/conf/config.php"]
302: );
303: } catch (\ArgumentError $e) {
304: return false;
305: }
306:
307: return array_combine(['host','prefix','user','password','db','type'], [
308: $walker->get('Database.Host'),
309: (string)($walker->get('Database.DatabasePrefix') ?? 'GDN_'),
310: $walker->get('Database.User'),
311: $walker->get('Database.Password'),
312: $walker->get('Database.Name'),
313: 'mysql'
314: ]);
315: }
316:
317: public function update_all(string $hostname, string $path = '', string $version = null): bool
318: {
319: return $this->update($hostname, $path, $version) || error('failed to update all components');
320: }
321:
322: /**
323: * Update Vanilla to latest version
324: *
325: * @param string $hostname domain or subdomain under which WP is installed
326: * @param string $path optional subdirectory
327: * @param string $version version to upgrade
328: * @return bool
329: */
330: public function update(string $hostname, string $path = '', string $version = null): bool
331: {
332: $docroot = $this->getDocumentRoot($hostname, $path);
333: $approot = $this->getAppRoot($hostname, $path);
334: parent::setInfo($docroot, [
335: 'failed' => true
336: ]);
337:
338: if (!$approot) {
339: return error('update failed');
340: }
341:
342: $oldVersion = $this->get_version($hostname, $path);
343:
344: if (!$version) {
345: $version = Versioning::nextVersion($this->get_versions(),
346: $oldVersion);
347: } else if (!Versioning::valid($version)) {
348: return error('invalid version number, %s', $version);
349: }
350: $app = Loader::fromHostname(null, $hostname, $path, $this->getAuthContext());
351: $oldFortificationMode = $app->getOption('fortify', $app::DEFAULT_FORTIFICATION);
352:
353: if (version_compare($oldVersion, '2021.024', '<')) {
354: return error("Version %(version)s too old to support automatic upgrades", ['version' => $oldVersion]);
355: }
356:
357:
358: $this->reconfigure($hostname, $path, ['maintenance' => true]);
359: $cleanup = new Deferred;
360:
361: $walker = Walker::instantiateContexted($this->getAuthContextFromDocroot($approot),
362: ["{$approot}/conf/config.php"]);
363: $token = $walker->get('Garden.UpdateToken');
364: if (null === $token) {
365: $this->reconfigure($hostname, $path, ['maintenance' => false]);
366: return error("Update token not found in %(path)s", ['path' => "{$approot}/conf/config.php"]);
367: }
368:
369: // reference: https://success.vanillaforums.com/kb/articles/158-upgrading-self-hosted-vanilla
370: $this->file_delete("{$approot}/dist", true);
371: if (!$this->downloadVersion($approot, $version)) {
372: $this->reconfigure($hostname, $path, ['maintenance' => false]);
373: return error("Failed to download %(version)s", ['version' => $version]);
374: }
375:
376: if ($oldFortificationMode !== 'min') {
377: $this->fortify($hostname, $path, 'min');
378: defer($cleanup, fn() => $this->fortify($hostname, $path, $oldFortificationMode));
379: }
380:
381: $this->file_delete("{$approot}/cache/*", true);
382:
383: if (is_debug()) {
384: $wasDebug = (bool)$walker->get('Debug');
385: $walker->set('Debug', true)->save();
386: // messy with maintenance reconfiguration also depending upon file
387: defer($cleanup, fn() => $wasDebug || $this->reconfigure($hostname, $path, ['debug' => $wasDebug]));
388: }
389:
390: if (!$walker->get('Feature.updateTokens.Enabled')) {
391: $walker->set('Feature.updateTokens.Enabled', true)->save();
392: }
393:
394: $this->file_put_file_contents("{$approot}/version.json", json_encode([
395: 'x-version-scheme' => '{Release version}-{? SNAPSHOT if it\'s a dev build}',
396: 'version' => (string)$version
397: ]));
398:
399: // permit pool read, inhibit Apache read
400: if ($this->php_jailed()) {
401: $this->file_chmod("{$approot}/version.json", 640);
402: }
403:
404: $http = \HTTP\SelfReferential::instantiateContexted($this->getAuthContext(), [
405: $hostname,
406: $this->site_ip_address()
407: ]);
408:
409: $library = $this->file_get_file_contents($dsPath = "{$approot}/library/database/class.databasestructure.php");
410: // unsupported in MariaDB
411: $this->file_put_file_contents(
412: $dsPath,
413: preg_replace('!\bALGORITHM = INPLACE LOCK = NONE\b!m', '', $library)
414: );
415:
416: try {
417: $http->post("{$path}/utility/update.json", ['updateToken' => $token], [
418: 'Authorization' => "Bearer {$token}"
419: ]);
420: // rebuild /dist contents
421: preempt($cleanup, fn() => $http->get('/'));
422: } catch (\GuzzleHttp\Exception\ClientException $e) {
423: $response = json_decode($e->getResponse()->getBody()->getContents(), true);
424: return error("Self-request failed: %s", $response['Exception']);
425: } catch (\GuzzleHttp\Exception\ServerException $e) {
426: debug("Falling back to GET: %s", $e->getResponse()->getBody()->getContents());
427: $http->get("{$path}/utility/update.json", [
428: 'Authorization' => "Bearer {$token}"
429: ]);
430: } finally {
431: unset($http);
432: preempt($cleanup, fn() => $this->reconfigure($hostname, $path, ['maintenance' => false]));
433: }
434:
435: $this->file_delete("{$approot}/cache/*", true);
436:
437: if ($version && $oldVersion === $version) {
438: return error("Failed to update %(app)s from `%(old)s' to `%(new)s'",
439: ['app' => $this->getAppName(), 'old' => $oldVersion, 'new' => $version]
440: );
441: }
442:
443: parent::setInfo($docroot, [
444: 'version' => $version,
445: 'failed' => false
446: ]);
447:
448: return true;
449: }
450:
451: public function get_admin(string $hostname, string $path = ''): ?string
452: {
453: $db = $this->db_config($hostname, $path);
454: $mysql = WebappsAlias::connectorFromCredentials($db);
455: $query = "SELECT Name FROM {$db['prefix']}User WHERE Admin = 1 ORDER BY UserID DESC LIMIT 1";
456: $rs = $mysql->query($query);
457: return $rs->rowCount() === 1 ? $rs->fetchObject()->Name : null;
458: }
459:
460: /**
461: * @param string $hostname
462: * @param string $path
463: * @param array $fields available option: password, user, email
464: * @return bool
465: */
466: public function change_admin(string $hostname, string $path, array $fields): bool
467: {
468: $docroot = $this->getAppRoot($hostname, $path);
469: if (!$docroot) {
470: return warn('failed to change administrator information');
471: }
472: $admin = $this->get_admin($hostname, $path);
473:
474: if (!$admin) {
475: return error('cannot determine admin of install');
476: }
477:
478: if (isset($fields['password'])) {
479: if (!Shadow::crypted($fields['password'])) {
480: if (!Password::strong($fields['password'])) {
481: return error("Password is insufficient strength");
482: }
483: $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH);
484: } else if (!Shadow::valid_crypted($fields['password'])) {
485: // error generated from fn
486: return false;
487: }
488: }
489:
490: if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) {
491: return error("Invalid email");
492: }
493:
494: if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) {
495: return error("Invalid user");
496: }
497:
498: $valid = [
499: 'user' => 'Name',
500: 'email' => 'Email',
501: 'password' => 'Password'
502: ];
503:
504: if ($unrecognized = array_diff_key($fields, $valid)) {
505: return error("Unrecognized fields: %s", implode(array_keys($unrecognized)));
506: }
507: $match = array_intersect_key($valid, $fields);
508: $fields = array_combine(array_values($match), array_intersect_key($fields, $match));
509: if (!$fields) {
510: return warn("No fields updated");
511: }
512:
513: $db = $this->db_config($hostname, $path);
514: $admin = $this->get_admin($hostname, $path);
515:
516: $mysql = WebappsAlias::connectorFromCredentials($db);
517: $query = "UPDATE {$db['prefix']}User SET " .
518: implode(', ', array_key_map(static fn($k, $v) => $k . ' = ' . $mysql->quote($v), $fields)) . " WHERE Name = " . $mysql->quote($admin);
519:
520: $rs = $mysql->query($query);
521: return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s",
522: ['admin' => $admin, 'err' => $rs->errorInfo()]);
523: }
524: }
525: