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->fortify($hostname, $path, Handler::DEFAULT_FORTIFICATION);
140: $this->fixRewriteBase($docroot);
141: $fqdn = $this->web_normalize_hostname($hostname);
142: $opts['uri'] = rtrim($fqdn . '/' . $path, '/');
143: $opts['proto'] = empty($opts['ssl']) ? 'http://' : 'https://';
144: } catch (\apnscpException $e) {
145: $this->file_delete($approot, true);
146: if (isset($db)) {
147: $db->rollback();
148: }
149:
150: return error('Failed to install %(app)s: %(err)s', ['app' => Handler::NAME, 'err' => $e->getMessage()]);
151: } finally {
152: \Error_Reporter::exception_upgrade($oldex);
153: }
154:
155: // permit pool read, inhibit Apache read
156: if ($this->php_jailed()) {
157: $this->file_chmod("{$approot}/version.json", 640);
158: }
159:
160: $this->initializeMeta($docroot, $opts);
161: $this->notifyInstalled($hostname, $path, $opts);
162:
163: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
164: ['app' => static::APP_NAME, 'email' => $opts['email']]);
165: }
166:
167: private function versionMeta(string $version): ?array
168: {
169: return array_first($this->_getVersions(), static function ($meta) use ($version) {
170: return strtok($meta['version'], ' ') === $version;
171: });
172: }
173:
174: private function downloadVersion(string $approot, string $version): bool
175: {
176: if (null === ($meta = $this->versionMeta($version))) {
177: return error(":webapp_missing_download", "Cannot locate %(app)s version %(version)s", [
178: 'app' => self::APP_NAME,
179: 'version' => $version
180: ]);
181: }
182: $dlUrl = array_first($meta['assets'], static function ($asset) {
183: return substr($asset['name'], -4) === '.zip';
184: });
185: $dlUrl = $dlUrl['browser_download_url'] ?? null;
186: if (null === $dlUrl) {
187: return error("@todo");
188: }
189:
190: return $this->download($dlUrl, "{$approot}/") &&
191: $this->file_copy("{$approot}/package/", $approot, true) &&
192: $this->file_delete("{$approot}/package", true);
193: }
194:
195: protected function checkVersion(array &$options): bool
196: {
197: if (!isset($options['version'])) {
198: $versions = $this->get_versions();
199: $options['version'] = array_pop($versions);
200: }
201:
202: $phpversion = $this->php_pool_get_version();
203:
204: $cap = null;
205: if (Opcenter\Versioning::compare($phpversion, '7.4.0', '<')) {
206: $cap = '2021.003';
207: }
208:
209: if ($cap && version_compare($options['version'], $cap, '>=')) {
210: info("PHP version `%(version)s' detected, capping %(app)s to %(capver)s", [
211: 'version' => $phpversion, 'app' => $this->getAppName(), 'capver' => $cap]
212: );
213: $options['version'] = $cap;
214: }
215:
216: return true;
217: }
218:
219: /**
220: * Get installed version
221: *
222: * @param string $hostname
223: * @param string $path
224: * @return string version number
225: */
226: public function get_version(string $hostname, string $path = ''): ?string
227: {
228: if (!$this->valid($hostname, $path)) {
229: return null;
230: }
231:
232: $approot = $this->getAppRoot($hostname, $path);
233: $parser = ConstantFetch::instantiateContexted($this->getAuthContextFromDocroot($approot), [
234: "{$approot}/environment.php"
235: ]);
236:
237: if ('$version' !== ($version = $parser->get('APPLICATION_VERSION'))) {
238: return $version;
239: }
240:
241: if ($this->file_exists($path = "{$approot}/version.json")) {
242: if (null !== ($version = json_decode($this->file_get_file_contents($path))?->version)) {
243: return $version;
244: }
245: }
246:
247: if (!$this->file_exists("{$approot}/cache/version.php")) {
248: return null;
249: }
250:
251: $ast = Walker::instantiateContexted($this->getAuthContextFromDocroot($approot), ["{$approot}/cache/version.php"]);
252: $version = $ast->first(static function (PhpParser\Node $node) {
253: return $node instanceof \PhpParser\Node\Stmt\Return_;
254: });
255:
256: return $version?->expr->value;
257:
258: }
259:
260: /**
261: * Get Vanilla framework versions
262: *
263: * @return array
264: */
265: public function get_versions(): array
266: {
267: return array_column($this->_getVersions(), 'version');
268: }
269:
270: protected function _getVersions(string $name = null): array
271: {
272: $key = 'vanilla.versions';
273: $cache = Cache_Super_Global::spawn();
274: if (false !== ($ver = $cache->get($key))) {
275: return (array)$ver;
276: }
277:
278: $versions = (new Github)->setMode('releases')->fetch('apisnetworks/vanilla-rebuilds');
279: $cache->set($key, $versions, 43200);
280:
281: return $versions;
282: }
283:
284:
285: /**
286: * Get database configuration for a blog
287: *
288: * @param string $hostname domain or subdomain of wp blog
289: * @param string $path optional path
290: * @return array|bool
291: */
292: public function db_config(string $hostname, string $path = '')
293: {
294: $this->web_purge();
295: $approot = $this->getAppRoot($hostname, $path);
296: if (!$approot) {
297: return error('failed to determine Vanilla');
298: }
299:
300: try {
301: $walker = Walker::instantiateContexted(
302: $this->getAuthContextFromDocroot($approot),
303: ["{$approot}/conf/config.php"]
304: );
305: } catch (\ArgumentError $e) {
306: return false;
307: }
308:
309: return array_combine(['host','prefix','user','password','db','type'], [
310: $walker->get('Database.Host'),
311: (string)($walker->get('Database.DatabasePrefix') ?? 'GDN_'),
312: $walker->get('Database.User'),
313: $walker->get('Database.Password'),
314: $walker->get('Database.Name'),
315: 'mysql'
316: ]);
317: }
318:
319: public function update_all(string $hostname, string $path = '', string $version = null): bool
320: {
321: return $this->update($hostname, $path, $version) || error('failed to update all components');
322: }
323:
324: /**
325: * Update Vanilla to latest version
326: *
327: * @param string $hostname domain or subdomain under which WP is installed
328: * @param string $path optional subdirectory
329: * @param string $version version to upgrade
330: * @return bool
331: */
332: public function update(string $hostname, string $path = '', string $version = null): bool
333: {
334: $docroot = $this->getDocumentRoot($hostname, $path);
335: $approot = $this->getAppRoot($hostname, $path);
336: parent::setInfo($docroot, [
337: 'failed' => true
338: ]);
339:
340: if (!$approot) {
341: return error('update failed');
342: }
343:
344: $oldVersion = $this->get_version($hostname, $path);
345:
346: if (!$version) {
347: $version = Versioning::nextVersion($this->get_versions(),
348: $oldVersion);
349: } else if (!Versioning::valid($version)) {
350: return error('invalid version number, %s', $version);
351: }
352: $app = Loader::fromHostname(null, $hostname, $path, $this->getAuthContext());
353: $oldFortificationMode = $app->getOption('fortify', $app::DEFAULT_FORTIFICATION);
354:
355: if (version_compare($oldVersion, '2021.024', '<')) {
356: return error("Version %(version)s too old to support automatic upgrades", ['version' => $oldVersion]);
357: }
358:
359:
360: $this->reconfigure($hostname, $path, ['maintenance' => true]);
361: $cleanup = new Deferred;
362:
363: $walker = Walker::instantiateContexted($this->getAuthContextFromDocroot($approot),
364: ["{$approot}/conf/config.php"]);
365: $token = $walker->get('Garden.UpdateToken');
366: if (null === $token) {
367: $this->reconfigure($hostname, $path, ['maintenance' => false]);
368: return error("Update token not found in %(path)s", ['path' => "{$approot}/conf/config.php"]);
369: }
370:
371: // reference: https://success.vanillaforums.com/kb/articles/158-upgrading-self-hosted-vanilla
372: $this->file_delete("{$approot}/dist", true);
373: if (!$this->downloadVersion($approot, $version)) {
374: $this->reconfigure($hostname, $path, ['maintenance' => false]);
375: return error("Failed to download %(version)s", ['version' => $version]);
376: }
377:
378: if ($oldFortificationMode !== 'min') {
379: $this->fortify($hostname, $path, 'min');
380: defer($cleanup, fn() => $this->fortify($hostname, $path, $oldFortificationMode));
381: }
382:
383: $this->file_delete("{$approot}/cache/*", true);
384:
385: if (is_debug()) {
386: $wasDebug = (bool)$walker->get('Debug');
387: $walker->set('Debug', true)->save();
388: // messy with maintenance reconfiguration also depending upon file
389: defer($cleanup, fn() => $wasDebug || $this->reconfigure($hostname, $path, ['debug' => $wasDebug]));
390: }
391:
392: if (!$walker->get('Feature.updateTokens.Enabled')) {
393: $walker->set('Feature.updateTokens.Enabled', true)->save();
394: }
395:
396: $this->file_put_file_contents("{$approot}/version.json", json_encode([
397: 'x-version-scheme' => '{Release version}-{? SNAPSHOT if it\'s a dev build}',
398: 'version' => (string)$version
399: ]));
400:
401: // permit pool read, inhibit Apache read
402: if ($this->php_jailed()) {
403: $this->file_chmod("{$approot}/version.json", 640);
404: }
405:
406: $http = \HTTP\SelfReferential::instantiateContexted($this->getAuthContext(), [
407: $hostname,
408: $this->site_ip_address()
409: ]);
410:
411: $library = $this->file_get_file_contents($dsPath = "{$approot}/library/database/class.databasestructure.php");
412: // unsupported in MariaDB
413: $this->file_put_file_contents(
414: $dsPath,
415: preg_replace('!\bALGORITHM = INPLACE LOCK = NONE\b!m', '', $library)
416: );
417:
418: try {
419: $http->post("{$path}/utility/update.json", ['updateToken' => $token], [
420: 'Authorization' => "Bearer {$token}"
421: ]);
422: // rebuild /dist contents
423: preempt($cleanup, fn() => $http->get('/'));
424: } catch (\GuzzleHttp\Exception\ClientException $e) {
425: $response = json_decode($e->getResponse()->getBody()->getContents(), true);
426: return error("Self-request failed: %s", $response['Exception']);
427: } catch (\GuzzleHttp\Exception\ServerException $e) {
428: debug("Falling back to GET: %s", $e->getResponse()->getBody()->getContents());
429: $http->get("{$path}/utility/update.json", [
430: 'Authorization' => "Bearer {$token}"
431: ]);
432: } finally {
433: unset($http);
434: preempt($cleanup, fn() => $this->reconfigure($hostname, $path, ['maintenance' => false]));
435: }
436:
437: $this->file_delete("{$approot}/cache/*", true);
438:
439: if ($version && $oldVersion === $version) {
440: return error("Failed to update %(app)s from `%(old)s' to `%(new)s'",
441: ['app' => $this->getAppName(), 'old' => $oldVersion, 'new' => $version]
442: );
443: }
444:
445: parent::setInfo($docroot, [
446: 'version' => $version,
447: 'failed' => false
448: ]);
449:
450: return true;
451: }
452:
453: public function get_admin(string $hostname, string $path = ''): ?string
454: {
455: $db = $this->db_config($hostname, $path);
456: $mysql = WebappsAlias::connectorFromCredentials($db);
457: $query = "SELECT Name FROM {$db['prefix']}User WHERE Admin = 1 ORDER BY UserID DESC LIMIT 1";
458: $rs = $mysql->query($query);
459: return $rs->rowCount() === 1 ? $rs->fetchObject()->Name : null;
460: }
461:
462: /**
463: * @param string $hostname
464: * @param string $path
465: * @param array $fields available option: password, user, email
466: * @return bool
467: */
468: public function change_admin(string $hostname, string $path, array $fields): bool
469: {
470: $docroot = $this->getAppRoot($hostname, $path);
471: if (!$docroot) {
472: return warn('failed to change administrator information');
473: }
474: $admin = $this->get_admin($hostname, $path);
475:
476: if (!$admin) {
477: return error('cannot determine admin of install');
478: }
479:
480: if (isset($fields['password'])) {
481: if (!Shadow::crypted($fields['password'])) {
482: if (!Password::strong($fields['password'])) {
483: return error("Password is insufficient strength");
484: }
485: $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH);
486: } else if (!Shadow::valid_crypted($fields['password'])) {
487: // error generated from fn
488: return false;
489: }
490: }
491:
492: if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) {
493: return error("Invalid email");
494: }
495:
496: if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) {
497: return error("Invalid user");
498: }
499:
500: $valid = [
501: 'user' => 'Name',
502: 'email' => 'Email',
503: 'password' => 'Password'
504: ];
505:
506: if ($unrecognized = array_diff_key($fields, $valid)) {
507: return error("Unrecognized fields: %s", implode(array_keys($unrecognized)));
508: }
509: $match = array_intersect_key($valid, $fields);
510: $fields = array_combine(array_values($match), array_intersect_key($fields, $match));
511: if (!$fields) {
512: return warn("No fields updated");
513: }
514:
515: $db = $this->db_config($hostname, $path);
516: $admin = $this->get_admin($hostname, $path);
517:
518: $mysql = WebappsAlias::connectorFromCredentials($db);
519: $query = "UPDATE {$db['prefix']}User SET " .
520: implode(', ', array_key_map(static fn($k, $v) => $k . ' = ' . $mysql->quote($v), $fields)) . " WHERE Name = " . $mysql->quote($admin);
521:
522: $rs = $mysql->query($query);
523: return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s",
524: ['admin' => $admin, 'err' => $rs->errorInfo()]);
525: }
526: }