Source: classes/SettingsValidator.php

<?php
/**
 * Settings validator.
 *
 * @package PoweredCache
 */

namespace PoweredCache;

use PoweredCache\Optimizer\Helper;

/**
 * Builds non-blocking validation reports for settings payloads.
 *
 * @since 4.0.0
 */
class SettingsValidator {

	const SEVERITY_ERROR   = 'error';
	const SEVERITY_WARNING = 'warning';
	const SEVERITY_INFO    = 'info';

	/**
	 * Issue action: rewrite configuration files from the saved settings.
	 *
	 * The settings app shows a "Fix configuration" button for issues with this action.
	 */
	const ACTION_REGENERATE_CONFIGURATION = 'regenerate_configuration';

	/**
	 * Settings whose lines are matched as regular expression fragments.
	 *
	 * Keep this list in sync with the places that build `#(line|line)#` patterns from
	 * these settings: Optimizer\Helper, LazyLoad, CDN, AdvancedCache, the page cache
	 * drop-in, and Premium's unused CSS exclusions.
	 *
	 * @var string[]
	 */
	const REGEX_FIELDS = array(
		'rejected_uri',
		'rejected_user_agents',
		'rejected_cookies',
		'excluded_css_files',
		'excluded_js_files',
		'js_defer_exclusions',
		'js_delay_exclusions',
		'lazy_load_exclusions',
		'cdn_rejected_files',
		'ucss_excluded_files',
	);

	/**
	 * Runtime context for schema metadata.
	 *
	 * @var array
	 */
	private $context;

	/**
	 * Whether Premium fields can be edited.
	 *
	 * @var bool|null
	 */
	private $premium_available;

	/**
	 * Constructor.
	 *
	 * @param array     $context Runtime context.
	 * @param bool|null $premium_available Whether Premium fields can be edited.
	 */
	public function __construct( array $context = array(), $premium_available = null ) {
		$this->context           = $context;
		$this->premium_available = null === $premium_available ? null : (bool) $premium_available;
	}

	/**
	 * Create a validator instance.
	 *
	 * @param array     $context Runtime context.
	 * @param bool|null $premium_available Whether Premium fields can be edited.
	 *
	 * @return SettingsValidator
	 */
	public static function factory( array $context = array(), $premium_available = null ) {
		return new self( $context, $premium_available );
	}

	/**
	 * Validate a settings payload.
	 *
	 * @param array $settings Settings payload.
	 *
	 * @return array
	 */
	public function validate( array $settings ) {
		$issues = array();
		$fields = SettingsSchema::fields( $this->context );

		foreach ( $settings as $key => $value ) {
			if ( ! isset( $fields[ $key ] ) ) {
				continue;
			}

			$issues = array_merge( $issues, $this->validate_field( $key, $value, $fields[ $key ], $settings ) );
		}

		$issues = array_merge( $issues, CompatibilityRules::factory()->settings_issues( $settings ) );
		$issues = array_merge( $issues, $this->validate_environment( $settings ) );

		/**
		 * Filter settings validation issues.
		 *
		 * @hook powered_cache_settings_validation_issues
		 *
		 * @param {array} $issues Validation issues.
		 * @param {array} $settings Settings payload.
		 *
		 * @return {array} New value.
		 *
		 * @since 4.0.0
		 */
		return apply_filters( 'powered_cache_settings_validation_issues', $issues, $settings );
	}

	/**
	 * Build a validation summary.
	 *
	 * @param array $settings Settings payload.
	 *
	 * @return array
	 */
	public function report( array $settings ) {
		$issues = $this->validate( $settings );
		$counts = array(
			self::SEVERITY_ERROR   => 0,
			self::SEVERITY_WARNING => 0,
			self::SEVERITY_INFO    => 0,
		);

		foreach ( $issues as $issue ) {
			if ( isset( $counts[ $issue['severity'] ] ) ) {
				$counts[ $issue['severity'] ]++;
			}
		}

		return array(
			'valid'  => 0 === $counts[ self::SEVERITY_ERROR ],
			'counts' => $counts,
			'issues' => $issues,
		);
	}

