array_fill_keys( self::FEATURES, true ), 'treatment_ids' => array(), 'shipping_text' => '', 'returns_text' => '', 'returns_days' => null, 'feed_scope' => 'treatment', // v0.2: the merchant's OWN Google / Bing verification codes (claim the site in their own Merchant Center). 'merchant_google_verification' => '', 'merchant_bing_verification' => '', // v0.2: explicit store-wide brand for own-brand stores. Used in feed + JSON-LD only when a product has no brand. 'default_brand' => '', // v0.2: optional feed g:shipping. null = use Merchant Center account-level shipping (the default path). 'feed_shipping' => null, // v0.2: optional product_cat term_id => Google product category (numeric ID or full path). Unmapped = omitted. 'google_category_map' => array(), // v0.3: allow description / FAQ fixes on products outside treatment_ids (off during the experiment). 'content_any_product' => false, ); } public static function all(): array { if ( null !== self::$cache ) { return self::$cache; } $stored = get_option( self::OPTION, array() ); $stored = is_array( $stored ) ? $stored : array(); $d = self::defaults(); $out = array_merge( $d, array_intersect_key( $stored, $d ) ); $features = is_array( $stored['features'] ?? null ) ? $stored['features'] : array(); foreach ( self::FEATURES as $f ) { $out['features'][ $f ] = array_key_exists( $f, $features ) ? (bool) $features[ $f ] : true; } $out['treatment_ids'] = array_values( array_unique( array_map( 'intval', (array) $out['treatment_ids'] ) ) ); $out['shipping_text'] = (string) $out['shipping_text']; $out['returns_text'] = (string) $out['returns_text']; $out['returns_days'] = ( null === $out['returns_days'] || '' === $out['returns_days'] ) ? null : (int) $out['returns_days']; $out['feed_scope'] = in_array( $out['feed_scope'], self::FEED_SCOPES, true ) ? $out['feed_scope'] : 'treatment'; foreach ( array( 'merchant_google_verification', 'merchant_bing_verification', 'default_brand' ) as $k ) { $out[ $k ] = is_string( $out[ $k ] ) ? $out[ $k ] : ''; } $ship = $out['feed_shipping']; $out['feed_shipping'] = ( is_array( $ship ) && ! empty( $ship['country'] ) && isset( $ship['price'] ) ) ? array( 'country' => (string) $ship['country'], 'price' => (string) $ship['price'], 'service' => (string) ( $ship['service'] ?? '' ), ) : null; $map = array(); foreach ( (array) $out['google_category_map'] as $tid => $cat ) { if ( (int) $tid > 0 && is_string( $cat ) && '' !== $cat ) { $map[ (string) (int) $tid ] = $cat; } } $out['google_category_map'] = $map; $out['content_any_product'] = (bool) $out['content_any_product']; self::$cache = $out; return $out; } public static function feature( string $name ): bool { $all = self::all(); return ! empty( $all['features'][ $name ] ); } /** v0.3: may description / FAQ content be written to this product? Treatment only, unless content_any_product. */ public static function content_allowed( int $product_id ): bool { return ! empty( self::all()['content_any_product'] ) || self::is_treatment( $product_id ); } public static function is_treatment( int $product_id ): bool { return $product_id > 0 && in_array( $product_id, self::all()['treatment_ids'], true ); } /** * Validate and merge a partial update. * * @param array $input Partial settings. * @return array|\WP_Error New settings. */ public static function update( array $input ) { $cur = self::all(); if ( array_key_exists( 'features', $input ) ) { if ( ! is_array( $input['features'] ) ) { return new \WP_Error( 'flareops_invalid', 'features must be an object', array( 'status' => 400 ) ); } foreach ( $input['features'] as $k => $v ) { if ( ! in_array( $k, self::FEATURES, true ) ) { return new \WP_Error( 'flareops_invalid', 'unknown feature: ' . sanitize_key( (string) $k ), array( 'status' => 400 ) ); } if ( ! is_bool( $v ) && ! in_array( $v, array( 0, 1, '0', '1', 'true', 'false' ), true ) ) { return new \WP_Error( 'flareops_invalid', 'feature values must be booleans', array( 'status' => 400 ) ); } $cur['features'][ $k ] = ( true === $v || 1 === $v || '1' === $v || 'true' === $v ); } } if ( array_key_exists( 'treatment_ids', $input ) ) { if ( ! is_array( $input['treatment_ids'] ) ) { return new \WP_Error( 'flareops_invalid', 'treatment_ids must be an array of product IDs', array( 'status' => 400 ) ); } $ids = array(); foreach ( $input['treatment_ids'] as $id ) { $id = (int) $id; if ( $id <= 0 || 'product' !== get_post_type( $id ) ) { return new \WP_Error( 'flareops_invalid', 'not a product ID: ' . $id, array( 'status' => 400 ) ); } $ids[] = $id; } $cur['treatment_ids'] = array_values( array_unique( $ids ) ); } foreach ( array( 'shipping_text', 'returns_text' ) as $k ) { if ( array_key_exists( $k, $input ) ) { if ( ! is_string( $input[ $k ] ) ) { return new \WP_Error( 'flareops_invalid', $k . ' must be a string', array( 'status' => 400 ) ); } $cur[ $k ] = mb_substr( sanitize_text_field( $input[ $k ] ), 0, 300 ); } } if ( array_key_exists( 'returns_days', $input ) ) { $v = $input['returns_days']; if ( null === $v || '' === $v ) { $cur['returns_days'] = null; } elseif ( is_numeric( $v ) && (int) $v >= 0 && (int) $v <= 365 ) { $cur['returns_days'] = (int) $v; } else { return new \WP_Error( 'flareops_invalid', 'returns_days must be an integer 0-365 or null', array( 'status' => 400 ) ); } } if ( array_key_exists( 'feed_scope', $input ) ) { if ( ! is_string( $input['feed_scope'] ) || ! in_array( $input['feed_scope'], self::FEED_SCOPES, true ) ) { return new \WP_Error( 'flareops_invalid', 'feed_scope must be "treatment" or "all"', array( 'status' => 400 ) ); } $cur['feed_scope'] = $input['feed_scope']; } foreach ( array( 'merchant_google_verification' => 'google-site-verification', 'merchant_bing_verification' => 'msvalidate.01' ) as $k => $meta_name ) { if ( array_key_exists( $k, $input ) ) { $v = self::parse_verification( $input[ $k ], $meta_name ); if ( is_wp_error( $v ) ) { return new \WP_Error( 'flareops_invalid', $k . ': ' . $v->get_error_message(), array( 'status' => 400 ) ); } $cur[ $k ] = $v; } } if ( array_key_exists( 'default_brand', $input ) ) { if ( ! is_string( $input['default_brand'] ) ) { return new \WP_Error( 'flareops_invalid', 'default_brand must be a string', array( 'status' => 400 ) ); } $b = trim( sanitize_text_field( $input['default_brand'] ) ); if ( mb_strlen( $b ) > 70 ) { return new \WP_Error( 'flareops_invalid', 'default_brand must be at most 70 characters (Google brand limit)', array( 'status' => 400 ) ); } $cur['default_brand'] = $b; } if ( array_key_exists( 'feed_shipping', $input ) ) { $v = self::parse_shipping( $input['feed_shipping'] ); if ( is_wp_error( $v ) ) { return $v; } $cur['feed_shipping'] = $v; } if ( array_key_exists( 'google_category_map', $input ) ) { $v = self::parse_category_map( $input['google_category_map'] ); if ( is_wp_error( $v ) ) { return $v; } $cur['google_category_map'] = $v; } if ( array_key_exists( 'content_any_product', $input ) ) { $v = $input['content_any_product']; if ( ! is_bool( $v ) && ! in_array( $v, array( 0, 1, '0', '1', 'true', 'false' ), true ) ) { return new \WP_Error( 'flareops_invalid', 'content_any_product must be a boolean', array( 'status' => 400 ) ); } $cur['content_any_product'] = ( true === $v || 1 === $v || '1' === $v || 'true' === $v ); } // v1: service-pushed connection values (verification codes, IndexNow key). Validated first, stored on success. $pushed = array(); foreach ( self::PUSHED_KEYS as $k ) { if ( array_key_exists( $k, $input ) ) { $v = self::parse_pushed( $k, $input[ $k ] ); if ( is_wp_error( $v ) ) { return new \WP_Error( 'flareops_invalid', $k . ': ' . $v->get_error_message(), array( 'status' => 400 ) ); } $pushed[ $k ] = $v; } } if ( $pushed ) { self::set_pushed( $pushed ); } update_option( self::OPTION, $cur, true ); self::$cache = null; do_action( 'flareops_settings_saved', self::all() ); return self::all(); } /** * Accept a verification meta tag or its content value. Returns the sanitized value ('' clears it). * * @param mixed $raw Pasted value. * @param string $meta_name Expected meta name (google-site-verification | msvalidate.01). * @return string|\WP_Error */ public static function parse_verification( $raw, string $meta_name ) { if ( null === $raw ) { return ''; } if ( ! is_string( $raw ) ) { return new \WP_Error( 'flareops_invalid', 'must be a string' ); } $raw = trim( wp_unslash( $raw ) ); if ( '' === $raw ) { return ''; } if ( false !== stripos( $raw, ']+)/i', $raw, $nm ) && strtolower( $nm[1] ) !== $meta_name ) { return new \WP_Error( 'flareops_invalid', 'this is a "' . sanitize_text_field( $nm[1] ) . '" tag, expected ' . $meta_name ); } if ( ! preg_match( '/\bcontent\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $raw, $m ) ) { return new \WP_Error( 'flareops_invalid', 'no content="..." value in the pasted tag' ); } $raw = (string) ( '' !== ( $m[1] ?? '' ) ? $m[1] : ( '' !== ( $m[2] ?? '' ) ? $m[2] : ( $m[3] ?? '' ) ) ); } $v = (string) preg_replace( '/[^A-Za-z0-9_\-]/', '', $raw ); if ( '' === $v || strlen( $v ) > 128 ) { return new \WP_Error( 'flareops_invalid', 'expected the verification code (letters, digits, _ and -; at most 128)' ); } return $v; } /** * Optional feed shipping: {country: "US", price: "4.95", service?: "Standard"} or null/empty to clear. * * @param mixed $v Input. * @return array|null|\WP_Error */ public static function parse_shipping( $v ) { if ( null === $v || '' === $v || ( is_array( $v ) && ( ! $v || '' === trim( (string) ( $v['country'] ?? '' ) ) ) ) ) { return null; } if ( ! is_array( $v ) ) { return new \WP_Error( 'flareops_invalid', 'feed_shipping must be an object {country, price, service?} or null', array( 'status' => 400 ) ); } $unknown = array_diff( array_keys( $v ), array( 'country', 'price', 'service' ) ); if ( $unknown ) { return new \WP_Error( 'flareops_invalid', 'feed_shipping: unknown keys', array( 'status' => 400 ) ); } $country = strtoupper( trim( (string) $v['country'] ) ); if ( ! preg_match( '/^[A-Z]{2}$/', $country ) ) { return new \WP_Error( 'flareops_invalid', 'feed_shipping.country must be an ISO 3166-1 alpha-2 code', array( 'status' => 400 ) ); } $price = $v['price'] ?? null; if ( ! is_numeric( $price ) || (float) $price < 0 || (float) $price > 100000 ) { return new \WP_Error( 'flareops_invalid', 'feed_shipping.price must be a number >= 0', array( 'status' => 400 ) ); } $service = isset( $v['service'] ) ? mb_substr( sanitize_text_field( (string) $v['service'] ), 0, 100 ) : ''; return array( 'country' => $country, 'price' => number_format( (float) $price, 2, '.', '' ), 'service' => $service, ); } /** * Category map: {product_cat term_id: Google product category ID or full path}. Empty values are dropped. * * @param mixed $v Input. * @return array|\WP_Error */ public static function parse_category_map( $v ) { if ( null === $v ) { return array(); } if ( ! is_array( $v ) ) { return new \WP_Error( 'flareops_invalid', 'google_category_map must be an object {term_id: category}', array( 'status' => 400 ) ); } $out = array(); foreach ( $v as $tid => $cat ) { $tid = (int) $tid; if ( $tid <= 0 || ! term_exists( $tid, 'product_cat' ) ) { return new \WP_Error( 'flareops_invalid', 'google_category_map: not a product category: ' . $tid, array( 'status' => 400 ) ); } if ( ! is_string( $cat ) && ! is_int( $cat ) ) { return new \WP_Error( 'flareops_invalid', 'google_category_map values must be strings', array( 'status' => 400 ) ); } $cat = trim( sanitize_text_field( (string) $cat ) ); if ( '' === $cat ) { continue; } if ( mb_strlen( $cat ) > 750 ) { return new \WP_Error( 'flareops_invalid', 'google_category_map: value too long', array( 'status' => 400 ) ); } $out[ (string) $tid ] = $cat; } return $out; } /* ---------- consent ---------- */ public static function has_consent(): bool { $c = get_option( self::CONSENT_OPTION, array() ); return is_array( $c ) && ! empty( $c['granted'] ); } /** * May anything leave the site? Consent is always required. Generic v1 builds also require a Connect * (contract: before Connect the plugin sends nothing outbound); PoC builds keep the v0.x consent-only rule. */ public static function outbound_allowed(): bool { return self::has_consent() && ( self::is_poc_build() || Connect::is_connected() ); } public static function set_consent( bool $granted, string $by = '' ): void { update_option( self::CONSENT_OPTION, array( 'granted' => $granted, 'at' => Util::iso(), 'by' => $by, ), true ); } public static function consent_info(): array { $c = get_option( self::CONSENT_OPTION, array() ); return is_array( $c ) ? $c : array(); } /* ---------- connection values: pushed by the service > build-time constant (PoC builds) > legacy option ---------- */ /** v1: values the flareOps service pushed (Connect token response or POST /settings). Data only, never code. */ public const PUSHED_OPTION = 'flareops_pushed'; /** Keys the service may push, with their validators. */ public const PUSHED_KEYS = array( 'google_verification', 'bing_verification', 'indexnow_key' ); public static function pushed(): array { $p = get_option( self::PUSHED_OPTION, array() ); $p = is_array( $p ) ? $p : array(); $o = array(); foreach ( self::PUSHED_KEYS as $k ) { $o[ $k ] = isset( $p[ $k ] ) && is_string( $p[ $k ] ) ? $p[ $k ] : ''; } return $o; } /** * Validate one pushed value. '' or null clears it. * * @param string $key Pushed key. * @param mixed $raw Value. * @return string|\WP_Error */ public static function parse_pushed( string $key, $raw ) { if ( null === $raw || '' === $raw ) { return ''; } if ( 'google_verification' === $key ) { return self::parse_verification( $raw, 'google-site-verification' ); } if ( 'bing_verification' === $key ) { return self::parse_verification( $raw, 'msvalidate.01' ); } if ( 'indexnow_key' === $key ) { if ( ! is_string( $raw ) || ! preg_match( '/^[A-Za-z0-9\-]{8,128}$/', $raw ) ) { return new \WP_Error( 'flareops_invalid', 'expected 8-128 characters a-z, A-Z, 0-9 or -' ); } return $raw; } return new \WP_Error( 'flareops_invalid', 'unknown key' ); } /** * Store pushed values (already validated). Keys not given are kept. * * @param array $values key => sanitized string. */ public static function set_pushed( array $values ): void { $cur = self::pushed(); foreach ( self::PUSHED_KEYS as $k ) { if ( array_key_exists( $k, $values ) && is_string( $values[ $k ] ) ) { $cur[ $k ] = $values[ $k ]; } } update_option( self::PUSHED_OPTION, array_filter( $cur, 'strlen' ), true ); } public static function clear_pushed(): void { delete_option( self::PUSHED_OPTION ); } /** True when this is a PoC build that ships flareops-config.php (baked token and codes). */ public static function is_poc_build(): bool { return defined( 'FLAREOPS_POC_CONFIG' ) && FLAREOPS_POC_CONFIG; } private static function config( string $const, string $option, string $pushed_key = '' ): string { if ( '' !== $pushed_key ) { $p = self::pushed()[ $pushed_key ] ?? ''; if ( '' !== $p ) { return $p; } } if ( defined( $const ) && is_string( constant( $const ) ) && '' !== constant( $const ) ) { return (string) constant( $const ); } $v = get_option( $option, '' ); return is_string( $v ) ? trim( $v ) : ''; } public static function config_source( string $const ): string { return ( defined( $const ) && '' !== (string) constant( $const ) ) ? 'build' : 'option'; } /** PoC bearer token (build constant or legacy option). The v1 site secret is checked separately (Connect). */ public static function api_token(): string { return self::config( 'FLAREOPS_API_TOKEN', 'flareops_api_token' ); } public static function google_verification(): string { return self::config( 'FLAREOPS_GOOGLE_VERIFICATION', 'flareops_google_verification', 'google_verification' ); } public static function bing_verification(): string { return self::config( 'FLAREOPS_BING_VERIFICATION', 'flareops_bing_verification', 'bing_verification' ); } public static function indexnow_key(): string { $k = self::config( 'FLAREOPS_INDEXNOW_KEY', 'flareops_indexnow_key', 'indexnow_key' ); // IndexNow keys: 8-128 chars, a-z A-Z 0-9 and dashes. return preg_match( '/^[A-Za-z0-9\-]{8,128}$/', $k ) ? $k : ''; } public static function reset_cache(): void { self::$cache = null; } }