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