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\App\Type\Laravel\Messages as LaravelMessages;
15: use Module\Support\Webapps\Composer;
16: use Module\Support\Webapps\ComposerMetadata;
17: use Module\Support\Webapps\ComposerWrapper;
18: use Module\Support\Webapps\Messages;
19: use Module\Support\Webapps\Traits\PublicRelocatable;
20: use Opcenter\Provisioning\ConfigurationWriter;
21:
22: /**
23: * Laravel management
24: *
25: * An interface to wp-cli
26: *
27: * @package core
28: */
29: class Laravel_Module extends Composer
30: {
31: use PublicRelocatable;
32: const APP_NAME = 'Laravel';
33: const PACKAGIST_NAME = 'laravel/laravel';
34: const BINARY_NAME = 'artisan';
35: const VALIDITY_FILE = self::BINARY_NAME;
36: const DEFAULT_VERSION_LOCK = 'minor';
37:
38: protected $aclList = array(
39: 'min' => array(
40: 'storage',
41: 'bootstrap/cache'
42: ),
43: 'max' => array(
44: 'storage/framework/cache',
45: 'storage/framework/views',
46: 'storage/framework/sessions',
47: 'storage/logs',
48: 'storage/app/public',
49: 'bootstrap/cache'
50: )
51: );
52:
53: /**
54: * Install Laravel into a pre-existing location
55: *
56: * @param string $hostname domain or subdomain to install Laravel
57: * @param string $path optional path under hostname
58: * @param array $opts additional install options
59: * @return bool
60: */
61: public function install(string $hostname, string $path = '', array $opts = array()): bool
62: {
63: if (!$this->mysql_enabled()) {
64: return error(Messages::ERR_INSTALL_MISSING_PREREQ,
65: ['what' => 'MySQL', 'app' => static::APP_NAME]);
66: }
67: if (!version_compare($this->php_version(), '7', '>=')) {
68: return error(Messages::ERR_INSTALL_MISSING_PREREQ, [
69: 'app' => static::APP_NAME, 'what' => 'PHP7'
70: ]);
71: }
72:
73: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
74: return false;
75: }
76:
77: $docroot = $this->getDocumentRoot($hostname, $path);
78:
79: // uninstall may relink public/
80: $args['version'] = $opts['version'];
81:
82: if (!$this->createProject($docroot, static::PACKAGIST_NAME, $opts['version'])) {
83: return false;
84: }
85:
86: if (null === ($docroot = $this->remapPublic($hostname, $path))) {
87: $this->file_delete($this->getDocumentRoot($hostname, $path), true);
88: return error(Messages::ERR_PATH_REMAP_FAILED,
89: ['app' => static::APP_NAME, 'path' => $docroot, 'public' => static::RELOCATED_DOCROOT_NAME]);
90: }
91:
92: $oldex = \Error_Reporter::exception_upgrade();
93: $approot = $this->getAppRoot($hostname, $path);
94:
95: try {
96: // handle the xn-- in punycode domains
97: $composerHostname = preg_replace("/-{2,}/", '-', $hostname);
98: $this->execComposer($approot, 'config name %(hostname)s/%(app)s', [
99: 'app' => $this->getInternalName(),
100: 'hostname' => $composerHostname
101: ]);
102: $docroot = $this->getDocumentRoot($hostname, $path);
103:
104: // ensure it's reachable
105: if (self::class === static::class) {
106: // Laravel
107: $this->_fixCache($approot);
108: }
109:
110: $db = $this->generateDatabaseStorage($hostname, $path);
111:
112: if (!$db->create()) {
113: return false;
114: }
115: $fqdn = $this->web_normalize_hostname($hostname);
116: $args['uri'] = rtrim($fqdn . '/' . $path, '/');
117: $args['proto'] = empty($opts['ssl']) ? 'http://' : 'https://';
118:
119: if (!$this->setConfiguration($approot, $docroot, array_merge([
120: 'dbname' => $db->database,
121: 'dbuser' => $db->username,
122: 'dbpassword' => $db->password,
123: 'dbhost' => $db->hostname,
124: 'dbprefix' => $db->prefix,
125: 'email' => $opts['email'],
126: 'user' => $opts['user'],
127: 'login' => $opts['login'] ?? $opts['user'],
128: 'password' => $opts['password'] ?? ''
129: ], $args))) {
130: return error(Messages::ERR_DATABASE_CONFIGURATION_FAILED);
131: }
132:
133: } catch (\apnscpException $e) {
134: if (empty($opts['keep']) && isset($db)) {
135: $db->rollback();
136: }
137: return error(Messages::ERR_APP_INSTALL_FAILED, [
138: 'app' => static::APP_NAME, 'err' => $e->getMessage()
139: ]);
140: } finally {
141: \Error_Reporter::exception_upgrade($oldex);
142: }
143:
144: $this->postInstall($hostname, $path);
145:
146: $this->fixRewriteBase($docroot);
147:
148: $this->buildConfig($approot, $docroot);
149:
150: $this->notifyInstalled($hostname, $path, $opts);
151:
152: return info(Messages::MSG_CHECKPOINT_APP_INSTALLED,
153: ['app' => static::APP_NAME, 'email' => $opts['email']]);
154: }
155:
156: protected function generateDatabaseStorage(string $hostname, string $path = ''): \Module\Support\Webapps\DatabaseGenerator {
157: return \Module\Support\Webapps\DatabaseGenerator::mysql($this->getAuthContext(), $hostname);
158: }
159:
160: protected function createProject(string $docroot, string $package, string $version, array $opts = []): bool
161: {
162: // Laravel bootstraps itself with laravel/laravel (laravel/framework main versioning)
163: // Flarum with flarum/flarum (flarum/core main versioning)
164: // Make a risky assumption the installation will always reflect the framework version
165: $installerVer = $this->latestMatchingPackage($this->updateLibraryName($docroot), $version, $package);
166: $opts = \Opcenter\CliParser::buildFlags($opts + ['prefer-dist' => true, 'no-install' => true, 'no-scripts' => true]);
167: $ret = $this->execComposer($docroot,
168: 'create-project ' . $opts . ' %(package)s %(docroot)s \'%(version)s\'',
169: [
170: 'package' => $package,
171: 'docroot' => $docroot,
172: 'version' => $installerVer
173: ]
174: );
175: $metadata = ComposerMetadata::read($ctx = $this->getAuthContextFromDocroot($docroot), $docroot);
176: array_set($metadata, 'require.' . $this->updateLibraryName($docroot), $version);
177: $metadata->sync();
178: $ret = \Module\Support\Webapps\ComposerWrapper::instantiateContexted($ctx)->exec($docroot,
179: 'update -W');
180:
181: return $ret['success'] ?:
182: error(Messages::ERR_APP_DOWNLOAD_FAILED_STD, [
183: 'app' => static::APP_NAME, 'version' => $version, 'stderr' => $ret['stderr'], 'stdout' => $ret['stdout']
184: ]
185: );
186: }
187:
188: protected function postInstall(string $hostname, string $path): bool
189: {
190: $approot = $this->getAppRoot($hostname, $path);
191: $version = $this->get_version($hostname, $path);
192: $commands = [
193: 'key:generate',
194: 'migrate',
195: \Opcenter\Versioning::compare($version, '10', '<') ? 'queue:seed' : null,
196: \Opcenter\Versioning::compare($version, '9',
197: '>=') ? 'vendor:publish --tag=laravel-assets --no-ansi' : null,
198: ];
199: foreach ($commands as $cmd) {
200: if (!$cmd) {
201: continue;
202: }
203: $this->execPhp($approot, './' . static::BINARY_NAME . ' ' . $cmd);
204: }
205:
206: if (!$this->file_exists($approot . '/public/storage')) {
207: $this->file_symlink($approot . '/storage/app/public', $approot . '/public/storage');
208: $this->file_chown_symlink($approot . '/public/storage',
209: $this->file_stat($approot . '/storage/app/public')['owner']);
210: }
211:
212: return true;
213: }
214:
215: protected function checkVersion(array &$options): bool
216: {
217: $versions = $this->get_installable_versions();
218: if (!isset($options['version'])) {
219: $options['version'] = array_pop($versions);
220: }
221: if (!parent::checkVersion($options)) {
222: return false;
223: } else if (self::class !== static::class) {
224: return true;
225: }
226: $phpversion = $this->php_pool_get_version();
227:
228: $cap = null;
229: if (version_compare($phpversion, '5.6.4', '<')) {
230: $cap = '5.3';
231: } else if (version_compare($phpversion, '7.0.0', '<')) {
232: $cap = '5.4';
233: } else if (version_compare($phpversion, '7.1.3', '<')) {
234: $cap = '5.5';
235: } else if (version_compare($phpversion, '8.0', '<')) {
236: $cap = '8';
237: } else if (version_compare($phpversion, '8.2', '<')) {
238: $cap = '10';
239: }
240:
241: if ($cap && Opcenter\Versioning::compare($options['version'], $cap, '>')) {
242: info(Messages::MSG_VERSION_CAP_APPLIED, [
243: 'what' => 'PHP', 'version' => $phpversion, 'app' => static::APP_NAME, 'cap' => $cap
244: ]);
245: $options['version'] = \Opcenter\Versioning::maxVersion($versions, $cap);
246: }
247:
248: return true;
249: }
250:
251: /**
252: * Inject custom bootstrapper
253: *
254: * @param $approot
255: * @return bool|int|void
256: */
257: protected function _fixCache($approot)
258: {
259: $file = $this->domain_fs_path() . '/' . $approot . '/app/ApplicationWrapper.php';
260: $tmpfile = tempnam($this->domain_fs_path() . '/tmp', 'appwrapper');
261: chmod($tmpfile, 0644);
262: if (!copy(resource_path('storehouse/laravel/ApplicationWrapper.php'), $tmpfile)) {
263: return warn(LaravelMessages::FAILED_TO_COPY_OPTIMIZED_CACHE_BOOTSTRAP);
264: }
265: if (!posix_getuid()) {
266: chown($tmpfile, File_Module::UPLOAD_UID);
267: }
268:
269:
270: $this->file_endow_upload(basename($tmpfile));
271: $this->file_move($this->file_unmake_path($tmpfile), $approot . '/app/ApplicationWrapper.php');
272:
273: $file = dirname(dirname($file)) . '/bootstrap/app.php';
274: if (!file_exists($file)) {
275: return error(Messages::ERR_UNABLE_TO_MODIFY_APP_FILE, ['file' => $file, 'app' => static::APP_NAME]);
276: }
277: $contents = file_get_contents($file);
278: $contents = preg_replace('/new\sIlluminate\\\\Foundation\\\\Application/m', 'new App\\ApplicationWrapper',
279: $contents);
280: if (!$this->file_put_file_contents($this->file_unmake_path($file), $contents)) {
281: return false;
282: }
283: $ret = $this->execComposer($approot, 'dumpautoload -o');
284:
285: return $ret['success'];
286: }
287:
288: protected function setConfiguration(string $approot, string $docroot, array $config)
289: {
290: $envcfg = (new ConfigurationWriter('@webapp(' . $this->getAppName() . ')::templates.env',
291: \Opcenter\SiteConfiguration::shallow($this->getAuthContext())))
292: ->compile($config);
293: $this->file_put_file_contents("{$approot}/.env", (string)$envcfg);
294:
295: return $this->buildConfig($approot, $docroot);
296: }
297:
298: /**
299: * Rebuild config and force frontend cache
300: *
301: * @param string $approot
302: * @param string $docroot
303: * @return bool
304: */
305: private function buildConfig(string $approot, string $docroot): bool
306: {
307: if (static::class !== self::class) {
308: return true;
309: }
310:
311: $ret = $this->execPhp($approot, static::BINARY_NAME . ' config:cache');
312: if (!$ret['success']) {
313: return error(LaravelMessages::CONFIG_REBUILD_FAILED, coalesce($ret['stderr'], $ret['stdout']));
314: }
315: if (!$this->php_jailed()) {
316: return true;
317: }
318:
319: if (!($uri = $this->web_get_hostname_from_docroot($docroot))) {
320: return error(Messages::ERR_HOSTNAME_FROM_DOCROOT, $docroot);
321: }
322: $uri = $this->web_normalize_hostname($uri);
323: $ctx = stream_context_create(array(
324: 'http' =>
325: array(
326: 'timeout' => 5,
327: 'method' => 'HEAD',
328: 'header' => [
329: 'User-agent: ' . PANEL_BRAND . ' Internal check',
330: "Host: {$uri}"
331: ],
332: 'protocol_version' => '1.1'
333: )
334: ));
335:
336: return (bool)@get_headers('http://' . $this->site_ip_address(), 0, $ctx) ?:
337: warn(LaravelMessages::CACHE_REQUEST_FAILED, $uri);
338: }
339:
340: protected function lumenSubtype(string $appRoot): bool
341: {
342: $file = $appRoot . '/composer.lock';
343: if (!$this->file_exists($file)) {
344: return false;
345: }
346:
347: $content = (string)$this->file_get_file_contents($file);
348:
349: return false !== strpos($content, "laravel/lumen-framework");
350: }
351:
352: /**
353: * Get installed version
354: *
355: * @param string $hostname
356: * @param string $path
357: * @return string version number
358: */
359: public function get_version(string $hostname, string $path = ''): ?string
360: {
361: // laravel/laravel installs laravel/illuminate, which breaks the composer helper
362: $approot = $this->getAppRoot($hostname, $path);
363:
364: if (!$this->valid($hostname, $path)) {
365: return null;
366: }
367:
368: if ($this->lumenSubtype($approot)) {
369: $meta = array_first(ComposerMetadata::readFrozen($this->getAuthContextFromDocroot($approot), $approot)->packages(), function ($package) {
370: return $package['name'] === 'laravel/lumen-framework';
371: });
372: return $meta ? substr($meta['version'], 1) : null;
373: }
374:
375: $ret = $this->execPhp($approot, './' . static::BINARY_NAME . ' --version');
376: if (!$ret['success']) {
377: return null;
378: }
379:
380: return rtrim(substr($ret['stdout'], strrpos($ret['stdout'], ' ')+1));
381: }
382:
383: /**
384: * Location is a valid Laravel install
385: *
386: * @param string $hostname or $docroot
387: * @param string $path
388: * @return bool
389: */
390: public function valid(string $hostname, string $path = ''): bool
391: {
392: if ($hostname[0] === '/') {
393: if (!($path = realpath($this->domain_fs_path($hostname)))) {
394: return false;
395: }
396: $approot = \dirname($path);
397: } else {
398: $approot = $this->getAppRoot($hostname, $path);
399: if (!$approot) {
400: return false;
401: }
402: $approot = $this->domain_fs_path($approot);
403: }
404:
405: return file_exists($approot . '/' . static::BINARY_NAME);
406: }
407:
408: /**
409: * Get Laravel framework versions
410: *
411: * @return array
412: */
413: public function get_versions(): array
414: {
415: return parent::getPackagistVersions('laravel/framework');
416: }
417:
418: /**
419: * Get installable versions
420: *
421: * Checks laravel/laravel bootstrapper package
422: *
423: * @return array
424: */
425: public function get_installable_versions(): array
426: {
427: if (self::class === static::class) {
428: return parent::getPackagistVersions('laravel/framework');
429: }
430:
431: return $this->get_versions();
432: }
433:
434: /**
435: * Get database configuration for a blog
436: *
437: * @param string $hostname domain or subdomain of wp blog
438: * @param string $path optional path
439: * @return array|bool
440: */
441: public function db_config(string $hostname, string $path = '')
442: {
443: $this->web_purge();
444: $approot = $this->getAppRoot($hostname, $path);
445: if (!$approot) {
446: return error(Messages::ERR_DETECTION_ASSERTION_FAILED, ['hostname' => $hostname, 'path' => $path, 'app' => $this->getAppName()]);
447: }
448: if (!$this->file_exists($approot . '/bootstrap/cache/config.php')) {
449: if ($this->file_exists($approot . '/.env')) {
450:
451: // barfs if value contains unquoted '='
452: $ini = parse_ini_string($this->file_get_file_contents($approot . '/.env'), false, INI_SCANNER_RAW);
453: return [
454: 'host' => $ini['DB_HOST'] ?? 'localhost',
455: 'prefix' => $ini['DB_PREFIX'] ?? '',
456: 'user' => $ini['DB_USERNAME'] ?? $this->username,
457: 'password' => $ini['DB_PASSWORD'] ?? '',
458: 'db' => $ini['DB_DATABASE'] ?? null,
459: 'type' => $ini['DB_CONNECTION'] ?? 'mysql',
460: 'port' => ((int)$ini['DB_PORT'] ?? 0) ?: null
461: ];
462: }
463: if (!$this->php_jailed()) {
464: // prime it
465: warn(LaravelMessages::CACHE_NOT_FOUND_PRIMING_WITH_REQUEST);
466: try {
467: (new \HTTP\SelfReferential($hostname, $this->site_ip_address()))->get($path);
468: } catch (\GuzzleHttp\Exception\RequestException $e) {
469: return error('Self-referential request failed: %s', $e->getMessage());
470: }
471: } else {
472: $this->buildConfig($approot, $this->getDocumentRoot($hostname, $path));
473: }
474: }
475:
476: $code = '$cfg = (include("./bootstrap/cache/config.php"))["database"]; $db=$cfg["connections"][$cfg["default"]]; ' .
477: 'print serialize(array("user" => $db["username"], "password" => $db["password"], "db" => $db["database"], ' .
478: '"host" => $db["host"], "prefix" => $db["prefix"], "type" => $db["driver"]));';
479: $cmd = 'cd %(path)s && php -d mysqli.default_socket=' . escapeshellarg(ini_get('mysqli.default_socket')) . ' -r %(code)s';
480: $ret = $this->pman_run($cmd, array('path' => $approot, 'code' => $code));
481:
482: if (!$ret['success']) {
483: return error(Messages::ERR_WEBAPP_DATABASE_APPROOT_FETCH_FAILED, ['app' => static::APP_NAME, 'approot' => $approot]);
484: }
485: $data = \Util_PHP::unserialize($ret['stdout']);
486:
487: return $data;
488: }
489:
490: public function update_all(string $hostname, string $path = '', string $version = null): bool
491: {
492: return $this->update($hostname, $path, $version) || error(Messages::ERR_ALL_COMPONENTS_FAILED_UPDATE);
493: }
494:
495: /**
496: * Update Laravel to latest version
497: *
498: * @param string $hostname domain or subdomain under which WP is installed
499: * @param string $path optional subdirectory
500: * @param string $version version to upgrade
501: * @return bool
502: */
503: public function update(string $hostname, string $path = '', string $version = null): bool
504: {
505: $docroot = $this->getDocumentRoot($hostname, $path);
506: parent::setInfo($docroot, [
507: 'failed' => true
508: ]);
509: if (!$docroot) {
510: return error('update failed');
511: }
512: $approot = $this->getAppRoot($hostname, $path);
513: $oldversion = $this->get_version($hostname, $path) ?? $version;
514:
515: $metadata = ComposerMetadata::read($ctx = $this->getAuthContextFromDocroot($approot), $approot);
516: array_set($metadata, 'require.' . $this->updateLibraryName($approot), $version ?: '*');
517: $metadata->sync();
518:
519: $cmd = 'update --no-plugins -a -W ';
520: $ret = $this->execComposer($approot, $cmd);
521: if ($version && $oldversion === $version || !$ret['success']) {
522: // @TODO add Composer output
523: return error(Messages::ERR_UPDATE_FAILED,
524: ['app' => static::APP_NAME, 'old' => $oldversion, 'new' => $version]
525: );
526: }
527:
528: // update composer.json versioning after update
529: defer($_, fn() => array_set(
530: $metadata,
531: "require.{$this->updateLibraryName($approot)}",
532: $this->parseLock($this->get_reconfigurable($hostname, $path, 'verlock'), $version ?? $this->get_version($hostname, $path))
533: ));
534:
535: $this->postUpdate($hostname, $path);
536: parent::setInfo($docroot, [
537: 'version' => $oldversion,
538: 'failed' => !$ret['success']
539: ]);
540:
541: return $ret['success'] ?: error($ret['stderr']);
542: }
543:
544: protected function postUpdate(string $hostname, string $path): bool
545: {
546: $approot = $this->getAppRoot($hostname, $path);
547: $version = $this->get_version($hostname, $path);
548: $commands = [
549: 'migrate',
550: \Opcenter\Versioning::compare($version, '9',
551: '>=') ? 'vendor:publish --tag=laravel-assets --no-ansi' : null,
552: ];
553: foreach ($commands as $cmd) {
554: if (!$cmd) {
555: continue;
556: }
557: $this->execPhp($approot, './' . static::BINARY_NAME . ' ' . $cmd);
558: }
559:
560: return true;
561: }
562:
563: protected function updateLibraryName(string $approot): string
564: {
565: return $this->lumenSubtype($approot) ? 'laravel/lumen-framework' : 'laravel/framework';
566: }
567:
568: protected function execComposer(string $path = null, string $cmd, array $args = array()): array
569: {
570: return ComposerWrapper::instantiateContexted(
571: $this->getAuthContextFromDocroot($path ?? \Web_Module::MAIN_DOC_ROOT)
572: )->exec($path, $cmd, $args);
573: }
574:
575: protected function execPhp(string $path, string $cmd, array $args = [], array $env = []): array
576: {
577: return ComposerWrapper::instantiateContexted(
578: $this->getAuthContextFromDocroot($path ?? \Web_Module::MAIN_DOC_ROOT)
579: )->direct($path, $cmd, $args, $env);
580: }
581: }