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;
15: use Module\Support\Webapps\App\Type\Flarum\Handler;
16: use Module\Support\Webapps\ComposerMetadata;
17: use Module\Support\Webapps\ComposerWrapper;
18: use Opcenter\Auth\Password;
19: use Opcenter\Auth\Shadow;
20:
21: /**
22: * Flarum management
23: *
24: * @package core
25: */
26: class Flarum_Module extends Laravel_Module
27: {
28: // remain on 20xx versions
29: const DEFAULT_VERSION_LOCK = 'major';
30: const PACKAGIST_NAME = 'flarum/flarum';
31: const APP_NAME = Handler::NAME;
32: const BINARY_NAME = 'flarum';
33: const VALIDITY_FILE = self::BINARY_NAME;
34:
35: protected $aclList = array(
36: 'min' => array(
37: 'storage',
38: 'vendor',
39: 'composer.json',
40: 'composer.lock',
41: 'public/assets'
42: ),
43: 'max' => array(
44: 'storage/cache',
45: 'storage/formatter',
46: 'storage/less',
47: 'storage/locale',
48: 'storage/logs',
49: 'storage/sessions',
50: 'storage/tmp',
51: 'storage/views',
52: 'public/assets'
53: )
54: );
55:
56: public function install(string $hostname, string $path = '', array $opts = array()): bool
57: {
58: return parent::install($hostname, $path, $opts);
59: }
60:
61: public function get_version(string $hostname, string $path = ''): ?string
62: {
63: return parent::get_version($hostname, $path);
64: }
65:
66: public function valid(string $hostname, string $path = ''): bool
67: {
68: return parent::valid($hostname, $path);
69: }
70:
71: public function update_all(string $hostname, string $path = '', string $version = null): bool
72: {
73: return parent::update_all($hostname, $path, $version);
74: }
75:
76: public function update(string $hostname, string $path = '', string $version = null): bool
77: {
78: return parent::update($hostname, $path, $version);
79: }
80:
81:
82: protected function postInstall(string $hostname, string $path): bool
83: {
84: $approot = $this->getAppRoot($hostname, $path);
85: $ret = $this->execPhp($approot, static::BINARY_NAME . ' install --file %s', ["{$approot}/.env"]);
86: $this->file_delete("{$approot}/.env");
87: if (!$ret['success']) {
88: return error("Failed to finish %(name)s install: %(err)s", [
89: 'name' => static::APP_NAME, 'err' => coalesce($ret['stderr'], $ret['stdout'])
90: ]);
91: }
92:
93: return $this->postUpdate($hostname, $path);
94: }
95:
96: protected function postUpdate(string $hostname, string $path): bool
97: {
98: $approot = $this->getAppRoot($hostname, $path);
99: foreach (['migrate', 'cache:clear', 'assets:publish'] as $directive) {
100: $this->execPhp($approot, static::BINARY_NAME . ' ' . $directive);
101: }
102:
103: return true;
104: }
105:
106: protected function updateLibraryName(string $approot): string
107: {
108: return \Module\Support\Webapps\App\Type\Flarum\Reconfiguration\Verlock::PACKAGE_NAME;
109: }
110:
111: protected function notifyInstalled(string $hostname, string $path = '', array $args = []): bool
112: {
113: $args['login'] ??= $args['user'];
114: return parent::notifyInstalled($hostname, $path, $args);
115: }
116:
117: /**
118: * Get Flarum framework versions
119: *
120: * @return array
121: */
122: public function get_versions(): array
123: {
124: return parent::getPackagistVersions('flarum/core');
125: }
126:
127: protected function parseInstallOptions(array &$options, string $hostname, string $path = ''): bool
128: {
129: if (!isset($options['user'])) {
130: $options['user'] = $this->username;
131: }
132: info("setting admin user to `%s'", $options['user']);
133: if (!isset($options['password'])) {
134: info("autogenerated password `%s'",
135: $options['password'] = Password::generate()
136: );
137: }
138:
139: return parent::parseInstallOptions($options, $hostname, $path);
140: }
141:
142: public function uninstall_plugin(string $hostname, string $path, string $plugin, bool $force = false): bool
143: {
144: $approot = $this->getAppRoot($hostname, $path);
145: $composer = ComposerWrapper::instantiateContexted($this->getAuthContextFromDocroot($approot));
146: if ($force && !$this->disable_plugin($hostname, $path, $plugin)) {
147: return false;
148: }
149: $ret = $composer->exec($approot, 'remove %s', [$plugin]);
150:
151: return $ret['success'] ?: error("Failed to remove plugin: %s", coalesce($ret['stderr'], $ret['stdout']));
152: }
153:
154: public function install_plugin(string $hostname, string $path, string $plugin, string $version = '*'): bool
155: {
156: $approot = $this->getAppRoot($hostname, $path);
157: $composer = ComposerWrapper::instantiateContexted($this->getAuthContextFromDocroot($approot));
158:
159: $ret = $composer->exec($approot, 'require %s:%s', [$plugin, $version]);
160:
161: return $ret['success'] && $this->enable_plugin($hostname, $path, $plugin) ?:
162: error("Failed to install plugin: %s", coalesce($ret['stderr'], $ret['stdout']));
163: }
164:
165: public function plugin_status(string $hostname, string $path = '', string $plugin = null)
166: {
167: $approot = $this->getAppRoot($hostname, $path);
168: $pdo = Webapps::connectorFromCredentials($dbCfg = $this->db_config($hostname, $path));
169: $activeExtensions = array_flip(json_decode(
170: $pdo->query("SELECT REPLACE(`value`, '-', '/') AS `value` FROM `{$dbCfg['prefix']}settings` WHERE `key` = 'extensions_enabled'")->fetchObject()?->value
171: ));
172:
173: $meta = ComposerMetadata::readFrozen($this->getAuthContextFromDocroot($approot), $approot);
174: $flarumPackages = array_filter((array)$meta->packages(),
175: fn($package) =>
176: isset($activeExtensions[$package['name']]) ||
177: $package['name'] === $plugin ||
178: $plugin === null && str_starts_with($package['name'], 'flarum/')
179: );
180: $pluginmeta = array_build(
181: $flarumPackages,
182: fn($i, $package) => [
183: $package['name'],
184: [
185: 'version' => $package['version_normalized'],
186: 'active' => isset($activeExtensions[$package['name']])
187: ]
188: ],
189: );
190:
191: unset($pluginmeta['flarum/core']);
192:
193: return $plugin ? $pluginmeta[$plugin] ?? error("unknown plugin `%s'", $plugin) : $pluginmeta;
194: }
195:
196: /**
197: * Disable plugin
198: *
199: * @param string $hostname
200: * @param string $path
201: * @param string $plugin
202: * @return bool
203: */
204: public function disable_plugin(string $hostname, string $path, string $plugin): bool
205: {
206: $approot = $this->getAppRoot($hostname, $path);
207: $composer = ComposerWrapper::instantiateContexted($this->getAuthContextFromDocroot($approot));
208: $ret = $composer->direct($approot, self::BINARY_NAME . ' --no-ansi extension:disable %s', [
209: str_replace('/', '-', $plugin)
210: ]);
211:
212: if (str_contains($ret['stderr'], "There are no extensions")) {
213: warn($ret['stderr']);
214: }
215: return $ret['success'] ?: error("Unable to disable plugin %(plugin)s: %(err)s",
216: ['plugin' => $plugin, 'err' => coalesce($ret['stderr'], $ret['stdout'])]);
217: }
218:
219: /**
220: * Enable plugin
221: *
222: * @param string $hostname
223: * @param string $path
224: * @param string $plugin
225: * @return bool
226: */
227: public function enable_plugin(string $hostname, string $path, string $plugin): bool
228: {
229: $approot = $this->getAppRoot($hostname, $path);
230: $composer = ComposerWrapper::instantiateContexted($this->getAuthContextFromDocroot($approot));
231: $ret = $composer->direct($approot, self::BINARY_NAME . ' --no-ansi extension:enable %s', [str_replace('/', '-', $plugin)]);
232: if (str_contains($ret['stderr'], "There are no extensions")) {
233: $ret['success'] = false;
234: }
235:
236: return $ret['success'] ?: error("Unable to enable plugin %(plugin)s: %(err)s",
237: ['plugin' => $plugin, 'err' => coalesce($ret['stderr'], $ret['stdout'])]);
238: }
239:
240: protected function setConfiguration(string $approot, string $docroot, array $config)
241: {
242: $envcfg = (new \Opcenter\Provisioning\ConfigurationWriter('@webapp(' . $this->getAppName() . ')::templates.env',
243: \Opcenter\SiteConfiguration::shallow($this->getAuthContext())))
244: ->compile($config);
245:
246: return $this->file_put_file_contents("{$approot}/.env", (string)$envcfg) && $this->file_chmod("{$approot}/.env", 600);
247: }
248:
249: public function get_admin(string $hostname, string $path = ''): ?string
250: {
251: $db = $this->db_config($hostname, $path);
252: $mysql = Webapps::connectorFromCredentials($db);
253: $query = "SELECT username FROM {$db['prefix']}users JOIN {$db['prefix']}group_user ON ({$db['prefix']}group_user.user_id = {$db['prefix']}users.id) WHERE group_id = 1 ORDER BY {$db['prefix']}users.id DESC LIMIT 1";
254: $rs = $mysql->query($query);
255: return $rs->rowCount() === 1 ? $rs->fetchObject()->username : null;
256: }
257:
258: /**
259: * Get database configuration for a blog
260: *
261: * @param string $hostname domain or subdomain of wp blog
262: * @param string $path optional path
263: * @return array|bool
264: */
265: public function db_config(string $hostname, string $path = '')
266: {
267: $this->web_purge();
268: $approot = $this->getAppRoot($hostname, $path);
269: if (!$approot) {
270: return error('failed to determine %s', $this->getAppName());
271: }
272:
273: try {
274: $cfg = Webapps\App\Type\Flarum\Walker::instantiateContexted($this->getAuthContextFromDocroot($approot), ["{$approot}/config.php"])->get('database');
275: } catch (\ArgumentError) {
276: return error("Failed to obtain %(app)s configuration for `%(path)s'", ['app' => static::APP_NAME, 'path' => $approot]);
277: }
278:
279: return [
280: 'type' => $cfg['driver'] ?? 'mysql',
281: 'host' => $cfg['host'],
282: 'port' => $cfg['port'],
283: 'db' => $cfg['database'],
284: 'user' => $cfg['username'],
285: 'password' => $cfg['password'],
286: 'prefix' => $cfg['prefix']
287: ];
288: }
289:
290: /**
291: * @param string $hostname
292: * @param string $path
293: * @param array $fields available option: password, user, email
294: * @return bool
295: */
296: public function change_admin(string $hostname, string $path, array $fields): bool
297: {
298: $docroot = $this->getAppRoot($hostname, $path);
299: if (!$docroot) {
300: return warn('failed to change administrator information');
301: }
302: $admin = $this->get_admin($hostname, $path);
303:
304: if (!$admin) {
305: return error('cannot determine admin of install');
306: }
307:
308: if (isset($fields['password'])) {
309: if (!Shadow::crypted($fields['password'])) {
310: if (!Password::strong($fields['password'])) {
311: return error("Password is insufficient strength");
312: }
313: $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH);
314: } else if (!Shadow::valid_crypted($fields['password'])) {
315: // error generated from fn
316: return false;
317: }
318: }
319:
320: if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) {
321: return error("Invalid email");
322: }
323:
324: if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) {
325: return error("Invalid user");
326: }
327:
328: $valid = [
329: 'user' => 'username',
330: 'email' => 'email',
331: 'password' => 'password'
332: ];
333:
334: if ($unrecognized = array_diff_key($fields, $valid)) {
335: return error("Unrecognized fields: %s", implode(array_keys($unrecognized)));
336: }
337:
338: if (!$match = array_intersect_key($valid, $fields)) {
339: return warn("No fields updated");
340: }
341:
342: $fields = array_intersect_key($fields, $match);
343: $db = $this->db_config($hostname, $path);
344: $admin = $this->get_admin($hostname, $path);
345: $mysql = Webapps::connectorFromCredentials($db);
346: $query = "UPDATE {$db['prefix']}users SET " .
347: implode(', ', array_key_map(static fn($k, $v) => $valid[$k] . ' = ' . $mysql->quote($v), $fields)) . " WHERE username = " . $mysql->quote($admin);
348:
349: $rs = $mysql->query($query);
350: return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s",
351: ['admin' => $admin, 'err' => $rs->errorInfo()]);
352: }
353: }