1: | <?php |
2: | |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
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: | |
28: | |
29: | |
30: | |
31: | class Vanilla_Module extends Composer |
32: | { |
33: | |
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: | |
52: | |
53: | |
54: | |
55: | |
56: | |
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: | |
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: | |
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: | |
219: | |
220: | |
221: | |
222: | |
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: | |
260: | |
261: | |
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: | |
285: | |
286: | |
287: | |
288: | |
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: | |
324: | |
325: | |
326: | |
327: | |
328: | |
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: | |
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: | |
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: | |
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: | |
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: | |
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: | |
462: | |
463: | |
464: | |
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: | |
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: | |