1: | <?php |
2: | |
3: | |
4: | |
5: | |
6: | |
7: | |
8: | |
9: | |
10: | |
11: | |
12: | |
13: | |
14: | use Module\Support\Webapps; |
15: | use Module\Support\Webapps\App\Type\Invoiceninja\Handler; |
16: | use Opcenter\Auth\Password; |
17: | use Opcenter\Auth\Shadow; |
18: | |
19: | |
20: | |
21: | |
22: | |
23: | |
24: | class Invoiceninja_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.txt")) { |
52: | return trim($this->file_get_file_contents("{$approot}/VERSION.txt")); |
53: | } |
54: | |
55: | $file = $this->file_exists("{$approot}/app/constants.php") ? '/app/constants.php' : '/app/Constants.php'; |
56: | try { |
57: | return Webapps\App\Type\Invoiceninja\DefineReplace::instantiateContexted($this->getAuthContextFromDocroot($approot), ["{$approot}/{$file}"])->get('NINJA_VERSION'); |
58: | } catch (\Exception $e) { |
59: | return null; |
60: | } |
61: | } |
62: | |
63: | protected function generateDatabaseStorage( |
64: | string $hostname, |
65: | string $path = '' |
66: | ): \Module\Support\Webapps\DatabaseGenerator { |
67: | $credentials = parent::generateDatabaseStorage($hostname, $path); |
68: | $credentials->hostname = '127.0.0.1'; |
69: | return $credentials; |
70: | } |
71: | |
72: | public function uninstall(string $hostname, string $path = '', string $delete = 'all'): bool |
73: | { |
74: | $approot = $this->getAppRoot($hostname, $path); |
75: | $this->removeJobs($approot); |
76: | |
77: | return parent::uninstall($hostname, $path, $delete); |
78: | } |
79: | |
80: | |
81: | public function update_all(string $hostname, string $path = '', string $version = null): bool |
82: | { |
83: | return parent::update_all($hostname, $path, $version); |
84: | } |
85: | |
86: | public function update(string $hostname, string $path = '', string $version = null): bool |
87: | { |
88: | $docroot = $this->getDocumentRoot($hostname, $path); |
89: | parent::setInfo($docroot, [ |
90: | 'failed' => true |
91: | ]); |
92: | |
93: | if (!$docroot) { |
94: | return error('update failed'); |
95: | } |
96: | $approot = $this->getAppRoot($hostname, $path); |
97: | $oldversion = $this->get_version($hostname, $path) ?? $version; |
98: | |
99: | $ret = $this->downloadVersion($approot, $version); |
100: | if ($version && $oldversion === $version || !$ret) { |
101: | return error("Failed to update %(name)s from `%(old)s' to `%(new)s', check composer.json for version restrictions", |
102: | ['name' => static::APP_NAME, 'old' => $oldversion, 'new' => $version] |
103: | ); |
104: | } |
105: | |
106: | $ret = $this->execComposer($approot, 'install -o --no-dev'); |
107: | |
108: | $this->postUpdate($hostname, $path); |
109: | parent::setInfo($docroot, [ |
110: | 'version' => $oldversion, |
111: | 'failed' => !$ret['success'] |
112: | ]); |
113: | |
114: | return $ret['success'] ?: error($ret['stderr']); |
115: | } |
116: | |
117: | protected function postInstall(string $hostname, string $path): bool |
118: | { |
119: | $approot = $this->getAppRoot($hostname, $path); |
120: | $this->execComposer($approot, 'install -o --no-plugins --no-dev'); |
121: | foreach(['key:generate', 'migrate:fresh --seed --force'] as $task) { |
122: | $ret = $this->execPhp($approot, static::BINARY_NAME . " {$task}"); |
123: | } |
124: | if (!$ret['success']) { |
125: | return error("Failed to finish %(name)s install: %(err)s", [ |
126: | 'name' => static::APP_NAME, 'err' => coalesce($ret['stderr'], $ret['stdout']) |
127: | ]); |
128: | } |
129: | |
130: | if ($this->crontab_permitted() && !$this->crontab_enabled()) { |
131: | debug("Implicitly started %(what)s", ['what' => 'Task Scheduler']); |
132: | $this->crontab_start(); |
133: | for ($i = 0; $i < 10; $i++) { |
134: | if ($this->crontab_enabled()) { |
135: | break; |
136: | } |
137: | usleep(500000); |
138: | } |
139: | } |
140: | |
141: | if ($this->crontab_enabled()) { |
142: | $formedCommand = array_get($this->execPhp( |
143: | $approot, |
144: | '-r %s', |
145: | ['echo "cd ' . escapeshellarg($approot) . ' && ", escapeshellarg(PHP_BINARY) . " ' . self::BINARY_NAME . ' schedule:run >> /dev/null 2>&1";'] |
146: | ), 'stdout'); |
147: | $this->crontab_add_job('*/5', '*', '*', '*', '*', $formedCommand); |
148: | } else { |
149: | warn("%(what)s disabled on account. Unable to schedule jobs automatically", ['what' => 'Task Scheduler']); |
150: | } |
151: | |
152: | return $this->postUpdate($hostname, $path); |
153: | } |
154: | |
155: | protected function postUpdate(string $hostname, string $path): bool |
156: | { |
157: | $approot = $this->getAppRoot($hostname, $path); |
158: | $this->execComposer($approot, 'install -o --no-dev'); |
159: | foreach (['config:cache', 'migrate --force'] as $directive) { |
160: | $this->execPhp($approot, static::BINARY_NAME . ' ' . $directive); |
161: | } |
162: | |
163: | return true; |
164: | } |
165: | |
166: | protected function notifyInstalled(string $hostname, string $path = '', array $args = []): bool |
167: | { |
168: | $args['login'] = $args['email']; |
169: | $approot = $this->getAppRoot($hostname, $path); |
170: | $this->execPhp($approot, static::BINARY_NAME . ' ninja:create-account --email=%(email)s --password=%(password)s', [ |
171: | 'email' => $args['email'], 'password' => $args['password']]); |
172: | return parent::notifyInstalled($hostname, $path, $args); |
173: | } |
174: | |
175: | |
176: | |
177: | |
178: | |
179: | |
180: | public function get_versions(): array |
181: | { |
182: | return array_values( |
183: | array_column($this->fetchPackages(), 'version') |
184: | ); |
185: | } |
186: | |
187: | protected function fetchPackages(): array |
188: | { |
189: | $key = "{$this->getAppName()}.versions"; |
190: | $cache = Cache_Super_Global::spawn(); |
191: | if (false !== ($ver = $cache->get($key))) { |
192: | return (array)$ver; |
193: | } |
194: | $versions = (new Webapps\VersionFetcher\Github)->setVersionField('tag_name')->fetch('invoiceninja/invoiceninja'); |
195: | |
196: | $versions = array_filter( |
197: | $versions, |
198: | fn($meta) => (int)$meta['version'] >= 5 && version_compare($meta['version'], '5.5.13', '>=') && |
199: | $meta['version'] !== '5.5.16' && $meta['version'] !== '5.5.82' && $meta['version'] !== '5.5.85' && |
200: | $meta['version'] !== '5.5.91' && $meta['version'] !== '5.5.109' && $meta['version'] !== '5.5.121' && |
201: | $meta['version'] !== '5.7.0' && $meta['version'] !== '5.7.25' && $meta['version'] !== '5.7.40' && |
202: | $meta['version'] !== '5.7.41'); |
203: | $cache->set($key, $versions, 43200); |
204: | return $versions; |
205: | } |
206: | |
207: | protected function parseInstallOptions(array &$options, string $hostname, string $path = ''): bool |
208: | { |
209: | if (!isset($options['password'])) { |
210: | info("autogenerated password `%s'", |
211: | $options['password'] = Password::generate() |
212: | ); |
213: | } |
214: | |
215: | return parent::parseInstallOptions($options, $hostname, $path); |
216: | } |
217: | |
218: | private function downloadVersion(string $approot, string $version): bool |
219: | { |
220: | if (null === ($meta = $this->versionMeta($version))) { |
221: | return error("Cannot locate %(app)s version %(version)s", [ |
222: | 'app' => self::APP_NAME, |
223: | 'version' => $version |
224: | ]); |
225: | } |
226: | $dlUrl = array_first($meta['assets'], static function ($asset) { |
227: | return substr($asset['name'], -4) === '.zip'; |
228: | }); |
229: | $dlUrl = $dlUrl['browser_download_url'] ?? $meta['zipball_url']; |
230: | $head = "{$approot}/" . $this->getInternalName() . '/'; |
231: | $this->download($dlUrl, "{$head}/invoice.zip"); |
232: | $entries = $this->file_get_directory_contents($head); |
233: | if (count($entries) === 1) { |
234: | $head .= "/{$entries[0]['filename']}/"; |
235: | } |
236: | if (!$this->file_copy($head, |
237: | $approot) || !$this->file_delete($head, true)) { |
238: | return false; |
239: | } |
240: | |
241: | return true; |
242: | } |
243: | |
244: | |
245: | |
246: | |
247: | |
248: | |
249: | |
250: | private function versionMeta(string $version): ?array |
251: | { |
252: | return array_first($this->fetchPackages(), static function ($meta) use ($version) { |
253: | return $meta['version'] === $version; |
254: | }); |
255: | } |
256: | |
257: | protected function createProject(string $docroot, string $package, string $version, array $opts = []): bool |
258: | { |
259: | return $this->downloadVersion($docroot, $version); |
260: | } |
261: | |
262: | public function get_admin(string $hostname, string $path = ''): ?string |
263: | { |
264: | $db = $this->db_config($hostname, $path); |
265: | $mysql = Webapps::connectorFromCredentials($db); |
266: | $query = "SELECT email FROM {$db['prefix']}users ORDER by id DESC limit 1"; |
267: | $rs = $mysql->query($query); |
268: | return $rs->rowCount() === 1 ? $rs->fetchObject()->email : null; |
269: | } |
270: | |
271: | |
272: | |
273: | |
274: | |
275: | |
276: | |
277: | public function change_admin(string $hostname, string $path, array $fields): bool |
278: | { |
279: | $docroot = $this->getAppRoot($hostname, $path); |
280: | if (!$docroot) { |
281: | return warn('failed to change administrator information'); |
282: | } |
283: | $admin = $this->get_admin($hostname, $path); |
284: | |
285: | if (!$admin) { |
286: | return error('cannot determine admin of install'); |
287: | } |
288: | |
289: | if (isset($fields['password'])) { |
290: | if (!Shadow::crypted($fields['password'])) { |
291: | if (!Password::strong($fields['password'])) { |
292: | return error("Password is insufficient strength"); |
293: | } |
294: | $fields['password'] = password_hash($fields['password'], CRYPT_BLOWFISH); |
295: | } else if (!Shadow::valid_crypted($fields['password'])) { |
296: | |
297: | return false; |
298: | } |
299: | } |
300: | |
301: | if (isset($fields['email']) && !preg_match(Regex::EMAIL, $fields['email'])) { |
302: | return error("Invalid email"); |
303: | } |
304: | |
305: | if (isset($fields['user']) && !preg_match(Regex::USERNAME, $fields['user'])) { |
306: | return error("Invalid user"); |
307: | } |
308: | |
309: | $valid = [ |
310: | 'fname' => 'first_name', |
311: | 'lname' => 'last_name', |
312: | 'email' => 'email', |
313: | 'password' => 'password' |
314: | ]; |
315: | |
316: | if ($unrecognized = array_diff_key($fields, $valid)) { |
317: | return error("Unrecognized fields: %s", implode(array_keys($unrecognized))); |
318: | } |
319: | |
320: | if (!$match = array_intersect_key($valid, $fields)) { |
321: | return warn("No fields updated"); |
322: | } |
323: | |
324: | $fields = array_intersect_key($fields, $match); |
325: | $db = $this->db_config($hostname, $path); |
326: | $admin = $this->get_admin($hostname, $path); |
327: | $mysql = Webapps::connectorFromCredentials($db); |
328: | $query = "UPDATE {$db['prefix']}users SET " . |
329: | implode(', ', array_key_map(static fn($k, $v) => $valid[$k] . ' = ' . $mysql->quote($v), $fields)) . " WHERE email = " . $mysql->quote($admin); |
330: | |
331: | $rs = $mysql->query($query); |
332: | return $rs->rowCount() > 0 ? true : error("Failed to update admin `%(admin)s', error: %(err)s", |
333: | ['admin' => $admin, 'err' => $rs->errorInfo()]); |
334: | } |
335: | |
336: | public function valid(string $hostname, string $path = ''): bool |
337: | { |
338: | if ($hostname[0] === '/') { |
339: | if (!($path = realpath($this->domain_fs_path($hostname)))) { |
340: | return false; |
341: | } |
342: | $approot = \dirname($path); |
343: | } else { |
344: | $approot = $this->getAppRoot($hostname, $path); |
345: | if (!$approot) { |
346: | return false; |
347: | } |
348: | $approot = $this->domain_fs_path($approot); |
349: | } |
350: | |
351: | return is_dir($approot . '/app/Ninja') || file_exists($approot . '/app/Jobs/Ninja'); |
352: | } |
353: | } |