list of clean values. A positive rule (includes, is) whose input * had an invalid value, or too many values, becomes [ NOTHING ], so it matches no order (a bad * filter never widens a report). Invalid values in a negative rule (excludes, is_not) are dropped; * too many values there also match nothing. * * @since 1.0.0 */ final class Params { /** * Filters and their rules. The first rule is the positive one. */ const FILTERS = array( 'country' => array( 'includes', 'excludes' ), 'state' => array( 'includes', 'excludes' ), 'city' => array( 'includes', 'excludes' ), 'payment' => array( 'is', 'is_not' ), 'shipping' => array( 'is', 'is_not' ), 'role' => array( 'is', 'is_not' ), ); /** * Role value for shoppers without an account (prefixed so it can't clash with a role slug). */ const GUEST = 'apwc_guest'; /** * Value of a positive rule that matches nothing. Fails every filter's check, so parsing a * normalized result again gives the same result. */ const NOTHING = '!none'; /** * Default most values per rule (city has its own). */ const MAX = 50; /** * Default most cities per rule. */ const CITY_MAX = 20; /** * Longest city name. */ const CITY_LENGTH = 100; /** * Param key for a filter and rule. * * @since 1.0.0 * @param string $filter Filter. * @param string $rule Rule. * @return string */ public static function key( $filter, $rule ) { return 'apwc_' . $filter . '_' . $rule; } /** * Every param key. * * @since 1.0.0 * @return string[] */ public static function keys() { $keys = array(); foreach ( self::FILTERS as $filter => $rules ) { foreach ( $rules as $rule ) { $keys[] = self::key( $filter, $rule ); } } return $keys; } /** * Whether a rule is positive (includes / is). * * @since 1.0.0 * @param string $rule Rule. * @return bool */ public static function positive( $rule ) { return 'includes' === $rule || 'is' === $rule; } /** * Our raw params from a request or argument array: strings and lists of scalars only, nothing * checked (used before permission checks, so it never queries). * * @since 1.0.0 * @param mixed $source Params. * @return array> */ public static function raw( $source ) { $out = array(); if ( ! is_array( $source ) ) { return $out; } foreach ( self::keys() as $key ) { if ( ! isset( $source[ $key ] ) ) { continue; } $value = $source[ $key ]; if ( is_scalar( $value ) ) { $out[ $key ] = Str::scalar( $value ); } elseif ( is_array( $value ) ) { // Nested values are kept as a marker so validation rejects them. $out[ $key ] = array_map( static fn( $item ) => is_scalar( $item ) ? Str::scalar( $item ) : self::NOTHING, array_values( $value ) ); } } return $out; } /** * Whether any of our params is present and non-empty. * * @since 1.0.0 * @param mixed $source Params. * @return bool */ public static function has_any( $source ) { if ( ! is_array( $source ) ) { return false; } foreach ( self::keys() as $key ) { if ( isset( $source[ $key ] ) && array() !== self::values( $source[ $key ] ) ) { return true; } } return false; } /** * Filters used in the params (to load only the lists needed). * * @since 1.0.0 * @param array $source Params. * @param string[] $enabled Enabled filters. * @return string[] */ public static function used( array $source, array $enabled ) { $used = array(); foreach ( self::FILTERS as $filter => $rules ) { if ( ! in_array( $filter, $enabled, true ) ) { continue; } foreach ( $rules as $rule ) { $key = self::key( $filter, $rule ); if ( isset( $source[ $key ] ) && array() !== self::values( $source[ $key ] ) ) { $used[] = $filter; break; } } } return $used; } /** * Split a CSV string or list into trimmed, non-empty strings. * * @since 1.0.0 * @param mixed $value Raw value. * @return array|null Null when the shape is wrong (objects, nested lists). */ public static function values( $value ) { if ( is_string( $value ) || is_int( $value ) ) { $value = explode( ',', (string) $value ); } if ( ! is_array( $value ) ) { return null; } $out = array(); foreach ( $value as $item ) { if ( ! is_string( $item ) && ! is_int( $item ) ) { return null; } $item = trim( (string) $item ); if ( '' !== $item ) { $out[] = $item; } } return $out; } /** * Parse and validate. * * @since 1.0.0 * @param array $source Params. * @param string[] $enabled Enabled filters (others are ignored). * @param array> $known Known codes: 'country', 'state' (CC:ST) and 'role' => code => label. * @param array $max Most values per rule, by filter (defaults MAX / CITY_MAX). * @return array> Param key => clean values. */ public static function parse( array $source, array $enabled, array $known, array $max = array() ) { $out = array(); foreach ( self::FILTERS as $filter => $rules ) { if ( ! in_array( $filter, $enabled, true ) ) { continue; } $limit = (int) ( $max[ $filter ] ?? ( 'city' === $filter ? self::CITY_MAX : self::MAX ) ); foreach ( $rules as $rule ) { $key = self::key( $filter, $rule ); if ( ! isset( $source[ $key ] ) ) { continue; } $values = self::values( $source[ $key ] ); if ( array() === $values ) { continue; // Not set. } $clean = self::clean_list( $filter, $rule, $values, $known[ $filter ] ?? array(), $limit ); if ( $clean ) { $out[ $key ] = $clean; } } } return $out; } /** * Clean one rule's values. * * @param string $filter Filter. * @param string $rule Rule. * @param array|null $values Values (null = bad shape). * @param array $known Known codes for this filter. * @param int $limit Most values. * @return array Clean values; [ NOTHING ] = match no order; [] = drop the rule. */ private static function clean_list( $filter, $rule, $values, array $known, $limit ) { // Bad shape, too many values, or a rule already normalized to "match nothing" (so parsing a // normalized result again never widens it). if ( null === $values || count( $values ) > max( 1, $limit ) || in_array( self::NOTHING, $values, true ) ) { return array( self::NOTHING ); } $clean = array(); foreach ( $values as $value ) { $value = self::clean( $filter, $value, $known ); if ( null === $value ) { if ( self::positive( $rule ) ) { return array( self::NOTHING ); } continue; } $clean[] = $value; } return array_values( array_unique( $clean ) ); } /** * Clean one value. * * @since 1.0.0 * @param string $filter Filter. * @param string $value Value. * @param array $known Known codes for this filter (country, state, role). * @return string|null Null when invalid. */ public static function clean( $filter, $value, array $known = array() ) { $value = trim( (string) $value ); if ( self::NOTHING === $value ) { return null; } switch ( $filter ) { case 'country': $value = strtoupper( $value ); return isset( $known[ $value ] ) ? $value : null; case 'state': $parts = explode( ':', $value, 2 ); if ( 2 !== count( $parts ) || '' === $parts[1] ) { return null; } $value = strtoupper( $parts[0] ) . ':' . $parts[1]; return isset( $known[ $value ] ) ? $value : null; case 'city': $value = trim( sanitize_text_field( str_replace( ',', '', $value ) ) ); $length = function_exists( 'mb_strlen' ) ? mb_strlen( $value ) : strlen( $value ); return '' !== $value && $length <= self::CITY_LENGTH ? $value : null; case 'payment': // A gateway ID in sanitize_key()'s characters (case kept): gateways removed since still match. return preg_match( '/^[A-Za-z0-9_-]{1,100}$/', $value ) ? $value : null; case 'shipping': // A method type ("flat_rate") or type and instance ("flat_rate:3"). return preg_match( '/^[A-Za-z0-9_-]{1,100}(?::[0-9]{1,10})?$/', $value ) ? $value : null; case 'role': return isset( $known[ $value ] ) ? $value : null; } return null; } }