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