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\Bookstack\Handler;
16: use Opcenter\Auth\Password;
17: use Opcenter\Auth\Shadow;
18:
19: /**
20: * BookStack management
21: *
22: * @package core
23: */
24: class Bookstack_Module extends Laravel_Module
25: {
26: const DEFAULT_VERSION_LOCK = 'major';
27: const APP_NAME = Handler::NAME;
28: const VALIDITY_FILE = null;
29:
30: protected $aclList = array(
31: 'min' => array(
32: 'bootstrap/cache',
33: 'storage',
34: 'public/uploads'
35: ),
36: 'max' => array(
37: 'bootstrap/cache',
38: 'storage',
39: 'public/uploads'
40: )
41: );
42:
43: public function install(string $hostname, string $path = '', array $opts = array()): bool
44: {
45: return parent::install($hostname, $path, $opts);
46: }
47:
48: public function get_version(string $hostname, string $path = ''): ?string
49: {
50: $approot = $this->getAppRoot($hostname, $path);
51: if (!$this->file_exists("{$approot}/version")) {
52: return null;
53: }
54:
55: return trim($this->file_get_file_contents("{$approot}/version"), "v\n");
56: }
57:
58: public function update_all(string $hostname, string $path = '', string $version = null): bool
59: {
60: return parent::update_all($hostname, $path, $version);
61: }
62:
63: public function update(string $hostname, string $path = '', string $version = null): bool
64: {
65: $docroot = $this->getDocumentRoot($hostname, $path);
66: parent::setInfo($docroot, [
67: 'failed' => true
68: ]);
69:
70: if (!$docroot) {
71: return error('update failed');
72: }
73: $approot = $this->getAppRoot($hostname, $path);
74: $oldversion = $this->get_version($hostname, $path) ?? $version;
75:
76: $ret = $this->downloadVersion($approot, $version);
77: if ($version && $oldversion === $version || !$ret) {
78: return error("Failed to update %(name)s from `%(old)s' to `%(new)s', check composer.json for version restrictions",
79: ['name' => static::APP_NAME, 'old' => $oldversion, 'new' => $version]
80: );
81: }
82: $ret = $this->execComposer($approot, 'install -o --no-dev');
83:
84: $this->postUpdate($hostname, $path);
85: parent::setInfo($docroot, [
86: 'version' => $oldversion,
87: 'failed' => !$ret['success']
88: ]);
89:
90: return $ret['success'] ?: error($ret['stderr']);
91: }
92:
93:
94: protected function postInstall(string $hostname, string $path): bool
95: {
96: $approot = $this->getAppRoot($hostname, $path);
97: $this->execComposer($approot, 'install --no-dev');
98: foreach(['key:generate'] as $task) {
99: $ret = $this->execPhp($approot, static::BINARY_NAME . ' %s', [$task]);
100: }
101: if (!$ret['success']) {
102: return error("Failed to finish %(name)s install: %(err)s", [
103: 'name' => static::APP_NAME, 'err' => coalesce($ret['stderr'], $ret['stdout'])
104: ]);
105: }
106:
107: return $this->postUpdate($hostname, $path);
108: }
109:
110: protected function postUpdate(string $hostname, string $path): bool
111: {
112: $approot = $this->getAppRoot($hostname, $path);
113: $this->execComposer($approot, 'install --no-dev');
114: foreach (['migrate', 'cache:clear', 'vendor:publish'] as $directive) {
115: $this->execPhp($approot, static::BINARY_NAME . ' ' . $directive);
116: }
117:
118: return true;
119: }
120:
121: protected function notifyInstalled(string $hostname, string $path = '', array $args = []): bool
122: {
123: $args['login'] ??= $args['user'];
124: $this->change_admin($hostname, $path, ['user' => $args['user'], 'email' => $args['email'], 'password' => $args['password']]);
125: return parent::notifyInstalled($hostname, $path, $args);
126: }
127:
128: /**
129: * Get BookStack framework versions
130: *
131: * @return array
132: */
133: public function get_versions(): array
134: {
135: return array_column($this->fetchPackages(), 'version');
136: }
137:
138: protected function fetchPackages(): array
139: {
140: $key = "{$this->getAppName()}.versions";
141: $cache = Cache_Super_Global::spawn();
142: if (false !== ($ver = $cache->get($key))) {
143: return (array)$ver;
144: }
145: $versions = array_filter(
146: (new Webapps\VersionFetcher\Github)->setVersionField('tag_name')->fetch(
147: 'BookStackApp/BookStack',
148: fn($version) => $version[0] === 'v' && $version !== 'v24.02.1' ? $version : null
149: )
150: );
151: $cache->set($key, $versions, 43200);
152: return $versions;
153: }
154:
155: protected function parseInstallOptions(array &$options, string $hostname, string $path = ''): bool
156: {
157: if (!isset($options['user'])) {
158: $options['user'] = $this->username;
159: }
160: info("setting admin user to `%s'", $options['user']);
161: if (!isset($options['password'])) {
162: info("autogenerated password `%s'",
163: $options['password'] = Password::generate()
164: );
165: }
166:
167: return parent::parseInstallOptions($options, $hostname, $path);
168: }
169:
170: private function downloadVersion(string $approot, string $version): bool
171: {
172: if (null === ($meta = $this->versionMeta($version))) {
173: return error("Cannot locate %(app)s version %(version)s", [
174: 'app' => self::APP_NAME,
175: 'version' => $version
176: ]);
177: }
178: $dlUrl = array_first($meta['assets'], static function ($asset) {
179: return substr($asset['name'], -4) === '.zip';
180: });
181: $dlUrl = $dlUrl['browser_download_url'] ?? $meta['zipball_url'];
182: $this->download($dlUrl, "$approot/bookstack-tmp/bookstack.zip");
183: $root = $this->file_get_directory_contents("{$approot}/bookstack-tmp/")[0]['filename'];
184:
185: if (!$this->file_copy("{$approot}/bookstack-tmp/{$root}/",
186: $approot) || !$this->file_delete("$approot/bookstack-tmp", true)) {
187: return false;
188: }
189:
190: return true;
191: }
192:
193: /**
194: * Release meta
195: *
196: * @param string $version
197: * @return array|null
198: */
199: private function versionMeta(string $version): ?array
200: {
201: return array_first($this->fetchPackages(), static function ($meta) use ($version) {
202: return version_compare($meta['version'], $version, '=');
203: });
204: }
205:
206: protected function createProject(string $docroot, string $package, string $version, array $opts = []): bool
207: {
208: return $this->downloadVersion($docroot, $version);
209: }
210:
211: public function get_admin(string $hostname, string $path = ''): ?string
212: {
213: $db = $this->db_config($hostname, $path);
214: $mysql = Webapps::connectorFromCredentials($db);
215: $query = "SELECT email FROM users JOIN {$db['prefix']}role_user ON ({$db['prefix']}role_user.user_id = {$db['prefix']}users.id) JOIN {$db['prefix']}roles ON ({$db['prefix']}roles.id = {$db['prefix']}role_user.role_id) WHERE {$db['prefix']}roles.system_name = 'admin' LIMIT 1";
216: $rs = $mysql->query($query);
217: return $rs->rowCount() === 1 ? $rs->fetchObject()->email : null;
218: }
219:
220: /**
221: * @param string $hostname
222: * @param string $path
223: * @param array $fields available option: password, user, email
224: * @return bool
225: */
226: public function change_admin(string $hostname, string $path, array $fields): bool
227: {
228: $docroot = $this->getAppRoot($hostname, $path);
229: if (!$docroot) {
230: return warn('failed to change administrator information');
231: }
232: $admin = $this->get_admin($hostname, $path);
233:
234: if (!$admin) {
235: return error('cannot determine admin of install');
236: }
237:
238: if (isset($fields['password'])) {
239: if (!Shadow::crypted($fields['password'])) {
240: if (!Password::strong($fields['password'])) {
241: return error("Password is insufficient strength");
242: }
243: $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH);
244: } else if (!Shadow::valid_crypted($fields['password'])) {
245: // error generated from fn
246: return false;
247: }
248: }
249:
250: if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) {
251: return error("Invalid email");
252: }
253:
254: if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) {
255: return error("Invalid user");
256: }
257:
258: $valid = [
259: 'user' => 'name',
260: 'email' => 'email',
261: 'password' => 'password'
262: ];
263:
264: if ($unrecognized = array_diff_key($fields, $valid)) {
265: return error("Unrecognized fields: %s", implode(array_keys($unrecognized)));
266: }
267:
268: if (!$match = array_intersect_key($valid, $fields)) {
269: return warn("No fields updated");
270: }
271:
272: $fields = array_intersect_key($fields, $match);
273: $db = $this->db_config($hostname, $path);
274: $admin = $this->get_admin($hostname, $path);
275: $mysql = Webapps::connectorFromCredentials($db);
276: $query = "UPDATE {$db['prefix']}users SET " .
277: implode(', ', array_key_map(static fn($k, $v) => $valid[$k] . ' = ' . $mysql->quote($v), $fields)) . " WHERE email = " . $mysql->quote($admin);
278:
279: $rs = $mysql->query($query);
280: return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s",
281: ['admin' => $admin, 'err' => $rs->errorInfo()]);
282: }
283:
284: public function valid(string $hostname, string $path = ''): bool
285: {
286: if ($hostname[0] === '/') {
287: if (!($path = realpath($this->domain_fs_path($hostname)))) {
288: return false;
289: }
290: $approot = \dirname($path);
291: } else {
292: $approot = $this->getAppRoot($hostname, $path);
293: if (!$approot) {
294: return false;
295: }
296: $approot = $this->domain_fs_path($approot);
297: }
298:
299: return file_exists($approot . '/app/Repos/BookRepo.php') || file_exists($approot . '/app/Entities/Repos/BookRepo.php');
300: }
301: }