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: 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: * Get Invoice Ninja versions
177: *
178: * @return array
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: // failed for various reasons, including dep failures against 8.2 or invalid VERSION.txt fields
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: * Release meta
246: *
247: * @param string $version
248: * @return array|null
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: * @param string $hostname
273: * @param string $path
274: * @param array $fields available option: password, user, email
275: * @return bool
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: // error generated from fn
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: }