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: | 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: | |
173: | |
174: | |
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: | |
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: | |
242: | |
243: | |
244: | |
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: | |
269: | |
270: | |
271: | |
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: | |
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: | } |