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\Composer;
15: use Module\Support\Webapps\ComposerMetadata;
16: use Module\Support\Webapps\ComposerWrapper;
17: use Module\Support\Webapps\Traits\PublicRelocatable;
18:
19: /**
20: * Laravel management
21: *
22: * An interface to wp-cli
23: *
24: * @package core
25: */
26: class Laravel_Module extends Composer
27: {
28: use PublicRelocatable;
29: const APP_NAME = 'Laravel';
30: const PACKAGIST_NAME = 'laravel/laravel';
31: const BINARY_NAME = 'artisan';
32: const VALIDITY_FILE = self::BINARY_NAME;
33: const DEFAULT_VERSION_LOCK = 'minor';
34:
35: protected $aclList = array(
36: 'min' => array(
37: 'storage',
38: 'bootstrap/cache'
39: ),
40: 'max' => array(
41: 'storage/framework/cache',
42: 'storage/framework/views',
43: 'storage/framework/sessions',
44: 'storage/logs',
45: 'storage/app/public',
46: 'bootstrap/cache'
47: )
48: );
49:
50: /**
51: * Install Laravel into a pre-existing location
52: *
53: * @param string $hostname domain or subdomain to install Laravel
54: * @param string $path optional path under hostname
55: * @param array $opts additional install options
56: * @return bool
57: */
58: public function install(string $hostname, string $path = '', array $opts = array()): bool
59: {
60: if (!$this->mysql_enabled()) {
61: return error('%(what)s must be enabled to install %(app)s',
62: ['what' => 'MySQL', 'app' => static::APP_NAME]);
63: }
64: if (!version_compare($this->php_version(), '7', '>=')) {
65: return error('%(name)s requires %(what)s', [
66: 'name' => static::APP_NAME, 'what' => 'PHP7'
67: ]);
68: }
69:
70: if (!$this->parseInstallOptions($opts, $hostname, $path)) {
71: return false;
72: }
73:
74: $docroot = $this->getDocumentRoot($hostname, $path);
75:
76: // uninstall may relink public/
77: $args['version'] = $opts['version'];
78:
79: if (!$this->createProject($docroot, static::PACKAGIST_NAME, $opts['version'])) {
80: if (empty($opts['keep'])) {
81: $this->file_delete($docroot, true);
82: }
83:
84: return false;
85: }
86:
87: if (null === ($docroot = $this->remapPublic($hostname, $path))) {
88: $this->file_delete($this->getDocumentRoot($hostname, $path), true);
89:
90: return error("Failed to remap %(name)s to public/, manually remap from `%(path)s' - %(name)s setup is incomplete!",
91: ['name' => static::APP_NAME, 'path' => $docroot]);
92: }
93:
94: $oldex = \Error_Reporter::exception_upgrade();
95: $approot = $this->getAppRoot($hostname, $path);
96:
97: try {
98: // handle the xn-- in punycode domains
99: $composerHostname = preg_replace("/-{2,}/", '-', $hostname);
100: $this->execComposer($approot, 'config name %(hostname)s/%(app)s', [
101: 'app' => $this->getInternalName(),
102: 'hostname' => $composerHostname
103: ]);
104: $docroot = $this->getDocumentRoot($hostname, $path);
105:
106: // ensure it's reachable
107: if (self::class === static::class) {
108: // Laravel
109: $this->_fixCache($approot);
110: }
111:
112:
113: $db = $this->generateDatabaseStorage($hostname, $path);
114:
115: if (!$db->create()) {
116: return false;
117: }
118:
119: $fqdn = $this->web_normalize_hostname($hostname);
120: $args['uri'] = rtrim($fqdn . '/' . $path, '/');
121: $args['proto'] = empty($opts['ssl']) ? 'http://' : 'https://';
122:
123: if (!$this->setConfiguration($approot, $docroot, array_merge([
124: 'dbname' => $db->database,
125: 'dbuser' => $db->username,
126: 'dbpassword' => $db->password,
127: 'dbhost' => $db->hostname,
128: 'dbprefix' => $db->prefix,
129: 'email' => $opts['email'],
130: 'user' => $opts['user'],
131: 'login' => $opts['login'] ?? $opts['user'],
132: 'password' => $opts['password'] ?? ''
133: ], $args))) {
134: return error('failed to set database configuration');
135: }
136:
137: } catch (\apnscpException $e) {
138: if (empty($opts['keep'])) {
139: $this->remapPublic($hostname, $path, '');
140: $this->file_delete($approot, true);
141: if (isset($db)) {
142: $db->rollback();
143: }
144: }
145: return error('Failed to install %(name)s: %(err)s', [
146: 'name' => static::APP_NAME, 'err' => $e->getMessage()
147: ]);
148: } finally {
149: \Error_Reporter::exception_upgrade($oldex);
150: }
151:
152: $this->postInstall($hostname, $path);
153:
154: $this->initializeMeta($docroot, $opts);
155: $this->fortify($hostname, $path, $this->handlerFromApplication($this->getAppName())::DEFAULT_FORTIFICATION);
156: $this->fixRewriteBase($docroot);
157:
158: $this->buildConfig($approot, $docroot);
159:
160: $this->notifyInstalled($hostname, $path, $opts);
161:
162: return info('%(app)s installed - confirmation email with login info sent to %(email)s',
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('failed to download %(name)s package: %(stderr)s %(stdout)s', [
193: 'name' => static::APP_NAME, '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: return parent::checkVersion($options);
229: }
230:
231: if (!isset($options['version'])) {
232: $versions = $this->get_installable_versions();
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 && version_compare($options['version'], $cap, '>=')) {
254: info("PHP version `%(phpversion)s' detected, capping %(name)s to %(cap)s", [
255: 'phpversion' => $phpversion, 'name' => static::APP_NAME, 'cap' => $cap
256: ]);
257: $options['version'] = $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('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('unable to alter app.php - file is missing (Laravel corrupted?)');
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 \Opcenter\Provisioning\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('config rebuild failed: %s', 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("no URI specified, cannot deduce URI from docroot `%s'", $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("failed to cache configuration directly, visit `%s' to cache configuration", $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('failed to determine %(app)s', ['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('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("failed to obtain %(app)s configuration for `%(approot)s'", ['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('failed to update all components');
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: return error("Failed to update %(name)s from `%(old)s' to `%(new)s', check composer.json for version restrictions",
531: ['name' => static::APP_NAME, 'old' => $oldversion, 'new' => $version]
532: );
533: }
534:
535: // update composer.json versioning after update
536: defer($_, fn() => array_set(
537: $metadata,
538: "require.{$this->updateLibraryName($approot)}",
539: $this->parseLock($this->get_reconfigurable($hostname, $path, 'verlock'), $version ?? $this->get_version($hostname, $path))
540: ));
541:
542: $this->postUpdate($hostname, $path);
543: parent::setInfo($docroot, [
544: 'version' => $oldversion,
545: 'failed' => !$ret['success']
546: ]);
547:
548: return $ret['success'] ?: error($ret['stderr']);
549: }
550:
551: protected function postUpdate(string $hostname, string $path): bool
552: {
553: $approot = $this->getAppRoot($hostname, $path);
554: $version = $this->get_version($hostname, $path);
555: $commands = [
556: 'migrate',
557: \Opcenter\Versioning::compare($version, '9',
558: '>=') ? 'vendor:publish --tag=laravel-assets --no-ansi' : null,
559: ];
560: foreach ($commands as $cmd) {
561: if (!$cmd) {
562: continue;
563: }
564: $this->execPhp($approot, './' . static::BINARY_NAME . ' ' . $cmd);
565: }
566:
567: return true;
568: }
569:
570: protected function updateLibraryName(string $approot): string
571: {
572: return $this->lumenSubtype($approot) ? 'laravel/lumen-framework' : 'laravel/framework';
573: }
574:
575: protected function execComposer(string $path = null, string $cmd, array $args = array()): array
576: {
577: return ComposerWrapper::instantiateContexted(
578: $this->getAuthContextFromDocroot($path ?? \Web_Module::MAIN_DOC_ROOT)
579: )->exec($path, $cmd, $args);
580: }
581:
582: protected function execPhp(string $path, string $cmd, array $args = [], array $env = []): array
583: {
584: return ComposerWrapper::instantiateContexted(
585: $this->getAuthContextFromDocroot($path ?? \Web_Module::MAIN_DOC_ROOT)
586: )->direct($path, $cmd, $args, $env);
587: }
588: }