From cb0967a453095ad7a4667e7d9f607837db4ea5e6 Mon Sep 17 00:00:00 2001 From: Giuseppe Mazzapica Date: Fri, 10 Nov 2023 18:31:14 +0100 Subject: [PATCH] Improve environment management - Do not use putenv/getenv by default - Decouple as much as possible from Symfony package - Use a single method to load env files, instead of "load" VS "loadFile" VS "loadAppended" - Move all the "dump cache" logic to the Helpers class - Fix PHP 8.1 deprecation in filter octal values --- composer.json | 6 +- docs/02-Environment-Variables.md | 32 ++- docs/09-Settings-Cheat-Sheet.md | 57 ++-- src/Config/Config.php | 3 + src/Env/Filters.php | 2 +- src/Env/Helpers.php | 42 +-- src/Env/WordPressEnvBridge.php | 254 ++++++++++-------- src/Step/WpConfigStep.php | 1 + src/Util/Locator.php | 2 +- templates/wp-config.php | 9 +- .../Env/WordPressEnvBridgeTest.php | 120 +++------ 11 files changed, 263 insertions(+), 265 deletions(-) diff --git a/composer.json b/composer.json index a2ac709..20a529b 100644 --- a/composer.json +++ b/composer.json @@ -47,13 +47,13 @@ "ext-SPL": "*", "composer-plugin-api": "^2", "composer/installers": "^2", - "symfony/dotenv": "^3.4 || ^5 || ^6" + "symfony/dotenv": "^5 || ^6" }, "require-dev": { "roave/security-advisories": "dev-latest", "composer/composer": "^2.6.5", - "symfony/process": "^5.4.19", - "wp-cli/wp-cli": "^2.7.1", + "symfony/process": "^v5.4.28", + "wp-cli/wp-cli": "^v2.9.0", "inpsyde/php-coding-standards": "^2@dev", "inpsyde/wp-stubs-versions": "dev-latest", "vimeo/psalm": "^5.15.0", diff --git a/docs/02-Environment-Variables.md b/docs/02-Environment-Variables.md index 8519c27..0da5303 100644 --- a/docs/02-Environment-Variables.md +++ b/docs/02-Environment-Variables.md @@ -68,7 +68,23 @@ In PHP there are two functions: [`getevn`](http://php.net/manual/en/function.get There's nothing in PHP core that parse env files, but is no surprise that there are different libraries to do that. -WP Starter uses one of these libraries: the **[Symfony Dotenv Component](https://symfony.com/doc/3.4/components/dotenv.html).** +WP Starter uses one of these libraries: the **[Symfony Dotenv Component](https://symfony.com/components/Dotenv).** + + + +### About `putenv()` and `getenv()` + +These two PHP functions are problematic as they are not thread safe. So **recent versions of WP Starter** (as well as recent version of Symfony Dotenv Component) **do not use `putenv()` by default** to store in the environment the values loaded via `.env` files. + +Even if `.env` files are discouraged in production where thread safety is important, we still prefer to have a safe behavior by default and not use `putenv()`. This means that environment variables loaded from WP Starter `.env` files will not be available to `getenv()`. + +There are different ways those values could be read instead: + +- Because WP Starter converts environment variables to constants (after converting to the correct type), one approach is to access values constants. +- Directly access super globals: `$_ENV['var_name'] ?? $_SERVER['var_name'] ?? null`. +- Make use of WP Starter helper, like ` WeCodeMore\WpStarter\Env\Helpers::readVar('var_name');` This approach has the benefit of obtaining "filtered values". E. g. obtaining the intended type instead of always a string. + +If any of the above can be used for some reason, it is still possible to let WP Starter use `putenv()` to load environments by setting the `use-putenv` configuration to `true`. @@ -82,6 +98,8 @@ Moreover, **if the "actual environment" contains all the variables WP Starter an Configuring WP Starter to **bypass** the loading of env files is accomplished via the **`WPSTARTER_ENV_LOADED`** env variable, which when set tells WP Starter to assume all variables are in the actual environment. + + ### Important security note about `.env` file WP Starter loads an `.env` file found in the project root folder, and it is worth noting that if no additional configuration is made, project root is also the folder assumed as webroot for the project. @@ -121,7 +139,7 @@ If there's a plugin that supports a constant like `"AWESOME_PLUGIN_CONFIG"`, by So you might need to write code like: ```php -$config = getenv('AWESOME_PLUGIN_CONFIG'); +$config = $_ENV['AWESOME_PLUGIN_CONFIG'] ?? $_SERVER['AWESOME_PLUGIN_CONFIG'] ?? null; if ($config) { define('AWESOME_PLUGIN_CONFIG', $config); } @@ -131,6 +149,8 @@ There are many places in which such code can be placed, for example a MU plugin. Alternatively WP Starter can be instructed to do something like this automatically. + + ### Let WP Starter define constants for custom env vars WP Starter supports an environment variable called `WP_STARTER_ENV_TO_CONST` containing a list of of comma-separated environment variables to be turned into constants: @@ -165,6 +185,8 @@ As described above, all WordPress configuration constants are natively supported Moreover, there are a few env variables that have a special meaning for WP Starter. + + ### DB check env vars During its bootstrap process, before doing any operation on the system, WP Starter checks if the environment is already setup for database connection. @@ -181,6 +203,8 @@ The three env vars are registered in the order they are listed above: if one is Sometime it might be desirable to bypass this WP Starter check and there's a way to accomplish that via the `skip-db-check` setting. Learn more about that in the [_"WP-Starter-Configuration"_ chapter](04-WP-Starter-Configuration.md). + + ### WordPress Configuration Those are a few env vars that are used in `wp-config.php` that WP Starter generates. @@ -226,9 +250,7 @@ The filter is executed very late (so could be added in MU plugins, plugins and e For example, to only allow cache in production a code like the following can be used: ```php -add_filter('wpstarter.skip-cache-env', function ($skip, $envName) { - return $skip || $envName !== 'production'; -}); +add_filter('wpstarter.skip-cache-env', fn ($skip, $envName) => $skip || $envName !== 'production'); ``` diff --git a/docs/09-Settings-Cheat-Sheet.md b/docs/09-Settings-Cheat-Sheet.md index f2494d5..bf0ee58 100644 --- a/docs/09-Settings-Cheat-Sheet.md +++ b/docs/09-Settings-Cheat-Sheet.md @@ -7,35 +7,36 @@ nav_order: 9 Alphabetically ordered: -| Key | Description | Default value | -|:------------------------:|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|:--------------------------:| -| `autoload` | PHP file loaded before WP Starter its steps.
Path to the file, relative to root. | "./wpstarter-autoload.php" | -| `cache-env` | Whether to cache env for WP requests. | true | -| `check-vcs-ignore` | Whether to check for VCS-ignored paths.
By default `true`, can be set to `false` to not do any check.
Can also be set to`"ask"`, in which case WP Starter will ask user what to do. | true | -| `command-steps` | Objects steps of custom steps to add to be ran via WP Starter command.
Values must be steps FQCN, keys the step names, matching what `Step::name()` returns.
Given classes must be autoloadable. | {} | -| `content-dev-dir` | Source folder for "development content".
Path to the folder, relative to root. | "./content-dev" | +| Key | Description | Default value | +| :----------------------: | :----------------------------------------------------------- | :------------------------: | +| `autoload` | PHP file loaded before WP Starter its steps.
Path to the file, relative to root. | "./wpstarter-autoload.php" | +| `cache-env` | Whether to cache env for WP requests. | true | +| `check-vcs-ignore` | Whether to check for VCS-ignored paths.
By default `true`, can be set to `false` to not do any check.
Can also be set to`"ask"`, in which case WP Starter will ask user what to do. | true | +| `command-steps` | Objects steps of custom steps to add to be ran via WP Starter command.
Values must be steps FQCN, keys the step names, matching what `Step::name()` returns.
Given classes must be autoloadable. | {} | +| `content-dev-dir` | Source folder for "development content".
Path to the folder, relative to root. | "./content-dev" | | `content-dev-op` | Operation to perform for "development content"
That is, plugins, themes, MU plugins, translations and dropins shipped with the project.
Valid values are "auto", symlink", "copy" and "none".
Can also be set to`"ask"`, in which case WP Starter will ask user what to do. | "auto" | -| `create-vcs-ignore-file` | Whether to check for VCS-ignore file if it does not exist.
By default `true`, can be set to `false` to not create it.
Can also be set to`"ask"`, in which case WP Starter will ask user what to do. | true | -| `custom-steps` | Map of custom step names to custom step classes.
Classes must be autoloadable. | null | -| `db-check` | Determine if and how DB check is done.
By default `true`, can be set to `false` to not do any check, or to `health` to do a health check using `mysqlcheck` binary. | true | -| `dropins` | Array of dropins files to move to WP content folder.
Can be local paths or remote URLs. | [] | -| `dropins-op` | Operation to perform for "dropins"
Valid values are "auto", symlink", "copy" and "none".
Can also be set to`"ask"`, in which case WP Starter will ask user what to do. | "auto" | -| `early-hook-file` | PHP file that adds callbacks to early hooks.
Path to the file, relative to root. | null | -| `env-bootstrap-dir` | Folder where to look for env bootstrap files.
Path to the folder, relative to root. | null | -| `env-dir` | Folder where to look for `.env` file.
Path to the folder, relative to root. | "./" | -| `env-example` | How to deal with `.env.example` file. Can be:
- `true` (copy default example file to root)
- `false` (do nothing)
- path, relative to root, to example file to copy.
- `"ask"` (user will be asked what to do) | true | -| `env-file` | Name of the `.env` file.
Will be searched inside `env-dir` | ".env" | -| `install-wp-cli` | Whether to install WP CLI from phar if necessary. | true | -| `move-content` | Whether to move default themes and plugins
to project wp-content folder.
Can be set to`"ask"`, in which case WP Starter will ask user what to do. | false | -| `prevent-overwrite` | Array of paths to preserve from overwrite.
Paths relative to root, might use glob patterns.
Can be set to`"ask"`, in which case WP Starter will ask confirmation before *each* overwrite attempt. | [] | -| `register-theme-folder` | Whether to register default themes.
Can be set to`"ask"`, in which case WP Starter will ask user what to do. | false | -| `require-wp` | Whether to check for WP package being required. | true | -| `scripts` | Array of script to run before or after steps.
An object where key is in the format:
`"pre-{$step}"` or `"post-{$step}"`
and value is either a callback.
Callbacks must be autoloadable. | [] | -| `skip-steps` | Array of step *names* to skip. | [] | -| `templates-dir` | Folder where to look for custom templates.
Path relative to root. | null | -| `wp-cli-commands` | Array of WP CLI commands.
Can be a path to a PHP file _returning_ the array of commands, or to a JSON file containing the array.
Paths relative to root. | [] | -| `wp-cli-files` | Array of file paths to be passed to WP CLI `eval file`. Can be an array of objects with "file", "args", and "skip-wordpress" properties.
Paths relative to root. | [] | -| `wp-version` | When `require-wp` is set to `false` this instruct WP Starter about the WP version that will be used with WP Starter. | null | +| `create-vcs-ignore-file` | Whether to check for VCS-ignore file if it does not exist.
By default `true`, can be set to `false` to not create it.
Can also be set to`"ask"`, in which case WP Starter will ask user what to do. | true | +| `custom-steps` | Map of custom step names to custom step classes.
Classes must be autoloadable. | null | +| `db-check` | Determine if and how DB check is done.
By default `true`, can be set to `false` to not do any check, or to `health` to do a health check using `mysqlcheck` binary. | true | +| `dropins` | Array of dropins files to move to WP content folder.
Can be local paths or remote URLs. | [] | +| `dropins-op` | Operation to perform for "dropins"
Valid values are "auto", symlink", "copy" and "none".
Can also be set to`"ask"`, in which case WP Starter will ask user what to do. | "auto" | +| `early-hook-file` | PHP file that adds callbacks to early hooks.
Path to the file, relative to root. | null | +| `env-bootstrap-dir` | Folder where to look for env bootstrap files.
Path to the folder, relative to root. | null | +| `env-dir` | Folder where to look for `.env` file.
Path to the folder, relative to root. | "./" | +| `env-example` | How to deal with `.env.example` file. Can be:
- `true` (copy default example file to root)
- `false` (do nothing)
- path, relative to root, to example file to copy.
- `"ask"` (user will be asked what to do) | true | +| `env-file` | Name of the `.env` file.
Will be searched inside `env-dir` | ".env" | +| `install-wp-cli` | Whether to install WP CLI from phar if necessary. | true | +| `move-content` | Whether to move default themes and plugins
to project wp-content folder.
Can be set to`"ask"`, in which case WP Starter will ask user what to do. | false | +| `prevent-overwrite` | Array of paths to preserve from overwrite.
Paths relative to root, might use glob patterns.
Can be set to`"ask"`, in which case WP Starter will ask confirmation before *each* overwrite attempt. | [] | +| `register-theme-folder` | Whether to register default themes.
Can be set to`"ask"`, in which case WP Starter will ask user what to do. | false | +| `require-wp` | Whether to check for WP package being required. | true | +| `scripts` | Array of script to run before or after steps.
An object where key is in the format:
`"pre-{$step}"` or `"post-{$step}"`
and value is either a callback.
Callbacks must be autoloadable. | [] | +| `skip-steps` | Array of step *names* to skip. | [] | +| `templates-dir` | Folder where to look for custom templates.
Path relative to root. | null | +| `use-putenv` | Tell WP Starter to store variables loaded from `.env` files _also_ using `putenv()`.
Use only if something is relying on `getenv()` and can not be changed. | false | +| `wp-cli-commands` | Array of WP CLI commands.
Can be a path to a PHP file _returning_ the array of commands, or to a JSON file containing the array.
Paths relative to root. | [] | +| `wp-cli-files` | Array of file paths to be passed to WP CLI `eval file`. Can be an array of objects with "file", "args", and "skip-wordpress" properties.
Paths relative to root. | [] | +| `wp-version` | When `require-wp` is set to `false` this instruct WP Starter about the WP version that will be used with WP Starter. | null | diff --git a/src/Config/Config.php b/src/Config/Config.php index b82ee21..dacf48a 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -40,6 +40,7 @@ final class Config implements \ArrayAccess public const ENV_DIR = 'env-dir'; public const ENV_EXAMPLE = 'env-example'; public const ENV_FILE = 'env-file'; + public const ENV_USE_PUTENV = 'use-putenv'; public const INSTALL_WP_CLI = 'install-wp-cli'; public const IS_COMPOSER_INSTALL = 'is-composer-install'; public const IS_COMPOSER_UPDATE = 'is-composer-update'; @@ -76,6 +77,7 @@ final class Config implements \ArrayAccess self::ENV_DIR => null, self::ENV_EXAMPLE => true, self::ENV_FILE => '.env', + self::ENV_USE_PUTENV => false, self::INSTALL_WP_CLI => true, self::IS_COMPOSER_INSTALL => null, self::IS_WPSTARTER_COMMAND => null, @@ -112,6 +114,7 @@ final class Config implements \ArrayAccess self::ENV_DIR => 'validateDirName', self::ENV_EXAMPLE => 'validateBoolOrAskOrUrlOrPath', self::ENV_FILE => 'validateFileName', + self::ENV_USE_PUTENV => 'validateBool', self::INSTALL_WP_CLI => 'validateBool', self::IS_COMPOSER_INSTALL => 'validateBool', self::IS_COMPOSER_UPDATE => 'validateBool', diff --git a/src/Env/Filters.php b/src/Env/Filters.php index d5d276e..0e2c5d7 100644 --- a/src/Env/Filters.php +++ b/src/Env/Filters.php @@ -188,7 +188,7 @@ private function filterOctalMod($value): int return $value; } - if (!is_string($value) || !is_numeric($value)) { + if (!is_string($value) || !preg_match('~^[0-7]+$~', $value)) { throw new \Exception('Invalid octal mod.'); } diff --git a/src/Env/Helpers.php b/src/Env/Helpers.php index fd13de9..3892f12 100644 --- a/src/Env/Helpers.php +++ b/src/Env/Helpers.php @@ -25,6 +25,9 @@ abstract class Helpers /** @var bool|null */ private static $shouldCache = null; + /** @var bool */ + private static bool $usePutenv = false; + /** * @return void */ @@ -33,6 +36,14 @@ final public static function enableCache(): void self::$cacheEnabled = true; } + /** + * @return void + */ + final public static function usePutenv(): void + { + self::$usePutenv = true; + } + /** * @param string $key * @return mixed @@ -77,7 +88,7 @@ final public static function loadEnvFiles(string $fileName, string $path): array $loader->load($fileName, $path); $envType = static::envType(true); if ($envType !== 'example') { - $loader->loadAppended("{$fileName}.{$envType}", $path); + $loader->load("{$fileName}.{$envType}", $path); } $loader->setupConstants(); @@ -91,12 +102,15 @@ final public static function loadEnvFiles(string $fileName, string $path): array } /** - * @param string $path * @return void */ - final public static function dumpEnvCache(string $path): void + final public static function dumpEnvCache(): void { + if (!defined('WPSTARTER_ENV_PATH')) { + return; + } if (static::shouldCacheEnv()) { + $path = WPSTARTER_ENV_PATH . WordPressEnvBridge::CACHE_DUMP_FILE; static::envBridge()->dumpCached($path); } } @@ -106,7 +120,7 @@ final public static function dumpEnvCache(string $path): void */ final public static function shouldCacheEnv(): bool { - if (!self::$cacheEnabled) { + if (!self::$cacheEnabled || !static::envBridge()->isWpSetup()) { return false; } @@ -131,24 +145,22 @@ final public static function isEnvCacheEnabled(): bool return static::$cacheEnabled; } - /** - * @return bool - */ - final public static function isWpEnvSetup(): bool - { - return static::envBridge()->isWpSetup(); - } - /** * @return WordPressEnvBridge */ private static function envBridge(): WordPressEnvBridge { if (!static::$bridge) { + if (!defined('WPSTARTER_ENV_PATH') || !self::$cacheEnabled) { + static::$bridge = new WordPressEnvBridge(); + static::$usePutenv and static::$bridge->usePutEnv(); + + return static::$bridge; + } + $envCacheFile = WPSTARTER_ENV_PATH . WordPressEnvBridge::CACHE_DUMP_FILE; - static::$bridge = self::$cacheEnabled - ? WordPressEnvBridge::buildFromCacheDump($envCacheFile) - : new WordPressEnvBridge(); + static::$bridge = WordPressEnvBridge::buildFromCacheDump($envCacheFile); + static::$usePutenv and static::$bridge->usePutEnv(); } return static::$bridge; diff --git a/src/Env/WordPressEnvBridge.php b/src/Env/WordPressEnvBridge.php index 2018bc8..f8904ee 100644 --- a/src/Env/WordPressEnvBridge.php +++ b/src/Env/WordPressEnvBridge.php @@ -15,6 +15,8 @@ /** * Handle WordPress related environment variables using Symfony Env component. + * + * phpcs:disable Inpsyde.CodeQuality.PropertyPerClassLimit */ class WordPressEnvBridge { @@ -250,12 +252,12 @@ class WordPressEnvBridge /** * @var Dotenv|null */ - private static $defaultDotEnv; + private static $defaultDotEnv = null; /** - * @var array|null + * @var array|null */ - private static $loadedVars; + private static $loadedVars = null; /** * @var array @@ -270,7 +272,7 @@ class WordPressEnvBridge /** * @var Filters|null */ - private $filters; + private $filters = null; /** * @var bool @@ -283,20 +285,25 @@ class WordPressEnvBridge private $customFiltersConfig = []; /** - * @var list + * @var list|null */ - private $definedConstants = []; + private $definedConstants = null; /** * @var string|null */ - private $envType; + private $envType = null; /** * @var bool */ private $wordPressSetup = false; + /** + * @var bool + */ + private $usePutEnv = false; + /** * @param string $file * @return WordPressEnvBridge @@ -317,12 +324,20 @@ public static function buildFromCacheDump(string $file): WordPressEnvBridge /** * Symfony stores a variable with the keys of variables it loads. * - * @return array + * @return array */ public static function loadedVars(): array { if (self::$loadedVars === null) { - self::$loadedVars = array_flip(explode(',', (getenv('SYMFONY_DOTENV_VARS') ?: ''))); + $vars = $_SERVER['WPSTARTER_DOTENV_VARS'] ?? $_ENV['WPSTARTER_DOTENV_VARS'] ?? null; + if ($vars === null) { + self::$loadedVars = []; + + return []; + } + + /** @var array $loadedVars */ + self::$loadedVars = array_flip(explode(',', $vars)); unset(self::$loadedVars['']); } @@ -338,13 +353,19 @@ public function __construct(Dotenv $dotenv = null) } /** - * @param string $file Environment file path relative to `$path` - * @param string|null $path Environment file path * @return void */ - public function load(string $file = '.env', ?string $path = null): void + public function usePutEnv(): void { - $this->loadFile($this->fullpathFor($file, $path)); + $this->usePutEnv = true; + } + + /** + * @return void + */ + public function dontUsePutEnv(): void + { + $this->usePutEnv = false; } /** @@ -372,42 +393,19 @@ public function determineEnvType(): string } /** + * @param string $file * @param string $path * @return void */ - public function loadFile(string $path): void + public function load(string $file, string $path): void { - $loaded = $_ENV['WPSTARTER_ENV_LOADED'] ?? $_SERVER['WPSTARTER_ENV_LOADED'] ?? null; - if ($loaded !== null) { + $loaded = $_ENV['WPSTARTER_ENV_LOADED'] ?? $_SERVER['WPSTARTER_ENV_LOADED'] ?? false; + if (filter_var($loaded, FILTER_VALIDATE_BOOLEAN)) { self::$loadedVars = []; return; } - if (self::$loadedVars !== null) { - return; - } - - $path = $this->fullpathFor('', $path); - if ($path) { - $this->dotenv()->load($path); - self::loadedVars(); - } - } - - /** - * @param string $file - * @param string|null $path - * @return void - */ - public function loadAppended(string $file, ?string $path = null): void - { - if (self::$loadedVars === null) { - $this->load($file, $path); - - return; - } - $fullpath = $this->fullpathFor($file, $path); if (!$fullpath) { return; @@ -416,12 +414,26 @@ public function loadAppended(string $file, ?string $path = null): void $contents = @file_get_contents($fullpath); /** @var array $values */ $values = $contents ? $this->dotenv()->parse($contents, $fullpath) : []; + + $index = count(self::loadedVars()); foreach ($values as $name => $value) { - if ($this->isWritable($name)) { - $this->write($name, $value); - self::$loadedVars[$name] = true; + if (!$this->isWritable($name)) { + continue; } + $this->doWrite($name, $value, false); + if (!isset(self::$loadedVars[$name])) { + self::$loadedVars[$name] = $index; + $index++; + } + } + + if (!self::$loadedVars) { + return; } + + $keysLoaded = implode(',', array_keys(self::$loadedVars)); + $_SERVER['WPSTARTER_DOTENV_VARS'] = $keysLoaded; + $_ENV['WPSTARTER_DOTENV_VARS'] = $keysLoaded; } /** @@ -430,7 +442,7 @@ public function loadAppended(string $file, ?string $path = null): void */ public function has(string $name): bool { - return $this->read($name) !== null; + return isset(self::$cache[$name]) || ($this->doRead($name) !== null); } /** @@ -445,63 +457,19 @@ public function hasCachedValues(): bool * @param string $name * @return mixed * - * phpcs:disable Generic.Metrics.CyclomaticComplexity * phpcs:disable Inpsyde.CodeQuality.ReturnTypeDeclaration */ public function read(string $name) { - // phpcs:enable Generic.Metrics.CyclomaticComplexity // phpcs:enable Inpsyde.CodeQuality.ReturnTypeDeclaration - $cached = self::$cache[$name] ?? null; if ($cached !== null) { return $cached[1]; } - if ($this->fromCache && (self::$loadedVars === null)) { - self::loadedVars(); - } - - // We don't check $_SERVER for keys starting with 'HTTP_' because clients can write there. - $serverSafe = strpos($name, 'HTTP_') !== 0; - - // We consider anything not loaded by Symfony Dot Env as "actual" environment, and because - // of thread safety issues, we don't use getenv() for those "actual" environment variables. - $loadedVar = self::$loadedVars && $this->isLoadedVar($name); - - $readGetEnv = false; - switch (true) { - case ($loadedVar && $serverSafe): - // Both $_SERVER and getenv() are ok. - $value = $_ENV[$name] ?? $_SERVER[$name] ?? null; - $readGetEnv = true; - break; - case ($loadedVar && !$serverSafe): - // $_SERVER is not ok, getenv() is. - $value = $_ENV[$name] ?? null; - $readGetEnv = true; - break; - case ($serverSafe): - // $_SERVER is ok, getenv() is not. - $value = $_ENV[$name] ?? $_SERVER[$name] ?? null; - break; - default: - // Neither $_SERVER nor getenv() are ok. - $value = $_ENV[$name] ?? null; - break; - } - - if (($value === null) && $readGetEnv) { - $value = getenv($name); - ($value === false) and $value = null; - } - - // Superglobals can contain anything, but environment variables must be strings. - // We can cast later scalar values. - // `is_scalar()` also discards null, and that is fine because we want to return null if - // that's the value we got here. + $value = $this->doRead($name); - return is_scalar($value) ? $this->maybeFilterThenCache($name, (string)$value) : null; + return ($value === null) ? null : $this->maybeFilterThenCache($name, (string)$value); } /** @@ -529,11 +497,7 @@ public function write(string $name, string $value): void throw new \BadMethodCallException("{$name} is not a writable ENV var."); } - putenv("{$name}={$value}"); - $_ENV[$name] = $value; - (strpos($name, 'HTTP_') !== 0) and $_SERVER[$name] = $value; - - $this->maybeFilterThenCache($name, $value); + $this->doWrite($name, $value, true); } /** @@ -547,29 +511,26 @@ public function dumpCached(string $file): bool } // Make sure cached env contains all loaded vars. - $symfonyLoaded = ''; - if (self::$loadedVars) { - foreach (array_keys(self::$loadedVars) as $key) { - $symfonyLoaded .= $symfonyLoaded ? ",{$key}" : $key; - $this->read($key); - } - } + $loaded = $this->collectVariables(); if (!static::$cache) { return false; } - $content = " list($value, $filtered)) { $slashed = str_replace("'", "\'", $value); // For defined constants, dump the `define` with filtered value, if any. if ( - in_array($key, $this->definedConstants, true) + in_array($key, $this->definedConstants ?? [], true) || array_key_exists($key, self::WP_CONSTANTS) ) { $define = $value !== $filtered @@ -585,7 +546,7 @@ public function dumpCached(string $file): bool } // For env loaded from file, dump the variable definition. - $content .= "putenv('{$key}={$slashed}');\n"; + $content .= $this->usePutEnv ? "putenv('{$key}={$slashed}');\n" : ''; $content .= "\$_ENV['{$key}'] = '{$slashed}';\n"; (strpos($key, 'HTTP_') !== 0) and $content .= "\$_SERVER['{$key}'] = '{$slashed}';\n\n"; } @@ -661,11 +622,49 @@ public function isWpSetup(): bool /** * @param string $name - * @return bool + * @param string $value + * @param bool $filterAndCache + * @return void */ - private function isLoadedVar(string $name): bool + private function doWrite(string $name, string $value, bool $filterAndCache): void { - return array_key_exists($name, self::loadedVars()); + $_ENV[$name] = $value; + (strpos($name, 'HTTP_') !== 0) and $_SERVER[$name] = $value; + $this->usePutEnv and putenv("{$name}={$value}"); + $filterAndCache and $this->maybeFilterThenCache($name, $value); + } + + /** + * @param string $name + * @return scalar|null + * + * phpcs:disable Inpsyde.CodeQuality.ReturnTypeDeclaration + */ + private function doRead(string $name) + { + // phpcs:enable Inpsyde.CodeQuality.ReturnTypeDeclaration + $value = $_ENV[$name] ?? null; + + // We don't check $_SERVER for keys starting with 'HTTP_' because clients can write there. + if (($value === null) && (strpos($name, 'HTTP_') !== 0)) { + $value = $_SERVER[$name] ?? null; + } + + if ($value === null) { + (self::$loadedVars === null) and self::loadedVars(); + if (!isset(self::$loadedVars[$name])) { + // Maybe someone used `putenv`, so we're going to use `getenv`, but this is + // discouraged. We're going to cache this on read to hopeful reduce issues. + $value = getenv($name); + ($value === false) and $value = null; + } + } + + // Superglobals can contain anything, but environment variables must be strings. + // We can cast later scalar values. + // `is_scalar()` also discards null, and that is fine because we are going to return null if + // that's the $value we got here. + return is_scalar($value) ? $value : null; } /** @@ -708,7 +707,6 @@ private function maybeFilterThenCache(string $name, string $value) private function defineConstantFromVar(string $name): bool { $value = $this->read($name); - if ($value === null) { return false; } @@ -749,7 +747,17 @@ private function determineWpEnvType(string $envType): string */ private function isWritable(string $name): bool { - return !$this->has($name) || $this->isLoadedVar($name); + if (isset(static::$cache[$name])) { + return false; + } + + if (!$this->has($name)) { + return true; + } + + $loaded = self::loadedVars(); + + return isset($loaded[$name]); } /** @@ -769,6 +777,22 @@ private function fullpathFor(string $filename, ?string $basePath = null): string return $fullpath; } + /** + * @return string + */ + private function collectVariables(): string + { + $loaded = ''; + if (self::$loadedVars) { + foreach (array_keys(self::$loadedVars) as $key) { + $loaded .= $loaded ? ",{$key}" : $key; + $this->read($key); + } + } + + return $loaded; + } + /** * @return Dotenv */ @@ -777,10 +801,6 @@ private function dotEnv(): Dotenv $dotEnv = $this->dotenv ?? self::$defaultDotEnv; if (!$dotEnv) { self::$defaultDotEnv = new Dotenv(); - /** @psalm-suppress RedundantCondition */ - if (is_callable([self::$defaultDotEnv, 'usePutenv'])) { - self::$defaultDotEnv->usePutenv(true); - } $dotEnv = self::$defaultDotEnv; } diff --git a/src/Step/WpConfigStep.php b/src/Step/WpConfigStep.php index 2045d99..b7bd724 100644 --- a/src/Step/WpConfigStep.php +++ b/src/Step/WpConfigStep.php @@ -144,6 +144,7 @@ public function run(Config $config, Paths $paths): int 'EARLY_HOOKS_FILE' => $earlyHookFile, 'ENV_BOOTSTRAP_DIR' => $envBootstrapDir ?: $envRelDir, 'ENV_FILE_NAME' => $config[Config::ENV_FILE]->unwrapOrFallback('.env'), + 'ENV_USE_PUTENV' => $config[Config::ENV_USE_PUTENV]->unwrapOrFallback(false), 'WPSTARTER_PATH' => $compatMode ? $envRelDir : $rootRelDir, 'ENV_REL_PATH' => $envRelDir, 'REGISTER_THEME_DIR' => $register ? 'true' : 'false', diff --git a/src/Util/Locator.php b/src/Util/Locator.php index b1c69c4..fe916e0 100644 --- a/src/Util/Locator.php +++ b/src/Util/Locator.php @@ -335,7 +335,7 @@ public function env(): WordPressEnvBridge $bridge = new WordPressEnvBridge(); $bridge->load($file, $dir); $environment = $bridge->determineEnvType(); - ($environment !== 'example') and $bridge->loadAppended("{$file}.{$environment}", $dir); + ($environment !== 'example') and $bridge->load("{$file}.{$environment}", $dir); $this->objects[__FUNCTION__] = $bridge; } diff --git a/templates/wp-config.php b/templates/wp-config.php index d78ab38..636ba42 100644 --- a/templates/wp-config.php +++ b/templates/wp-config.php @@ -58,6 +58,7 @@ * overridden from file, even if `WPSTARTER_ENV_LOADED` is not set. */ filter_var('{{{CACHE_ENV}}}', FILTER_VALIDATE_BOOLEAN) and Helpers::enableCache(); + filter_var('{{{ENV_USE_PUTENV}}}', FILTER_VALIDATE_BOOLEAN) and Helpers::usePutenv(); [$envType, $envIsCached] = Helpers::loadEnvFiles('{{{ENV_FILE_NAME}}}', WPSTARTER_ENV_PATH); /** @@ -255,13 +256,7 @@ static function ($color): string { ENV_CACHE : { /** On shutdown, we dump environment so that on subsequent requests we can load it faster */ - if (Helpers::isEnvCacheEnabled() && Helpers::isWpEnvSetup()) { - register_shutdown_function( - static function (): void { - Helpers::dumpEnvCache(WPSTARTER_ENV_PATH . WordPressEnvBridge::CACHE_DUMP_FILE); - } - ); - } + register_shutdown_function([Helpers::class, 'dumpEnvCache']); } #@@/ENV_CACHE DEBUG_INFO : { diff --git a/tests/integration/Env/WordPressEnvBridgeTest.php b/tests/integration/Env/WordPressEnvBridgeTest.php index 54aa477..5913c53 100644 --- a/tests/integration/Env/WordPressEnvBridgeTest.php +++ b/tests/integration/Env/WordPressEnvBridgeTest.php @@ -38,7 +38,7 @@ public function testLoadSkippingFile(): void * @test * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge */ - public function testLoadFile(): void + public function testLoad(): void { $bridge = new WordPressEnvBridge(); @@ -52,21 +52,6 @@ public function testLoadFile(): void static::assertSame('', $bridge->read('COOKIE_DOMAIN')); } - /** - * @test - * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge - */ - public function testLoadFileMoreTimesDoNothing(): void - { - $bridge = new WordPressEnvBridge(); - - $bridge->load('example.env', $this->fixturesPath()); - $bridge->load('more.env', $this->fixturesPath()); - - static::assertSame('localhost', $bridge->read('DB_HOST')); - static::assertNull($bridge->read('FOO')); - } - /** * @test * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge @@ -88,26 +73,11 @@ public function testHttpServerVarsAreReturnedOnlyIfLoaded(): void * @test * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge */ - public function testGetEnvIsSkippedForNotLoadedVars(): void - { - putenv('PUT_THE_ENV=HERE'); - - $bridge = new WordPressEnvBridge(); - $bridge->load('more.env', $this->fixturesPath()); - - static::assertSame('HERE', getenv('PUT_THE_ENV')); - static::assertNull($bridge->read('PUT_THE_ENV')); - } - - /** - * @test - * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge - */ - public function testLoadAppended(): void + public function testMultipleLoads(): void { $bridge = new WordPressEnvBridge(); $bridge->load('example.env', $this->fixturesPath()); - $bridge->loadAppended('more.env', $this->fixturesPath()); + $bridge->load('more.env', $this->fixturesPath()); static::assertSame('192.168.1.255', $bridge->read('DB_HOST')); static::assertSame('BAR BAR', $bridge->read('BAZ')); @@ -117,49 +87,29 @@ public function testLoadAppended(): void * @test * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge */ - public function testLoadAppendedWrongFileDoNothing(): void + public function testMultipleLoadsDontOverrideIfRead(): void { $bridge = new WordPressEnvBridge(); + $bridge->load('example.env', $this->fixturesPath()); - $bridge->loadAppended('not-more.env', $this->fixturesPath()); + static::assertSame('localhost', $bridge->read('DB_HOST')); + $bridge->load('more.env', $this->fixturesPath()); static::assertSame('localhost', $bridge->read('DB_HOST')); - static::assertNull($bridge->read('BAZ')); } /** * @test * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge */ - public function testLoadAppendedAlwaysLoadsIfLoadWasCalledAndEnvLoaded(): void + public function testLoadAppendedWrongFileDoNothing(): void { - $_ENV['WPSTARTER_ENV_LOADED'] = 1; - $bridge = new WordPressEnvBridge(); $bridge->load('example.env', $this->fixturesPath()); + $bridge->load('not-more.env', $this->fixturesPath()); - static::assertNull($bridge->read('DB_HOST')); - - $bridge->loadAppended('more.env', $this->fixturesPath()); - - static::assertSame('192.168.1.255', $bridge->read('DB_HOST')); - } - - /** - * @test - * @covers \WeCodeMore\WpStarter\Env\WordPressEnvBridge - */ - public function testLoadAppendedNotLoadsIfLoadWasNotCalledAndEnvLoaded(): void - { - $_ENV['WPSTARTER_ENV_LOADED'] = 1; - - $bridge = new WordPressEnvBridge(); - - static::assertNull($bridge->read('DB_HOST')); - - $bridge->loadAppended('more.env', $this->fixturesPath()); - - static::assertNull($bridge->read('DB_HOST')); + static::assertSame('localhost', $bridge->read('DB_HOST')); + static::assertNull($bridge->read('BAZ')); } /** @@ -172,14 +122,14 @@ public function testLoadAppendedDoesNotOverrideActualEnv(): void $bridge = new WordPressEnvBridge(); $bridge->load('example.env', $this->fixturesPath()); - $bridge->loadAppended('more.env', $this->fixturesPath()); + $bridge->load('more.env', $this->fixturesPath()); $bridge->write('NEW', 'new!'); $env = $bridge->readMany('DB_NAME', 'DB_HOST', 'FOO', 'NEW'); static::assertSame('wp', $env['DB_NAME']); // example.env static::assertSame('192.168.1.255', $env['DB_HOST']); // more.env - static::assertSame('I come first.', $env['FOO']); // actual.env + static::assertSame('I come first.', $env['FOO']); // actual env static::assertSame('new!', $env['NEW']); // offsetSet } @@ -291,7 +241,7 @@ public function testDumpCacheAndLoadFromDump(): void $_ENV['FS_CHMOD_DIR'] = '0644'; $bridge = new WordPressEnvBridge(); - $bridge->loadFile($this->fixturesPath() . '/example.env'); + $bridge->load('example.env', $this->fixturesPath()); $bridge->write('FOO', 'Bar!'); static::assertFalse($bridge->hasCachedValues()); @@ -304,7 +254,6 @@ public function testDumpCacheAndLoadFromDump(): void static::assertSame('Awesome!', $bridge->read('MY_AWESOME_VAR')); // and also works with "manual" added env. static::assertSame('Bar!', $bridge->read('FOO')); - static::assertSame('Bar!', getenv('FOO')); static::assertSame('Bar!', $_ENV['FOO'] ?? ''); $loadedVars = WordPressEnvBridge::loadedVars(); @@ -322,7 +271,7 @@ function (bool $setCache) use (&$oldCache): void { $setCache and $oldCache = static::$cache; static::$cache = []; static::$loadedVars = null; - putenv('SYMFONY_DOTENV_VARS'); + unset($_SERVER['WPSTARTER_DOTENV_VARS'], $_ENV['WPSTARTER_DOTENV_VARS']); }, $bridge, WordPressEnvBridge::class @@ -345,18 +294,15 @@ function (bool $setCache) use (&$oldCache): void { unset($_ENV['DB_NAME']); unset($_SERVER['DB_NAME']); // ...and prove it is clean - static::assertSame(false, getenv('MY_BAD_VAR')); static::assertNull($_ENV['MY_BAD_VAR'] ?? null); static::assertNull($_ENV['MY_BAD_VAR'] ?? null); - static::assertSame(false, getenv('DB_HOST')); static::assertNull($_ENV['DB_HOST'] ?? null); static::assertNull($_ENV['DB_HOST'] ?? null); - static::assertSame(false, getenv('DB_NAME')); static::assertNull($_ENV['DB_NAME'] ?? null); static::assertNull($_ENV['DB_NAME'] ?? null); $cachedBridge = WordPressEnvBridge::buildFromCacheDump($cacheFile); - $cachedContent = file_get_contents($cacheFile); + $contents = file_get_contents($cacheFile); static::assertFalse($cachedBridge->isWpSetup()); static::assertTrue($cachedBridge->hasCachedValues()); @@ -374,13 +320,18 @@ function (): array { static::assertSame($oldCache, $newCache); // These variables were accessed via read() and should be part of the dump - static::assertStringContainsString("putenv('MY_AWESOME_VAR=Awesome!');", $cachedContent); - static::assertStringContainsString("putenv('DB_NAME=wp');", $cachedContent); - static::assertStringContainsString("define('DB_NAME', 'wp');", $cachedContent); + static::assertStringContainsString("define('DB_NAME', 'wp');", $contents); + static::assertStringContainsString("\$_SERVER['DB_NAME'] = 'wp';", $contents); + static::assertStringContainsString("\$_ENV['DB_NAME'] = 'wp';", $contents); + static::assertStringContainsString("\$_SERVER['MY_AWESOME_VAR'] = 'Awesome!';", $contents); + static::assertStringContainsString("\$_ENV['MY_AWESOME_VAR'] = 'Awesome!';", $contents); + // ... and these variables were NOT accessed via read() but still should be part of the dump - static::assertStringContainsString("putenv('MY_BAD_VAR=Bad!');", $cachedContent); - static::assertStringContainsString("putenv('EMPTY_TRASH_DAYS=12');", $cachedContent); - static::assertStringContainsString("define('EMPTY_TRASH_DAYS', 12);", $cachedContent); + static::assertStringContainsString("\$_SERVER['MY_BAD_VAR'] = 'Bad!';", $contents); + static::assertStringContainsString("\$_ENV['MY_BAD_VAR'] = 'Bad!';", $contents); + static::assertStringContainsString("\$_SERVER['EMPTY_TRASH_DAYS'] = '12';", $contents); + static::assertStringContainsString("\$_ENV['EMPTY_TRASH_DAYS'] = '12';", $contents); + static::assertStringContainsString("define('EMPTY_TRASH_DAYS', 12);", $contents); // WP constants are set for actual env, accessed loaded env, and not accessed loaded env static::assertTrue(defined('WP_POST_REVISIONS')); @@ -397,15 +348,12 @@ function (): array { static::assertSame(7, WP_POST_REVISIONS); static::assertSame('0644', $_ENV['FS_CHMOD_DIR'] ?? null); static::assertSame(0644, FS_CHMOD_DIR); - static::assertSame('Bar!', getenv('FOO')); static::assertSame('Bar!', $_ENV['FOO'] ?? null); static::assertSame('Bar!', $_SERVER['FOO'] ?? null); // Loaded env can be read, we proved they were not there before cache - static::assertSame('Bad!', getenv('MY_BAD_VAR')); static::assertSame('Bad!', $_ENV['MY_BAD_VAR'] ?? null); static::assertSame('Bad!', $_SERVER['MY_BAD_VAR'] ?? null); - static::assertSame('localhost', getenv('DB_HOST')); static::assertSame('localhost', $_ENV['DB_HOST'] ?? null); static::assertSame('localhost', $_SERVER['DB_HOST'] ?? null); @@ -437,7 +385,7 @@ public function testsSetupConstants(): void $_ENV['FS_CHMOD_DIR'] = '0644'; $bridge = new WordPressEnvBridge(); - $bridge->loadFile($this->fixturesPath() . '/example.env'); + $bridge->load('example.env', $this->fixturesPath()); $bridge->write('FOO', 'Bar!'); $bridge->write( @@ -463,6 +411,7 @@ public function testsSetupConstants(): void static::assertSame(0644, FS_CHMOD_DIR); static::assertSame('on', SUNRISE); + static::assertSame('One!', PLUGIN_CONFIG_ONE); static::assertSame(2, PLUGIN_CONFIG_TWO); static::assertTrue(PLUGIN_CONFIG_THREE); static::assertSame('4', PLUGIN_CONFIG_FOUR); @@ -510,37 +459,32 @@ public function testLoadCacheFromScratch(): void static::assertSame("on", SUNRISE); // Variables from actual env are not set in env in the dump file... - static::assertFalse(getenv('WP_POST_REVISIONS')); static::assertNull($_ENV['WP_POST_REVISIONS'] ?? null); - static::assertFalse(getenv('FS_CHMOD_DIR')); static::assertNull($_ENV['FS_CHMOD_DIR'] ?? null); - static::assertFalse(getenv('FOO')); static::assertNull($_ENV['FOO'] ?? null); + static::assertNull($_SERVER['WP_POST_REVISIONS'] ?? null); + static::assertNull($_SERVER['FS_CHMOD_DIR'] ?? null); + static::assertNull($_SERVER['FOO'] ?? null); // but because there were accessed, cache still contains them. static::assertSame(7, $cachedBridge->read('WP_POST_REVISIONS')); static::assertSame(0644, $cachedBridge->read('FS_CHMOD_DIR')); static::assertSame('Bar!', $cachedBridge->read('FOO')); // These loaded env vars were accessed in previous test (via setupWordPress()). - static::assertSame('xxx_', getenv('DB_TABLE_PREFIX')); static::assertSame('xxx_', $_ENV['DB_TABLE_PREFIX'] ?? null); static::assertSame('xxx_', $_SERVER['DB_TABLE_PREFIX'] ?? null); static::assertSame('xxx_', $cachedBridge->read('DB_TABLE_PREFIX')); - static::assertSame('wp', getenv('DB_NAME')); static::assertSame('wp', $_ENV['DB_NAME'] ?? null); static::assertSame('wp', $_SERVER['DB_NAME'] ?? null); static::assertSame('wp', $cachedBridge->read('DB_NAME')); - static::assertSame('', getenv('COOKIE_DOMAIN')); static::assertSame('', $_ENV['COOKIE_DOMAIN'] ?? null); static::assertSame('', $_SERVER['COOKIE_DOMAIN'] ?? null); static::assertSame('', $cachedBridge->read('COOKIE_DOMAIN')); - static::assertSame('', getenv('COOKIE_DOMAIN')); static::assertSame('on', $_ENV['SUNRISE'] ?? null); static::assertSame('on', $_SERVER['SUNRISE'] ?? null); static::assertSame('on', $cachedBridge->read('SUNRISE')); // These loaded env vars were NOT accessed in previous test, but they can be accessed now. - static::assertSame('Bad!', getenv('MY_BAD_VAR')); static::assertSame('Bad!', $_ENV['MY_BAD_VAR'] ?? null); static::assertSame('Bad!', $_SERVER['MY_BAD_VAR'] ?? null); static::assertSame('Bad!', $cachedBridge->read('MY_BAD_VAR'));