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) &&
247: $this->file_chmod("{$approot}/.env", 600) &&
248: $this->file_chown("{$approot}/.env", $this->getDocrootUser($approot));
249: }
250:
251: public function get_admin(string $hostname, string $path = ''): ?string
252: {
253: $db = $this->db_config($hostname, $path);
254: $mysql = Webapps::connectorFromCredentials($db);
255: $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";
256: $rs = $mysql->query($query);
257: return $rs->rowCount() === 1 ? $rs->fetchObject()->username : null;
258: }
259:
260: /**
261: * Get database configuration for a blog
262: *
263: * @param string $hostname domain or subdomain of wp blog
264: * @param string $path optional path
265: * @return array|bool
266: */
267: public function db_config(string $hostname, string $path = '')
268: {
269: $this->web_purge();
270: $approot = $this->getAppRoot($hostname, $path);
271: if (!$approot) {
272: return error('failed to determine %s', $this->getAppName());
273: }
274:
275: try {
276: $cfg = Webapps\App\Type\Flarum\Walker::instantiateContexted($this->getAuthContextFromDocroot($approot), ["{$approot}/config.php"])->get('database');
277: } catch (\ArgumentError) {
278: return error("Failed to obtain %(app)s configuration for `%(path)s'", ['app' => static::APP_NAME, 'path' => $approot]);
279: }
280:
281: return [
282: 'type' => $cfg['driver'] ?? 'mysql',
283: 'host' => $cfg['host'],
284: 'port' => $cfg['port'],
285: 'db' => $cfg['database'],
286: 'user' => $cfg['username'],
287: 'password' => $cfg['password'],
288: 'prefix' => $cfg['prefix']
289: ];
290: }
291:
292: /**
293: * @param string $hostname
294: * @param string $path
295: * @param array $fields available option: password, user, email
296: * @return bool
297: */
298: public function change_admin(string $hostname, string $path, array $fields): bool
299: {
300: $docroot = $this->getAppRoot($hostname, $path);
301: if (!$docroot) {
302: return warn('failed to change administrator information');
303: }
304: $admin = $this->get_admin($hostname, $path);
305:
306: if (!$admin) {
307: return error('cannot determine admin of install');
308: }
309:
310: if (isset($fields['password'])) {
311: if (!Shadow::crypted($fields['password'])) {
312: if (!Password::strong($fields['password'])) {
313: return error("Password is insufficient strength");
314: }
315: $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH);
316: } else if (!Shadow::valid_crypted($fields['password'])) {
317: // error generated from fn
318: return false;
319: }
320: }
321:
322: if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) {
323: return error("Invalid email");
324: }
325:
326: if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) {
327: return error("Invalid user");
328: }
329:
330: $valid = [
331: 'user' => 'username',
332: 'email' => 'email',
333: 'password' => 'password'
334: ];
335:
336: if ($unrecognized = array_diff_key($fields, $valid)) {
337: return error("Unrecognized fields: %s", implode(array_keys($unrecognized)));
338: }
339:
340: if (!$match = array_intersect_key($valid, $fields)) {
341: return warn("No fields updated");
342: }
343:
344: $fields = array_intersect_key($fields, $match);
345: $db = $this->db_config($hostname, $path);
346: $admin = $this->get_admin($hostname, $path);
347: $mysql = Webapps::connectorFromCredentials($db);
348: $query = "UPDATE {$db['prefix']}users SET " .
349: implode(', ', array_key_map(static fn($k, $v) => $valid[$k] . ' = ' . $mysql->quote($v), $fields)) . " WHERE username = " . $mysql->quote($admin);
350:
351: $rs = $mysql->query($query);
352: return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s",
353: ['admin' => $admin, 'err' => $rs->errorInfo()]);
354: }
355: }