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: function(&$item) {
149: $version = $item['version'];
150: if ($version[0] !== 'v' || $version === 'v24.02.1') {
151: return false;
152: }
153:
154: $item['version'] = substr($version, 1);
155: return;
156: }
157: )
158: );
159: $cache->set($key, $versions, 43200);
160: return $versions;
161: }
162:
163: protected function parseInstallOptions(array &$options, string $hostname, string $path = ''): bool
164: {
165: if (!isset($options['user'])) {
166: $options['user'] = $this->username;
167: }
168: info("setting admin user to `%s'", $options['user']);
169: if (!isset($options['password'])) {
170: info("autogenerated password `%s'",
171: $options['password'] = Password::generate()
172: );
173: }
174:
175: return parent::parseInstallOptions($options, $hostname, $path);
176: }
177:
178: private function downloadVersion(string $approot, string $version): bool
179: {
180: if (null === ($meta = $this->versionMeta($version))) {
181: return error("Cannot locate %(app)s version %(version)s", [
182: 'app' => self::APP_NAME,
183: 'version' => $version
184: ]);
185: }
186: $dlUrl = array_first($meta['assets'], static function ($asset) {
187: return substr($asset['name'], -4) === '.zip';
188: });
189: $dlUrl = $dlUrl['browser_download_url'] ?? $meta['zipball_url'];
190: $this->download($dlUrl, "$approot/bookstack-tmp/bookstack.zip");
191: $root = $this->file_get_directory_contents("{$approot}/bookstack-tmp/")[0]['filename'];
192:
193: if (!$this->file_copy("{$approot}/bookstack-tmp/{$root}/",
194: $approot) || !$this->file_delete("$approot/bookstack-tmp", true)) {
195: return false;
196: }
197:
198: return true;
199: }
200:
201: /**
202: * Release meta
203: *
204: * @param string $version
205: * @return array|null
206: */
207: private function versionMeta(string $version): ?array
208: {
209: return array_first($this->fetchPackages(), static function ($meta) use ($version) {
210: return version_compare($meta['version'], $version, '=');
211: });
212: }
213:
214: protected function createProject(string $docroot, string $package, string $version, array $opts = []): bool
215: {
216: return $this->downloadVersion($docroot, $version);
217: }
218:
219: public function get_admin(string $hostname, string $path = ''): ?string
220: {
221: $db = $this->db_config($hostname, $path);
222: $mysql = Webapps::connectorFromCredentials($db);
223: $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";
224: $rs = $mysql->query($query);
225: return $rs->rowCount() === 1 ? $rs->fetchObject()->email : null;
226: }
227:
228: /**
229: * @param string $hostname
230: * @param string $path
231: * @param array $fields available option: password, user, email
232: * @return bool
233: */
234: public function change_admin(string $hostname, string $path, array $fields): bool
235: {
236: $docroot = $this->getAppRoot($hostname, $path);
237: if (!$docroot) {
238: return warn('failed to change administrator information');
239: }
240: $admin = $this->get_admin($hostname, $path);
241:
242: if (!$admin) {
243: return error('cannot determine admin of install');
244: }
245:
246: if (isset($fields['password'])) {
247: if (!Shadow::crypted($fields['password'])) {
248: if (!Password::strong($fields['password'])) {
249: return error("Password is insufficient strength");
250: }
251: $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH);
252: } else if (!Shadow::valid_crypted($fields['password'])) {
253: // error generated from fn
254: return false;
255: }
256: }
257:
258: if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) {
259: return error("Invalid email");
260: }
261:
262: if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) {
263: return error("Invalid user");
264: }
265:
266: $valid = [
267: 'user' => 'name',
268: 'email' => 'email',
269: 'password' => 'password'
270: ];
271:
272: if ($unrecognized = array_diff_key($fields, $valid)) {
273: return error("Unrecognized fields: %s", implode(array_keys($unrecognized)));
274: }
275:
276: if (!$match = array_intersect_key($valid, $fields)) {
277: return warn("No fields updated");
278: }
279:
280: $fields = array_intersect_key($fields, $match);
281: $db = $this->db_config($hostname, $path);
282: $admin = $this->get_admin($hostname, $path);
283: $mysql = Webapps::connectorFromCredentials($db);
284: $query = "UPDATE {$db['prefix']}users SET " .
285: implode(', ', array_key_map(static fn($k, $v) => $valid[$k] . ' = ' . $mysql->quote($v), $fields)) . " WHERE email = " . $mysql->quote($admin);
286:
287: $rs = $mysql->query($query);
288: return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s",
289: ['admin' => $admin, 'err' => $rs->errorInfo()]);
290: }
291:
292: public function valid(string $hostname, string $path = ''): bool
293: {
294: if ($hostname[0] === '/') {
295: if (!($path = realpath($this->domain_fs_path($hostname)))) {
296: return false;
297: }
298: $approot = \dirname($path);
299: } else {
300: $approot = $this->getAppRoot($hostname, $path);
301: if (!$approot) {
302: return false;
303: }
304: $approot = $this->domain_fs_path($approot);
305: }
306:
307: return file_exists($approot . '/app/Repos/BookRepo.php') || file_exists($approot . '/app/Entities/Repos/BookRepo.php');
308: }
309: }