diff --git a/.github/workflows/php-sa.yml b/.github/workflows/php-sa.yml index 6688d6c..bd16308 100644 --- a/.github/workflows/php-sa.yml +++ b/.github/workflows/php-sa.yml @@ -28,7 +28,7 @@ jobs: fail-fast: false matrix: php-ver: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] - composer-ver: [ '~2.7.0', '^2' ] + composer-ver: [ '~2.9.0', '^2' ] steps: - name: Checkout diff --git a/.github/workflows/php-tests.yml b/.github/workflows/php-tests.yml index be3dab6..9b42711 100644 --- a/.github/workflows/php-tests.yml +++ b/.github/workflows/php-tests.yml @@ -32,7 +32,7 @@ jobs: fail-fast: false matrix: php-ver: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5' ] - composer-ver: [ '~2.7.0', '~2.8.0', '~2.9.0' ] + composer-ver: [ '~2.9.0' ] steps: - name: Update "USE_COVERAGE" env var based on matrix diff --git a/composer.json b/composer.json index 888d487..45521a1 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ }, "require-dev": { "roave/security-advisories": "dev-latest", - "composer/composer": "^2.8.10", + "composer/composer": "^2.9.0", "symfony/process": "^5.4.47 || ^6 || ^7", "wp-cli/wp-cli": "^2.12.0", "inpsyde/php-coding-standards": "^2.0.2", diff --git a/src/ComposerPlugin.php b/src/ComposerPlugin.php index 1eb07cd..f844952 100644 --- a/src/ComposerPlugin.php +++ b/src/ComposerPlugin.php @@ -233,7 +233,7 @@ public function run(Util\SelectedStepsFactory $factory): void if ($factory->isListMode()) { $factory->selectAndFactory($this->locator, $this->composer); - return; // @phpstan-ignore finally.exitPoint + return; } if ($config[Config\Config::SKIP_DB_CHECK]->is(false)) { @@ -261,7 +261,7 @@ public function run(Util\SelectedStepsFactory $factory): void } finally { restore_error_handler(); if ($this->mode === self::MODE_COMMAND) { - exit($exit); // @phpstan-ignore finally.exitPoint + exit($exit); } } } diff --git a/src/Env/Filters.php b/src/Env/Filters.php index 4fa44a1..448258e 100644 --- a/src/Env/Filters.php +++ b/src/Env/Filters.php @@ -33,6 +33,7 @@ final class Filters public const FILTER_INT_OR_BOOL = 'int|bool'; public const FILTER_STRING_OR_BOOL = 'string|bool'; public const FILTER_STRING = 'string'; + public const FILTER_RAW_STRING = 'raw-string'; public const FILTER_OCTAL_MOD = 'mod'; public const FILTER_TABLE_PREFIX = 'table-prefix'; @@ -90,6 +91,8 @@ private function applyFilter(string $mode, $value) return $this->filterFloat($value); case self::FILTER_STRING: return $this->filterString($value); + case self::FILTER_RAW_STRING: + return $this->filterRawString($value); case self::FILTER_INT_OR_BOOL: return $this->filterIntOrBool($value); case self::FILTER_STRING_OR_BOOL: @@ -160,6 +163,19 @@ private function filterString($value): string return htmlspecialchars(strip_tags((string) $value), ENT_QUOTES, 'UTF-8', false); } + /** + * @param mixed $value + * @return string + */ + private function filterRawString($value): string + { + if (!is_scalar($value)) { + throw new \Exception('Invalid scalar.'); + } + + return addslashes((string) $value); + } + /** * @param mixed $value * @return bool|int diff --git a/src/Env/WordPressEnvBridge.php b/src/Env/WordPressEnvBridge.php index e84bedb..b4ebb14 100644 --- a/src/Env/WordPressEnvBridge.php +++ b/src/Env/WordPressEnvBridge.php @@ -25,8 +25,8 @@ class WordPressEnvBridge 'ALLOW_UNFILTERED_UPLOADS' => Filters::FILTER_BOOL, 'ALTERNATE_WP_CRON' => Filters::FILTER_BOOL, 'AUTH_COOKIE' => Filters::FILTER_STRING, - 'AUTH_KEY' => Filters::FILTER_STRING, - 'AUTH_SALT' => Filters::FILTER_STRING, + 'AUTH_KEY' => Filters::FILTER_RAW_STRING, + 'AUTH_SALT' => Filters::FILTER_RAW_STRING, 'AUTOMATIC_UPDATER_DISABLED' => Filters::FILTER_BOOL, 'AUTOSAVE_INTERVAL' => Filters::FILTER_INT, @@ -50,7 +50,7 @@ class WordPressEnvBridge 'DB_COLLATE' => Filters::FILTER_STRING, 'DB_HOST' => Filters::FILTER_STRING, 'DB_NAME' => Filters::FILTER_STRING, - 'DB_PASSWORD' => Filters::FILTER_STRING, + 'DB_PASSWORD' => Filters::FILTER_RAW_STRING, 'DB_USER' => Filters::FILTER_STRING, 'DIEONDBERROR' => Filters::FILTER_BOOL, 'DISABLE_WP_CRON' => Filters::FILTER_BOOL, @@ -80,7 +80,7 @@ class WordPressEnvBridge 'FTP_FORCE' => Filters::FILTER_BOOL, 'FTP_HOST' => Filters::FILTER_STRING, 'FTP_LANG_DIR' => Filters::FILTER_STRING, - 'FTP_PASS' => Filters::FILTER_STRING, + 'FTP_PASS' => Filters::FILTER_RAW_STRING, 'FTP_PLUGIN_DIR' => Filters::FILTER_STRING, 'FTP_PRIKEY' => Filters::FILTER_STRING, 'FTP_PUBKEY' => Filters::FILTER_STRING, @@ -97,8 +97,8 @@ class WordPressEnvBridge 'LANGDIR' => Filters::FILTER_STRING, 'LOGGED_IN_COOKIE' => Filters::FILTER_STRING, - 'LOGGED_IN_KEY' => Filters::FILTER_STRING, - 'LOGGED_IN_SALT' => Filters::FILTER_STRING, + 'LOGGED_IN_KEY' => Filters::FILTER_RAW_STRING, + 'LOGGED_IN_SALT' => Filters::FILTER_RAW_STRING, 'MEDIA_TRASH' => Filters::FILTER_BOOL, 'MULTISITE' => Filters::FILTER_BOOL, @@ -108,8 +108,8 @@ class WordPressEnvBridge 'MYSQL_NEW_LINK' => Filters::FILTER_BOOL, 'NOBLOGREDIRECT' => Filters::FILTER_STRING, - 'NONCE_KEY' => Filters::FILTER_STRING, - 'NONCE_SALT' => Filters::FILTER_STRING, + 'NONCE_KEY' => Filters::FILTER_RAW_STRING, + 'NONCE_SALT' => Filters::FILTER_RAW_STRING, 'NO_HEADER_TEXT' => Filters::FILTER_STRING, 'PASS_COOKIE' => Filters::FILTER_STRING, @@ -129,11 +129,11 @@ class WordPressEnvBridge 'SAVEQUERIES' => Filters::FILTER_BOOL, 'SCRIPT_DEBUG' => Filters::FILTER_BOOL, - 'SECRET_KEY' => Filters::FILTER_STRING, - 'SECRET_SALT' => Filters::FILTER_STRING, + 'SECRET_KEY' => Filters::FILTER_RAW_STRING, + 'SECRET_SALT' => Filters::FILTER_RAW_STRING, 'SECURE_AUTH_COOKIE' => Filters::FILTER_STRING, - 'SECURE_AUTH_KEY' => Filters::FILTER_STRING, - 'SECURE_AUTH_SALT' => Filters::FILTER_STRING, + 'SECURE_AUTH_KEY' => Filters::FILTER_RAW_STRING, + 'SECURE_AUTH_SALT' => Filters::FILTER_RAW_STRING, 'SHORTINIT' => Filters::FILTER_BOOL, 'SITECOOKIEPATH' => Filters::FILTER_STRING, 'SITE_ID_CURRENT_SITE' => Filters::FILTER_INT, @@ -177,7 +177,7 @@ class WordPressEnvBridge 'WP_POST_REVISIONS' => Filters::FILTER_INT_OR_BOOL, 'WP_PROXY_BYPASS_HOSTS' => Filters::FILTER_STRING, 'WP_PROXY_HOST' => Filters::FILTER_STRING, - 'WP_PROXY_PASSWORD' => Filters::FILTER_STRING, + 'WP_PROXY_PASSWORD' => Filters::FILTER_RAW_STRING, 'WP_PROXY_PORT' => Filters::FILTER_INT, 'WP_PROXY_USERNAME' => Filters::FILTER_STRING, 'WP_SITEURL' => Filters::FILTER_STRING, diff --git a/src/Io/Io.php b/src/Io/Io.php index 6130246..215b1a4 100644 --- a/src/Io/Io.php +++ b/src/Io/Io.php @@ -185,7 +185,7 @@ public function ask(Question $question): ?string try { $answer = null; $count = 0; - while (!is_string($answer) || !$question->isValidAnswer($answer)) { + while ($answer === null) { if ($count > 4) { usleep(250000); throw $tooMuchTriesException; @@ -194,8 +194,8 @@ public function ask(Question $question): ?string $this->writeComment('Invalid answer, try again.'); usleep(250000); } - $answer = $this->io->ask($questionText, $question->defaultAnswerKey()); - $answer = is_string($answer) ? strtolower(trim($answer)) : null; + $asked = $this->io->ask($questionText, $question->defaultAnswerKey()); + $answer = is_string($asked) ? $question->filterAnswer($asked) : null; $count++; } diff --git a/src/Io/Question.php b/src/Io/Question.php index 4d59e5c..ec3205c 100644 --- a/src/Io/Question.php +++ b/src/Io/Question.php @@ -62,11 +62,14 @@ public function __construct(array $lines, array $answers = [], ?string $default /** * @param string $answer - * @return bool + * @return string|null */ - public function isValidAnswer(string $answer): bool + public function filterAnswer(string $answer): ?string { - return array_key_exists(strtolower(trim($answer)), $this->answers); + $answer = trim($answer); + $answer = strtolower($answer); + + return array_key_exists($answer, $this->answers) ? $answer : null; } /** diff --git a/src/Step/EnvExampleStep.php b/src/Step/EnvExampleStep.php index 11c4f27..8776517 100644 --- a/src/Step/EnvExampleStep.php +++ b/src/Step/EnvExampleStep.php @@ -60,10 +60,13 @@ public function name(): string */ public function allowed(Config $config, Paths $paths): bool { - /** @var string $envFile */ - $envFile = $config[Config::ENV_FILE]->unwrapOrFallback('.env'); + /** @var string $envFileName */ + $envFileName = $config[Config::ENV_FILE]->unwrapOrFallback('.env'); + /** @var string $envDir */ + $envDir = $config[Config::ENV_DIR]->unwrapOrFallback($paths->root()); + $envFile = $this->filesystem->normalizePath("{$envDir}/{$envFileName}"); - return $config[Config::ENV_EXAMPLE]->not(false) && !is_file($paths->root($envFile)); + return $config[Config::ENV_EXAMPLE]->not(false) && !is_file($envFile); } /** @@ -72,7 +75,10 @@ public function allowed(Config $config, Paths $paths): bool */ public function targetPath(Paths $paths): string { - return $paths->root('.env.example'); + /** @var string $envDir */ + $envDir = $this->config[Config::ENV_DIR]->unwrap(); + + return $this->filesystem->normalizePath("{$envDir}/.env.example"); } /** diff --git a/src/Step/MuLoaderStep.php b/src/Step/MuLoaderStep.php index 7058e26..722cabf 100644 --- a/src/Step/MuLoaderStep.php +++ b/src/Step/MuLoaderStep.php @@ -68,7 +68,7 @@ public function name(): string */ public function allowed(Config $config, Paths $paths): bool { - $this->muPlugins = $this->list->pluginsList(); + $this->muPlugins = $this->list->pluginsList($config); return (bool) $this->muPlugins; } diff --git a/src/Util/Filesystem.php b/src/Util/Filesystem.php index 61ff1c8..07c695b 100644 --- a/src/Util/Filesystem.php +++ b/src/Util/Filesystem.php @@ -226,6 +226,15 @@ public function removeRealDir(string $directory): bool } } + /** + * @param string $path + * @return string + */ + public function normalizePath(string $path): string + { + return $this->filesystem->normalizePath($path); + } + /** * @param string $path * @return bool diff --git a/src/Util/Locator.php b/src/Util/Locator.php index fc2b0ca..cc2eb87 100644 --- a/src/Util/Locator.php +++ b/src/Util/Locator.php @@ -239,7 +239,11 @@ public function wpConfigSectionEditor(): WpConfigSectionEditor public function muPluginsList(): MuPluginList { if (!isset($this->objects[__FUNCTION__])) { - $this->objects[__FUNCTION__] = new MuPluginList($this->packageFinder(), $this->paths()); + $this->objects[__FUNCTION__] = new MuPluginList( + $this->packageFinder(), + $this->paths(), + $this->composerFilesystem() + ); } /** @var MuPluginList */ return $this->objects[__FUNCTION__]; diff --git a/src/Util/MuPluginList.php b/src/Util/MuPluginList.php index 2a7b576..07ac2b6 100644 --- a/src/Util/MuPluginList.php +++ b/src/Util/MuPluginList.php @@ -12,6 +12,9 @@ namespace WeCodeMore\WpStarter\Util; use Composer\Package\PackageInterface; +use Composer\Util\Filesystem as ComposerFilesystem; +use Symfony\Component\Finder\Finder; +use WeCodeMore\WpStarter\Config\Config; /** * Helper that uses Composer objects to get a list of installed packages and filter them to obtain @@ -21,24 +24,40 @@ class MuPluginList { private PackageFinder $packageFinder; private Paths $paths; + private ComposerFilesystem $filesystem; + + /** @var list|null */ + private ?array $dropins = null; /** * @param PackageFinder $packageFinder * @param Paths $paths + * @param ComposerFilesystem $filesystem */ - public function __construct(PackageFinder $packageFinder, Paths $paths) - { + public function __construct( + PackageFinder $packageFinder, + Paths $paths, + ComposerFilesystem $filesystem + ) { + $this->packageFinder = $packageFinder; $this->paths = $paths; + $this->filesystem = $filesystem; } /** + * @param Config $config * @return array */ - public function pluginsList(): array + public function pluginsList(Config $config): array { $list = []; + // First, we search for packages with "wordpress-muplugin" type. + // We store each path we have looked into in an array we'll use below. + // PHP files found in "wordpress-muplugin" packages' root will need the use "Plugin name" + // header to have them loaded, unless a single file is found in path. + $packagesPaths = []; $packages = $this->packageFinder->findByType('wordpress-muplugin'); foreach ($packages as $package) { $paths = $this->pathsForPluginPackage($package); @@ -49,6 +68,38 @@ public function pluginsList(): array $name = $package->getName(); $multi = count($paths) > 1; foreach ($paths as $path) { + $packagesPaths[] = dirname($path); + /** @var non-falsy-string $key */ + $key = $multi ? "{$name}_" . pathinfo($path, PATHINFO_FILENAME) : $name; + $list[$key] = $path; + } + } + + // Now we check any subdirectory of MU plugins folder, which we haven't looked into yet + // (that why we store the array above), to see if there's any MU plugin to load in there. + // This is necessary because MU plugins in the wp.org repository don't have the + // "wordpress-muplugin" type, because wp.org doesn't allow it. + // So we can move them to the MU plugin folder via installers path, but we then need to have + // them loaded. + // Because we have no indication files are actually plugins, we require the plugin header to + // be there even if a single PHP file is in a path. + $muPluginsDir = $this->paths->wpContent('/mu-plugins/'); + $muPluginsSubDirs = Finder::create() + ->in($muPluginsDir) + ->depth(0) + ->directories() + ->ignoreUnreadableDirs() + ->ignoreVCS(true); + + foreach ($muPluginsSubDirs as $muSubDir) { + $muDirPath = $this->filesystem->normalizePath($muSubDir->getPathname()); + if (in_array($muDirPath, $packagesPaths, true)) { + continue; + } + $morePaths = $this->mupluginsPathsInDir($muDirPath, true, $config); + $name = basename($muDirPath); + $multi = count($morePaths) > 1; + foreach ($morePaths as $path) { /** @var non-falsy-string $key */ $key = $multi ? "{$name}_" . pathinfo($path, PATHINFO_FILENAME) : $name; $list[$key] = $path; @@ -78,22 +129,50 @@ private function pathsForPluginPackage(PackageInterface $package): array return []; } - /** @var list|false $files */ - $files = glob("{$path}/*.php"); - if (($files === false) || ($files === [])) { + return $this->mupluginsPathsInDir($path, false); + } + + /** + * @param string $path + * @param bool $requireHeader + * @param Config|null $config + * @return list + */ + private function mupluginsPathsInDir( + string $path, + bool $requireHeader, + ?Config $config = null + ): array { + + $files = Finder::create()->in($path) + ->name('*.php') + ->depth(0) + ->ignoreUnreadableDirs() + ->ignoreVCS(true) + ->ignoreDotFiles(true) + ->files(); + $count = $files->count(); + if ($count === 0) { return []; } - if (count($files) === 1) { - $file = reset($files); - - return is_readable($file) ? [$file] : []; - } + // If a folder contains a single file, and we know the folder is for a MU plugin, then + // we can assume that file is the file to load without checking plugin header. + // A header is required when we don't know for sure the path belongs to a MU plugin package. + $single = ($count === 1) && !$requireHeader; $paths = []; foreach ($files as $file) { - if (is_readable($file) && $this->isPluginFile($file)) { - $paths[] = $file; + if (!$file->isReadable()) { + continue; + } + /** @var non-falsy-string $filePath */ + $filePath = $this->filesystem->normalizePath($file->getRealPath()); + if ($config !== null && $this->isDropinPath($filePath, $config)) { + continue; + } + if ($single || $this->isPluginFile($filePath)) { + $paths[] = $filePath; } } @@ -127,4 +206,43 @@ private function isPluginFile(string $file): bool return preg_match('/^[ \t\/*#@]*Plugin Name:(.*)$/mi', $data) === 1; } + + /** + * @param string $path + * @param Config $config + * @return bool + */ + private function isDropinPath(string $path, Config $config): bool + { + $realpath = realpath($path); + $realpath = $realpath !== false ? $this->filesystem->normalizePath($realpath) : ''; + + return in_array($realpath, $this->dropinsList($config), true); + } + + /** + * @param Config $config + * @return list + */ + private function dropinsList(Config $config): array + { + if ($this->dropins !== null) { + return $this->dropins; + } + + $this->dropins = []; + /** @var array $dropins */ + $dropins = $config[Config::DROPINS]->unwrapOrFallback([]); + foreach ($dropins as $dropin) { + if (filter_var($dropin, FILTER_VALIDATE_URL) !== false) { + continue; + } + $dropinPath = realpath($dropin); + if ($dropinPath !== false) { + $this->dropins[] = $this->filesystem->normalizePath($dropinPath); + } + } + + return $this->dropins; + } } diff --git a/tests/fixtures/example.env b/tests/fixtures/example.env index 33351bd..10bfa22 100644 --- a/tests/fixtures/example.env +++ b/tests/fixtures/example.env @@ -1,6 +1,6 @@ DB_HOST=localhost DB_NAME=wp -DB_PASSWORD="my secret!" +DB_PASSWORD="foo&bar!bazread('DB_HOST')); static::assertSame('wp', $bridge->read('DB_NAME')); - static::assertSame('my secret!', $bridge->read('DB_PASSWORD')); + static::assertSame('foo&bar!bazread('DB_PASSWORD')); static::assertSame('xxx_', $bridge->read('DB_TABLE_PREFIX')); static::assertSame('wp_user', $bridge->read('DB_USER')); static::assertSame('', $bridge->read('COOKIE_DOMAIN')); diff --git a/tests/unit/Env/FiltersTest.php b/tests/unit/Env/FiltersTest.php index 67f4254..99d1a3c 100644 --- a/tests/unit/Env/FiltersTest.php +++ b/tests/unit/Env/FiltersTest.php @@ -80,6 +80,19 @@ public static function filterDataProvider(): array [Filters::FILTER_STRING, [], null], [Filters::FILTER_STRING, '', ''], [Filters::FILTER_STRING, "", ''], + [Filters::FILTER_RAW_STRING, 1, '1'], + [Filters::FILTER_RAW_STRING, 123.456, '123.456'], + [Filters::FILTER_RAW_STRING, 0, '0'], + [Filters::FILTER_RAW_STRING, new \ArrayObject(), null], + [Filters::FILTER_RAW_STRING, false, ''], + [Filters::FILTER_RAW_STRING, true, '1'], + [Filters::FILTER_RAW_STRING, [], null], + [Filters::FILTER_RAW_STRING, '', ''], + [Filters::FILTER_RAW_STRING, "", ''], + [Filters::FILTER_RAW_STRING, 'hello!', 'hello!'], + [Filters::FILTER_RAW_STRING, 'foo&bar', 'foo&bar'], + [Filters::FILTER_RAW_STRING, 'foodefaultAnswerKey(), 'n'); static::assertSame($question->defaultAnswerText(), 'No'); - static::assertTrue($question->isValidAnswer('y')); - static::assertTrue($question->isValidAnswer('Y')); - static::assertTrue($question->isValidAnswer(' Y ')); - static::assertTrue($question->isValidAnswer(' y ')); - static::assertTrue($question->isValidAnswer('n')); - static::assertTrue($question->isValidAnswer('N')); - static::assertTrue($question->isValidAnswer(' N ')); - static::assertTrue($question->isValidAnswer(' n')); - static::assertFalse($question->isValidAnswer('x')); + static::assertSame('y', $question->filterAnswer('y')); + static::assertSame('y', $question->filterAnswer('Y')); + static::assertSame('y', $question->filterAnswer(' Y ')); + static::assertSame('y', $question->filterAnswer(' y ')); + static::assertSame('n', $question->filterAnswer('n')); + static::assertSame('n', $question->filterAnswer('N')); + static::assertSame('n', $question->filterAnswer(' N ')); + static::assertSame('n', $question->filterAnswer(' n')); + static::assertNull($question->filterAnswer('x')); } /** diff --git a/tests/unit/Util/MuPluginListTest.php b/tests/unit/Util/MuPluginListTest.php index 4089bbe..396b4de 100644 --- a/tests/unit/Util/MuPluginListTest.php +++ b/tests/unit/Util/MuPluginListTest.php @@ -12,6 +12,7 @@ namespace WeCodeMore\WpStarter\Tests\Unit\Util; use Composer\Package\CompletePackage; +use Composer\Util\Filesystem as ComposerFilesystem; use WeCodeMore\WpStarter\Tests\TestCase; use WeCodeMore\WpStarter\Util\MuPluginList; use WeCodeMore\WpStarter\Util\PackageFinder; @@ -55,8 +56,14 @@ public function testPluginList(): void 'test/mu-plugin-2_b-mu-plugin' => "{$muPluginsPath}/dir2/b-mu-plugin.php", ]; - $muPluginsList = new MuPluginList($finder, $this->factoryPaths()); + $muPluginsList = new MuPluginList( + $finder, + $this->factoryPaths(), + new ComposerFilesystem() + ); - static::assertSame($expected, $muPluginsList->pluginsList()); + $config = $this->factoryConfig(); + + static::assertSame($expected, $muPluginsList->pluginsList($config)); } }