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->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: | |
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: | |
221: | |
222: | |
223: | |
224: | |
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: | |
262: | |
263: | |
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: | |
287: | |
288: | |
289: | |
290: | |
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: | |
326: | |
327: | |
328: | |
329: | |
330: | |
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: | |
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: | |
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: | |
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: | |
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: | |
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: | |
464: | |
465: | |
466: | |
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: | |
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: | } |