	/**
	 * Validate one known schema field.
	 *
	 * @param string $key Setting key.
	 * @param mixed  $value Setting value.
	 * @param array  $field Schema field.
	 * @param array  $settings Full settings payload.
	 *
	 * @return array
	 */
	private function validate_field( $key, $value, array $field, array $settings ) {
		$issues = array();

		if ( SettingsSchema::TYPE_ENUM === $field['type'] && ! empty( $field['enum'] ) && ! in_array( $value, $field['enum'], true ) ) {
			$issues[] = $this->issue(
				$key,
				self::SEVERITY_ERROR,
				'invalid_enum',
				'This setting contains a value outside the allowed options.'
			);
		}

		if ( SettingsSchema::TYPE_INTEGER === $field['type'] && ! is_numeric( $value ) ) {
			$issues[] = $this->issue(
				$key,
				self::SEVERITY_ERROR,
				'invalid_integer',
				'This setting must be a number.'
			);
		}

		if ( SettingsSchema::TYPE_ARRAY === $field['type'] && ! is_array( $value ) ) {
			$issues[] = $this->issue(
				$key,
				self::SEVERITY_ERROR,
				'invalid_array',
				'This setting must be a list.'
			);
		}

		if ( in_array( $key, self::REGEX_FIELDS, true ) && is_string( $value ) && $this->dependencies_met( $field, $settings ) ) {
			$invalid_patterns = $this->invalid_patterns( $value );

			if ( ! empty( $invalid_patterns ) ) {
				// Informational only: these lines still match as plain text.
				$issues[] = $this->issue(
					$key,
					self::SEVERITY_INFO,
					'pattern_as_text',
					sprintf(
						'These lines are not valid regular expressions, so they are matched as plain text: %s. Use * as a wildcard, or escape regex characters such as ( ) [ ] + ? and # with a backslash.',
						implode( ', ', $invalid_patterns )
					)
				);
			}
		}

		return $issues;
	}

