*/ public static function defaults(): array { return array( 'measurement_id' => '', 'tracking_enabled' => true, 'exclude_roles' => array( 'administrator' ), 'track_non_production' => false, 'consent_mode' => 'advanced', 'consent_region_default' => 'eea_denied', 'property_id' => '', 'property_name' => '', 'account_name' => '', 'stream_id' => '', 'stream_default_uri' => '', 'gsc_enabled' => false, 'gsc_site' => '', 'report_roles' => array( 'administrator', 'editor' ), 'post_types' => array( 'post', 'page' ), 'delete_on_uninstall' => true, 'onboarded' => false, 'clarity_enabled' => false, 'clarity_project_id' => '', 'clarity_wp_tags' => true, 'pixel_events' => Platforms::default_events(), 'pixels' => Platforms::defaults(), ); } /** * Sanitizes every write of the settings row, whoever makes it (this * store, WP-CLI, another plugin), by hooking the option's own sanitize * filter. */ public function register(): void { add_filter( 'sanitize_option_' . self::OPTION, array( $this, 'sanitize_option' ), 10, 1 ); } /** * The sanitize_option_tagsmith_settings filter: the row about to be saved, * sanitized like update() does. A value that is not an array is replaced by * the stored settings. * * @param mixed $value The row being saved. * @return array The sanitized row. */ public function sanitize_option( mixed $value ): array { $current = $this->all(); return is_array( $value ) ? self::sanitize( $value, $current ) : $current; } /** * All settings, defaults merged with the stored option. * * @return array */ public function all(): array { $stored = get_option( self::OPTION, array() ); if ( ! is_array( $stored ) ) { $stored = array(); } return self::merge_defaults( self::defaults(), $stored ); } /** * Lays a stored row over the defaults. Associative defaults (a nested * group such as one platform's settings) are merged key by key, so a * stored value never wipes out the defaults beside it. Lists and scalars * from the stored row replace the default whole, and a stored key that is * not in the defaults is kept. Where the default is an array, a stored * value that is not an array (or is a list where the default is a group) * is ignored, so code reading a group never meets a string. * * @param array $defaults The defaults. * @param array $stored The stored row. * @return array */ public static function merge_defaults( array $defaults, array $stored ): array { $merged = $defaults; foreach ( $stored as $key => $value ) { $default = $defaults[ $key ] ?? null; if ( is_array( $default ) ) { // A stored value of the wrong type (a string where an array is // expected, a list where a group is) never replaces the default. if ( ! is_array( $value ) || ( ! array_is_list( $default ) && array() !== $value && array_is_list( $value ) ) ) { continue; } $merged[ $key ] = array_is_list( $default ) ? $value : self::merge_defaults( $default, $value ); } else { $merged[ $key ] = $value; } } return $merged; } /** * A single settings value. * * @param string $key Settings key. */ public function get( string $key ): mixed { return $this->all()[ $key ] ?? null; } /** * Sanitizes, merges and persists the given changes. * * @param array $changes Proposed changes, keyed by settings key. * @return array The full settings array after the update. */ public function update( array $changes ): array { $all = self::sanitize( $changes, $this->all() ); // Every request reads this row, so it is autoloaded. update_option() // applies the flag only when the value changes: when it reports no // change, set the flag on a row that was stored without it. if ( ! update_option( self::OPTION, $all, true ) ) { wp_set_option_autoload( self::OPTION, true ); } return $all; } /** * Sanitizes $input against $current: unknown keys are dropped, invalid * values keep the value already in $current. * * @param array $input Proposed changes. * @param array $current The current (already-sanitized) settings. * @return array The full, sanitized settings array. */ public static function sanitize( array $input, array $current ): array { $out = self::merge_defaults( self::defaults(), $current ); foreach ( $input as $key => $value ) { if ( ! is_string( $key ) || ! array_key_exists( $key, $out ) ) { continue; } $out[ $key ] = self::sanitize_value( $key, $value, $out[ $key ] ); } return $out; } /** * Sanitizes a single value; returns $current when the value is invalid. * * @param string $key Settings key. * @param mixed $value Proposed value. * @param mixed $current Current value for this key. */ private static function sanitize_value( string $key, mixed $value, mixed $current ): mixed { if ( in_array( $key, self::BOOLEAN_KEYS, true ) ) { $bool_value = ( is_bool( $value ) || is_int( $value ) ) ? $value : (string) $value; return rest_sanitize_boolean( $bool_value ); } if ( in_array( $key, self::ROLE_LIST_KEYS, true ) ) { if ( ! is_array( $value ) ) { return $current; } return array_values( array_map( static fn( $v ) => sanitize_key( (string) $v ), $value ) ); } switch ( $key ) { case 'measurement_id': return self::sanitize_measurement_id( $value, $current ); case 'clarity_project_id': return self::normalize_clarity_project_id( $value ) ?? $current; case 'pixel_events': return self::sanitize_pixel_events( $value, $current ); case 'pixels': return self::sanitize_pixels( $value, $current ); case 'consent_mode': return self::sanitize_enum( $value, self::CONSENT_MODES, $current ); case 'consent_region_default': return self::sanitize_enum( $value, self::CONSENT_REGIONS, $current ); case 'property_id': return self::sanitize_pattern( $value, '/^properties\/\d+\z/', $current ); case 'stream_id': return self::sanitize_pattern( $value, '/^properties\/\d+\/dataStreams\/\d+\z/', $current ); case 'gsc_site': return self::sanitize_gsc_site( $value, $current ); case 'stream_default_uri': if ( ! is_string( $value ) ) { return $current; } return '' === $value ? '' : esc_url_raw( $value ); case 'property_name': case 'account_name': if ( ! is_string( $value ) ) { return $current; } return sanitize_text_field( $value ); default: return $current; } } /** * Sanitizes the shared ad pixel events: each of the four can be set on its * own; one whose value is not a boolean-like scalar, and any other key, * changes nothing. page_view is always on and is not stored. * * @param mixed $value Proposed value. * @param mixed $current Current value. * @return array */ private static function sanitize_pixel_events( mixed $value, mixed $current ): array { $events = self::merge_defaults( Platforms::default_events(), is_array( $current ) ? $current : array() ); if ( ! is_array( $value ) ) { return $events; } foreach ( Platforms::CHOICES as $event ) { $bool = array_key_exists( $event, $value ) ? self::to_bool( $value[ $event ] ) : null; if ( null !== $bool ) { $events[ $event ] = $bool; } } return $events; } /** * Sanitizes the per-platform ad pixel settings. Only the platforms and * fields the input names change; an unknown platform or field is dropped * and an invalid value keeps the stored one (an ID is checked against the * platform's pattern, see Platforms). * * @param mixed $value Proposed value. * @param mixed $current Current value. * @return array> */ private static function sanitize_pixels( mixed $value, mixed $current ): array { $pixels = self::merge_defaults( Platforms::defaults(), is_array( $current ) ? $current : array() ); if ( ! is_array( $value ) ) { return $pixels; } foreach ( Platforms::keys() as $key ) { if ( isset( $value[ $key ] ) && is_array( $value[ $key ] ) ) { $pixels[ $key ] = self::sanitize_pixel( $key, $value[ $key ], $pixels[ $key ] ); } } return $pixels; } /** * Sanitizes one platform's settings: enabled, id, conversions (only for a * platform that has them) and custom_events. * * @param string $key Platform key. * @param array $input Proposed fields. * @param array $current The platform's current settings. * @return array */ private static function sanitize_pixel( string $key, array $input, array $current ): array { $row = $current; if ( array_key_exists( 'enabled', $input ) ) { $row['enabled'] = self::to_bool( $input['enabled'] ) ?? (bool) $row['enabled']; } if ( array_key_exists( 'id', $input ) ) { $row['id'] = Platforms::normalize_id( $key, $input['id'] ) ?? $row['id']; } $conversion = Platforms::get( $key )['conversion'] ?? null; if ( null !== $conversion && isset( $input['conversions'] ) && is_array( $input['conversions'] ) ) { foreach ( $conversion['events'] as $event ) { if ( array_key_exists( $event, $input['conversions'] ) ) { $row['conversions'][ $event ] = Platforms::normalize_conversion( $key, $input['conversions'][ $event ] ) ?? $row['conversions'][ $event ]; } } } if ( array_key_exists( 'custom_events', $input ) ) { $row['custom_events'] = self::sanitize_custom_events( $input['custom_events'], $row['custom_events'] ); } return $row; } /** * Sanitizes a platform's own event choices: null (follow the shared * events), or an array with all four events as booleans. A partial or * mistyped array keeps the stored value, so a platform never ends up with * a half-defined list. * * @param mixed $value Proposed value. * @param mixed $current Current value. * @return array|null */ private static function sanitize_custom_events( mixed $value, mixed $current ): ?array { if ( null === $value ) { return null; } $current = is_array( $current ) ? $current : null; if ( ! is_array( $value ) ) { return $current; } $events = array(); foreach ( Platforms::CHOICES as $event ) { $bool = array_key_exists( $event, $value ) ? self::to_bool( $value[ $event ] ) : null; if ( null === $bool ) { return $current; } $events[ $event ] = $bool; } return $events; } /** * A boolean from a boolean, integer or string (the forms a REST body, a * form post or WP-CLI gives), or null for anything else. * * @param mixed $value The value. */ private static function to_bool( mixed $value ): ?bool { if ( is_bool( $value ) || is_int( $value ) || is_string( $value ) ) { return (bool) rest_sanitize_boolean( $value ); } return null; } /** * Sanitizes a GA4 measurement id: uppercased G-XXXX, or empty. * * @param mixed $value Proposed value. * @param mixed $current Current value. */ private static function sanitize_measurement_id( mixed $value, mixed $current ): mixed { if ( ! is_string( $value ) ) { return $current; } if ( '' === $value ) { return ''; } $upper = strtoupper( $value ); if ( 1 === preg_match( '/^G-[A-Z0-9]{4,16}\z/', $upper ) ) { return $upper; } return $current; } /** * Normalizes a Microsoft Clarity project id: trimmed and lower-cased, * it must match ^[a-z0-9]{6,16}$. Shared by the clarity_project_id * setting and the id Microsoft's own Clarity plugin stores. * * @param mixed $value Proposed value. * @return string|null The normalized id, '' for an empty value, or null when invalid. */ public static function normalize_clarity_project_id( mixed $value ): ?string { if ( ! is_string( $value ) ) { return null; } $id = strtolower( trim( $value ) ); if ( '' === $id || 1 === preg_match( '/^[a-z0-9]{6,16}\z/', $id ) ) { return $id; } return null; } /** * The project id Microsoft's own Clarity plugin stores (its * clarity_project_id option, separate from our settings), normalized * like ours, or '' when it is unset or invalid. */ public static function microsoft_clarity_project_id(): string { return self::normalize_clarity_project_id( get_option( 'clarity_project_id', '' ) ) ?? ''; } /** * Sanitizes a value against an enum of allowed strings. * * @param mixed $value Proposed value. * @param string[] $allowed Allowed values. * @param mixed $current Current value. */ private static function sanitize_enum( mixed $value, array $allowed, mixed $current ): mixed { if ( is_string( $value ) && in_array( $value, $allowed, true ) ) { return $value; } return $current; } /** * Sanitizes a value against a regular expression, allowing an empty string. * * @param mixed $value Proposed value. * @param string $pattern Regular expression pattern. * @param mixed $current Current value. */ private static function sanitize_pattern( mixed $value, string $pattern, mixed $current ): mixed { if ( ! is_string( $value ) ) { return $current; } if ( '' === $value || 1 === preg_match( $pattern, $value ) ) { return $value; } return $current; } /** * Sanitizes gsc_site: 'sc-domain:' + host, an http(s) URL, or empty. * * @param mixed $value Proposed value. * @param mixed $current Current value. */ private static function sanitize_gsc_site( mixed $value, mixed $current ): mixed { if ( ! is_string( $value ) ) { return $current; } if ( '' === $value ) { return ''; } if ( 0 === strpos( $value, 'sc-domain:' ) ) { $host = substr( $value, strlen( 'sc-domain:' ) ); if ( 1 === preg_match( '/^[a-z0-9.-]+\.[a-z]{2,}\z/i', $host ) ) { return 'sc-domain:' . strtolower( $host ); } return $current; } if ( 1 === preg_match( '#^https?://#i', $value ) ) { $escaped = esc_url_raw( $value ); return '' !== $escaped ? $escaped : $current; } return $current; } }