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: $phpVersion = $this->php_pool_version_from_path($approot);
358: if (Versioning::compare($phpVersion, '8.0.2', '<') && Versioning::compare($version, '2024.012', '>=')) {
359: return error("%(name)s v%(version)s requires at least %(what)s v%(minver)s", [
360: 'name' => self::APP_NAME,
361: 'version' => $version,
362: 'what' => 'PHP',
363: 'minver' => '8.0.2'
364: ]);
365: }
366:
367: $this->reconfigure($hostname, $path, ['maintenance' => true]);
368: $cleanup = new Deferred;
369:
370: $walker = Walker::instantiateContexted($this->getAuthContextFromDocroot($approot),
371: ["{$approot}/conf/config.php"]);
372: $token = $walker->get('Garden.UpdateToken');
373: if (null === $token) {
374: $this->reconfigure($hostname, $path, ['maintenance' => false]);
375: return error("Update token not found in %(path)s", ['path' => "{$approot}/conf/config.php"]);
376: }
377:
378: // reference: https://success.vanillaforums.com/kb/articles/158-upgrading-self-hosted-vanilla
379: $this->file_delete("{$approot}/dist", true);
380: if (!$this->downloadVersion($approot, $version)) {
381: $this->reconfigure($hostname, $path, ['maintenance' => false]);
382: return error("Failed to download %(version)s", ['version' => $version]);
383: }
384:
385: if ($oldFortificationMode !== 'min') {
386: $this->fortify($hostname, $path, 'min');
387: defer($cleanup, fn() => $this->fortify($hostname, $path, $oldFortificationMode));
388: }
389:
390: $this->file_delete("{$approot}/cache/*", true);
391:
392: if (is_debug()) {
393: $wasDebug = (bool)$walker->get('Debug');
394: $walker->set('Debug', true)->save();
395: // messy with maintenance reconfiguration also depending upon file
396: defer($cleanup, fn() => $wasDebug || $this->reconfigure($hostname, $path, ['debug' => $wasDebug]));
397: }
398:
399: if (!$walker->get('Feature.updateTokens.Enabled')) {
400: $walker->set('Feature.updateTokens.Enabled', true)->save();
401: }
402:
403: $this->file_put_file_contents("{$approot}/version.json", json_encode([
404: 'x-version-scheme' => '{Release version}-{? SNAPSHOT if it\'s a dev build}',
405: 'version' => (string)$version
406: ]));
407:
408: // permit pool read, inhibit Apache read
409: if ($this->php_jailed()) {
410: $this->file_chmod("{$approot}/version.json", 640);
411: }
412:
413: $http = \HTTP\SelfReferential::instantiateContexted($this->getAuthContext(), [
414: $hostname,
415: $this->site_ip_address()
416: ]);
417:
418: $library = $this->file_get_file_contents($dsPath = "{$approot}/library/database/class.databasestructure.php");
419: // unsupported in MariaDB
420: $this->file_put_file_contents(
421: $dsPath,
422: preg_replace('!\bALGORITHM = INPLACE LOCK = NONE\b!m', '', $library)
423: );
424:
425: try {
426: $http->post("{$path}/utility/update.json", ['updateToken' => $token], [
427: 'Authorization' => "Bearer {$token}"
428: ]);
429: // rebuild /dist contents
430: preempt($cleanup, fn() => $http->get('/'));
431: } catch (\GuzzleHttp\Exception\ClientException $e) {
432: $response = json_decode($e->getResponse()->getBody()->getContents(), true);
433: return error("Self-request failed: %s", $response['Exception']);
434: } catch (\GuzzleHttp\Exception\ServerException $e) {
435: debug("Falling back to GET: %s", $e->getResponse()->getBody()->getContents());
436: $http->get("{$path}/utility/update.json", [
437: 'Authorization' => "Bearer {$token}"
438: ]);
439: } finally {
440: unset($http);
441: preempt($cleanup, fn() => $this->reconfigure($hostname, $path, ['maintenance' => false]));
442: }
443:
444: $this->file_delete("{$approot}/cache/*", true);
445:
446: if ($version && $oldVersion === $version) {
447: return error("Failed to update %(app)s from `%(old)s' to `%(new)s'",
448: ['app' => $this->getAppName(), 'old' => $oldVersion, 'new' => $version]
449: );
450: }
451:
452: parent::setInfo($docroot, [
453: 'version' => $version,
454: 'failed' => false
455: ]);
456:
457: return true;
458: }
459:
460: public function get_admin(string $hostname, string $path = ''): ?string
461: {
462: $db = $this->db_config($hostname, $path);
463: $mysql = WebappsAlias::connectorFromCredentials($db);
464: $query = "SELECT Name FROM {$db['prefix']}User WHERE Admin = 1 ORDER BY UserID DESC LIMIT 1";
465: $rs = $mysql->query($query);
466: return $rs->rowCount() === 1 ? $rs->fetchObject()->Name : null;
467: }
468:
469: /**
470: * @param string $hostname
471: * @param string $path
472: * @param array $fields available option: password, user, email
473: * @return bool
474: */
475: public function change_admin(string $hostname, string $path, array $fields): bool
476: {
477: $docroot = $this->getAppRoot($hostname, $path);
478: if (!$docroot) {
479: return warn('failed to change administrator information');
480: }
481: $admin = $this->get_admin($hostname, $path);
482:
483: if (!$admin) {
484: return error('cannot determine admin of install');
485: }
486:
487: if (isset($fields['password'])) {
488: if (!Shadow::crypted($fields['password'])) {
489: if (!Password::strong($fields['password'])) {
490: return error("Password is insufficient strength");
491: }
492: $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH);
493: } else if (!Shadow::valid_crypted($fields['password'])) {
494: // error generated from fn
495: return false;
496: }
497: }
498:
499: if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) {
500: return error("Invalid email");
501: }
502:
503: if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) {
504: return error("Invalid user");
505: }
506:
507: $valid = [
508: 'user' => 'Name',
509: 'email' => 'Email',
510: 'password' => 'Password'
511: ];
512:
513: if ($unrecognized = array_diff_key($fields, $valid)) {
514: return error("Unrecognized fields: %s", implode(array_keys($unrecognized)));
515: }
516: $match = array_intersect_key($valid, $fields);
517: $fields = array_combine(array_values($match), array_intersect_key($fields, $match));
518: if (!$fields) {
519: return warn("No fields updated");
520: }
521:
522: $db = $this->db_config($hostname, $path);
523: $admin = $this->get_admin($hostname, $path);
524:
525: $mysql = WebappsAlias::connectorFromCredentials($db);
526: $query = "UPDATE {$db['prefix']}User SET " .
527: implode(', ', array_key_map(static fn($k, $v) => $k . ' = ' . $mysql->quote($v), $fields)) . " WHERE Name = " . $mysql->quote($admin);
528:
529: $rs = $mysql->query($query);
530: return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s",
531: ['admin' => $admin, 'err' => $rs->errorInfo()]);
532: }
533: }