	/**
	 * Whether every parent toggle of a field is enabled.
	 *
	 * Values of inactive settings are kept but not used, so they should not raise warnings.
	 *
	 * @param array $field    Schema field.
	 * @param array $settings Full settings payload.
	 *
	 * @return bool
	 */
	private function dependencies_met( array $field, array $settings ) {
		foreach ( (array) ( $field['dependencies'] ?? array() ) as $dependency ) {
			if ( empty( $settings[ $dependency ] ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Find the lines of a multi-line setting that are neither wildcards nor valid regex fragments.
	 *
	 * @param string $value Multi-line setting value.
	 *
	 * @return string[] Invalid lines, trimmed.
	 */
	private function invalid_patterns( $value ) {
		$invalid = array();
		$lines   = preg_split( '#(\r\n|\r|\n)#', $value, -1, PREG_SPLIT_NO_EMPTY );

		foreach ( (array) $lines as $line ) {
			$line = trim( $line );

			if ( '' !== $line && ! Helper::is_valid_exclusion_pattern( $line ) ) {
				$invalid[] = $line;
			}
		}

		return array_values( array_unique( $invalid ) );
	}

	/**
	 * Validate runtime environment signals that can keep settings from working.
	 *
	 * @param array $settings Settings payload.
	 *
	 * @return array
	 */
	private function validate_environment( array $settings ) {
		$issues = array();

		if ( ! empty( $settings['enable_page_cache'] ) ) {
			if ( $this->context_flag_is( 'wp_cache_enabled', false ) ) {
				$issues[] = $this->issue(
					'enable_page_cache',
					self::SEVERITY_WARNING,
					'wp_cache_disabled',
					'Page cache needs WP_CACHE enabled in wp-config.php before cached pages can be served. Use Fix configuration to add it.',
					self::ACTION_REGENERATE_CONFIGURATION
				);
			}

			if ( $this->context_flag_is( 'page_cache_loaded', false ) && $this->context_flag_is( 'wp_cache_enabled', true ) ) {
				$issues[] = $this->issue(
					'enable_page_cache',
					self::SEVERITY_WARNING,
					'advanced_cache_dropin_inactive',
					'The advanced-cache.php drop-in is not loading Powered Cache page caching. Use Fix configuration to recreate it.',
					self::ACTION_REGENERATE_CONFIGURATION
				);
			}

			if ( $this->context_flag_is( 'page_cache_has_problem', true ) ) {
				$issues[] = $this->issue(
					'enable_page_cache',
					self::SEVERITY_WARNING,
					'page_cache_dropin_unavailable',
					'Powered Cache could not load the page cache drop-in from this hosting environment.'
				);
			}
		}

		if ( ! empty( $settings['object_cache'] ) && 'off' !== $settings['object_cache'] ) {
			if ( $this->context_flag_is( 'object_cache_dropin_exists', false ) ) {
				$issues[] = $this->issue(
					'object_cache',
					self::SEVERITY_WARNING,
					'object_cache_dropin_missing',
					'Object cache is enabled, but wp-content/object-cache.php is missing or not accessible. Use Fix configuration to recreate it.',
					self::ACTION_REGENERATE_CONFIGURATION
				);
			}

			if ( $this->context_flag_is( 'object_cache_has_problem', true ) ) {
				$issues[] = $this->issue(
					'object_cache',
					self::SEVERITY_WARNING,
					'object_cache_dropin_unavailable',
					'Powered Cache could not load the selected object cache drop-in from this hosting environment.'
				);
			}
		}

		if ( ! empty( $settings['auto_configure_htaccess'] ) && $this->context_flag_is( 'is_apache', true ) ) {
			if ( $this->context_flag_is( 'htaccess_exists', false ) ) {
				$issues[] = $this->issue(
					'auto_configure_htaccess',
					self::SEVERITY_WARNING,
					'htaccess_missing',
					'Automatic .htaccess configuration is enabled, but the .htaccess file could not be found.'
				);
			} elseif ( $this->context_flag_is( 'htaccess_writable', false ) ) {
				$issues[] = $this->issue(
					'auto_configure_htaccess',
					self::SEVERITY_WARNING,
					'htaccess_not_writable',
					'Automatic .htaccess configuration is enabled, but the .htaccess file is not writable.'
				);
			}
		}

		if ( ! empty( $settings['enable_cloudflare'] ) && empty( $settings['cloudflare_zone'] ) ) {
			$issues[] = $this->issue(
				'cloudflare_zone',
				self::SEVERITY_WARNING,
				'cloudflare_zone_missing',
				'Cloudflare purge is enabled, but no Cloudflare Zone ID is configured.'
			);
		}

		if ( true === $this->premium_available && ! empty( $settings['enable_varnish'] ) && empty( $settings['varnish_ip'] ) && ! $this->has_varnish_default_target() ) {
			$issues[] = $this->issue(
				'varnish_ip',
				self::SEVERITY_WARNING,
				'varnish_target_missing',
				'Varnish purge is enabled, but no Varnish server IP address is configured.'
			);
		}

		return $issues;
	}

	/**
	 * Determine whether a Varnish purge target is configured through constants.
	 *
	 * @return bool
	 */
	private function has_varnish_default_target() {
		return defined( 'VHP_VARNISH_IP' ) && false !== VHP_VARNISH_IP && '' !== trim( (string) VHP_VARNISH_IP );
	}

	/**
	 * Determine whether a known runtime context flag matches an expected value.
	 *
	 * Missing flags are ignored so validators can run safely in older contexts.
	 *
	 * @param string $flag     Context flag.
	 * @param bool   $expected Expected value.
	 *
	 * @return bool
	 */
	private function context_flag_is( $flag, $expected ) {
		return array_key_exists( $flag, $this->context ) && (bool) $this->context[ $flag ] === (bool) $expected;
	}

	/**
	 * Determine whether a field has a meaningful value.
	 *
	 * @param mixed $value Setting value.
	 * @param array $field Schema field.
	 *
	 * @return bool
	 */
	private function has_value( $value, array $field ) {
		if ( SettingsSchema::TYPE_BOOLEAN === $field['type'] ) {
			return ! empty( $value );
		}

		if ( is_array( $value ) ) {
			return ! empty( array_filter( $value ) );
		}

		return '' !== (string) $value;
	}

	/**
	 * Build one validation issue.
	 *
	 * @param string $key Setting key.
	 * @param string $severity Issue severity.
	 * @param string $code Issue code.
	 * @param string $message Human-readable message.
	 * @param string $action Optional action the settings app can offer, e.g. ACTION_REGENERATE_CONFIGURATION.
	 *
	 * @return array
	 */
	private function issue( $key, $severity, $code, $message, $action = '' ) {
		$issue = array(
			'key'      => $key,
			'severity' => $severity,
			'code'     => $code,
			'message'  => $message,
		);

		if ( '' !== $action ) {
			$issue['action'] = $action;
		}

		return $issue;
	}
}