true, 'rowspan' => true, ); return array( 'p' => array(), 'ul' => array(), 'ol' => array(), 'li' => array(), 'strong' => array(), 'em' => array(), 'h3' => array(), 'h4' => array(), 'table' => array(), 'thead' => array(), 'tbody' => array(), 'tr' => array(), 'th' => $cell + array( 'scope' => true ), 'td' => $cell, 'br' => array(), ); } /** Sanitize description HTML: drop dangerous elements with their content, then wp_kses to the allowlist. */ public static function sanitize_description( string $html ): string { // Elements whose content must never survive as text (wp_kses would keep the inner text). $html = (string) preg_replace( '#<(script|style|iframe|noscript|template|object|embed|svg|math|textarea|select|head|title)\b[^>]*>.*?#is', '', $html ); $html = (string) preg_replace( '#<(script|style|iframe|noscript|template|object|embed|svg|math)\b[^>]*/?>#i', '', $html ); $html = wp_kses( $html, self::description_allowed_html(), array() ); $html = force_balance_tags( $html ); return trim( $html ); } /** JSON form of a FAQ list, used for the change log before/after and for comparisons. */ public static function faq_json( array $faq ): string { return (string) wp_json_encode( array_values( $faq ), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ); } /** * Validate a FAQ: [] (removes) or 1-8 items {q, a}, plain text, q <= 200, a <= 800 chars. * * @param mixed $value Input. * @return array|\WP_Error */ public static function sanitize_faq( $value ) { if ( ! is_array( $value ) || ( $value && array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) ) { return self::err( 'faq must be an array of {q, a} objects ([] removes the FAQ)' ); } if ( count( $value ) > self::FAQ_MAX ) { return self::err( 'faq has more than ' . self::FAQ_MAX . ' items' ); } $out = array(); foreach ( $value as $i => $item ) { if ( ! is_array( $item ) || array_diff( array_keys( $item ), array( 'q', 'a' ) ) || ! isset( $item['q'], $item['a'] ) || ! is_string( $item['q'] ) || ! is_string( $item['a'] ) ) { return self::err( 'faq[' . $i . '] must be an object {q: string, a: string}' ); } $q = trim( sanitize_text_field( $item['q'] ) ); $a = trim( sanitize_textarea_field( $item['a'] ) ); if ( '' === $q || '' === $a ) { return self::err( 'faq[' . $i . ']: q and a must not be empty' ); } if ( mb_strlen( $q ) > self::FAQ_Q_MAX ) { return self::err( 'faq[' . $i . '].q is longer than ' . self::FAQ_Q_MAX . ' characters' ); } if ( mb_strlen( $a ) > self::FAQ_A_MAX ) { return self::err( 'faq[' . $i . '].a is longer than ' . self::FAQ_A_MAX . ' characters' ); } $out[] = array( 'q' => $q, 'a' => $a, ); } return $out; } /** Fields that must never come from AI (data rule: never invent identifiers). */ public const NO_AI_FIELDS = array( 'gtin', 'mpn' ); private static function err( string $msg, int $status = 400, string $code = 'flareops_invalid' ): \WP_Error { return new \WP_Error( $code, $msg, array( 'status' => $status ) ); } /** Current display value of a field. */ public static function current_value( \WC_Product $p, string $field ): string { switch ( $field ) { case 'brand': return implode( ', ', Products::brand_names( $p->get_id() ) ); case 'gtin': return Products::gtin( $p ); case 'mpn': return Products::mpn( $p ); case 'title': return html_entity_decode( (string) $p->get_name( 'edit' ), ENT_QUOTES, 'UTF-8' ); case 'short_description': return (string) $p->get_short_description( 'edit' ); case 'description': return (string) $p->get_description( 'edit' ); case 'faq': return self::faq_json( Products::faq( $p ) ); } return ''; } /** Data needed to restore the field exactly. */ private static function restore_data( \WC_Product $p, string $field ): array { if ( 'brand' === $field ) { if ( Products::brand_uses_taxonomy() ) { $ids = wp_get_object_terms( $p->get_id(), Products::BRAND_TAX, array( 'fields' => 'ids' ) ); return array( 'mode' => 'tax', 'term_ids' => is_array( $ids ) ? array_map( 'intval', $ids ) : array(), ); } return array( 'mode' => 'meta', 'value' => (string) get_post_meta( $p->get_id(), Products::BRAND_META, true ), ); } return array( 'value' => self::current_value( $p, $field ) ); } /** * Validate + normalize a requested value. * * @return string|\WP_Error */ private static function normalize_value( \WC_Product $p, string $field, $value ) { if ( 'faq' === $field ) { $faq = self::sanitize_faq( $value ); return is_wp_error( $faq ) ? $faq : self::faq_json( $faq ); } if ( ! is_string( $value ) && ! is_numeric( $value ) ) { return self::err( $field . ' must be a string' ); } $value = (string) $value; switch ( $field ) { case 'brand': $v = trim( sanitize_text_field( $value ) ); if ( mb_strlen( $v ) > 70 ) { return self::err( 'brand is longer than 70 characters' ); } return $v; case 'gtin': $v = Util::normalize_gtin( $value ); if ( '' === $v ) { return ''; } if ( ! Util::is_valid_gtin( $v ) ) { return self::err( 'invalid GTIN: must be 8, 12, 13 or 14 digits with a valid GS1 check digit', 400, 'flareops_invalid_gtin' ); } if ( function_exists( 'wc_get_product_id_by_global_unique_id' ) ) { $other = (int) wc_get_product_id_by_global_unique_id( $v ); if ( $other && $other !== $p->get_id() ) { return self::err( 'GTIN already used by product ' . $other, 409, 'flareops_duplicate_gtin' ); } } return $v; case 'mpn': $v = trim( sanitize_text_field( $value ) ); if ( mb_strlen( $v ) > 70 ) { return self::err( 'mpn is longer than 70 characters' ); } return $v; case 'title': $v = trim( sanitize_text_field( $value ) ); if ( '' === $v ) { return self::err( 'title cannot be empty' ); } if ( mb_strlen( $v ) > 150 ) { return self::err( 'title is longer than 150 characters' ); } return $v; case 'short_description': return trim( wp_kses_post( $value ) ); case 'description': if ( mb_strlen( $value ) > self::DESCRIPTION_MAX ) { return self::err( 'description is longer than ' . self::DESCRIPTION_MAX . ' characters' ); } $v = self::sanitize_description( $value ); if ( '' === trim( wp_strip_all_tags( $v ) ) ) { return self::err( 'description is empty after sanitizing (allowed tags: ' . implode( ', ', array_keys( self::description_allowed_html() ) ) . ')' ); } if ( mb_strlen( $v ) > self::DESCRIPTION_MAX ) { return self::err( 'description is longer than ' . self::DESCRIPTION_MAX . ' characters' ); } return $v; } return self::err( 'unknown field ' . $field ); } /** Write one field on a product (does not save the product for non-taxonomy fields). */ private static function write_field( \WC_Product $p, string $field, string $value ): void { switch ( $field ) { case 'brand': $target = $p->get_id(); if ( Products::brand_uses_taxonomy() ) { $r = wp_set_object_terms( $target, '' === $value ? array() : array( $value ), Products::BRAND_TAX, false ); if ( is_wp_error( $r ) ) { throw new \RuntimeException( esc_html( $r->get_error_message() ) ); } } elseif ( '' === $value ) { delete_post_meta( $target, Products::BRAND_META ); } else { update_post_meta( $target, Products::BRAND_META, $value ); } break; case 'gtin': if ( ! method_exists( $p, 'set_global_unique_id' ) ) { throw new \RuntimeException( 'WooCommerce 9.2+ is required for GTIN' ); } $p->set_global_unique_id( $value ); break; case 'mpn': if ( '' === $value ) { $p->delete_meta_data( Products::MPN_META ); } else { $p->update_meta_data( Products::MPN_META, $value ); } break; case 'title': $p->set_name( $value ); break; case 'short_description': $p->set_short_description( $value ); break; case 'description': $p->set_description( $value ); break; case 'faq': $faq = json_decode( $value, true ); if ( ! is_array( $faq ) || ! $faq ) { $p->delete_meta_data( Products::FAQ_META ); } else { $p->update_meta_data( Products::FAQ_META, array_values( $faq ) ); } break; } } /** * WooCommerce saves post_content through wp_update_post(), which unslashes it (and runs kses for users without * unfiltered_html). The change log promises the exact stored HTML, so check after save() and correct if needed. */ private static function ensure_description( int $product_id, string $want ): void { global $wpdb; $stored = (string) $wpdb->get_var( $wpdb->prepare( "SELECT post_content FROM {$wpdb->posts} WHERE ID = %d", $product_id ) ); // phpcs:ignore WordPress.DB if ( $stored === $want ) { return; } $wpdb->update( $wpdb->posts, array( 'post_content' => $want ), array( 'ID' => $product_id ) ); // phpcs:ignore WordPress.DB clean_post_cache( $product_id ); if ( function_exists( 'wc_delete_product_transients' ) ) { wc_delete_product_transients( $product_id ); } } /** * Save a product without WordPress' kses filters. API requests run as no user (no unfiltered_html), so * wp_update_post() would otherwise run kses over EVERY post field WooCommerce re-saves, e.g. turn a title's * "&" into "&" and drop "", even when only the description changed. Values written here are either * sanitized by flareOps (normalize_value) or the product's existing / previously stored values. */ private static function save_product( \WC_Product $p ): void { $kses = false !== has_filter( 'content_save_pre', 'wp_filter_post_kses' ) || false !== has_filter( 'title_save_pre', 'wp_filter_kses' ); if ( $kses ) { kses_remove_filters(); } try { $p->save(); } finally { if ( $kses ) { kses_init_filters(); } } } /** After a content change: the feed is rebuilt, and IndexNow queues the URL (IndexNow checks consent itself). */ private static function after_content_change( int $product_id ): void { Feed::invalidate(); IndexNow::on_product_change( $product_id ); } /** * Apply approved fixes. * * @return array|\WP_Error */ public static function apply( int $product_id, array $body ) { $p = wc_get_product( $product_id ); if ( ! $p ) { return self::err( 'product not found', 404, 'flareops_not_found' ); } $fields = $body['fields'] ?? null; if ( ! is_array( $fields ) || ! $fields ) { return self::err( 'fields must be a non-empty object' ); } $source = $body['source'] ?? ''; if ( ! is_string( $source ) || ! in_array( $source, self::SOURCES, true ) ) { return self::err( 'source must be one of: ' . implode( ', ', self::SOURCES ) ); } $approved_by = $body['approved_by'] ?? ''; if ( ! is_string( $approved_by ) || '' === trim( $approved_by ) ) { return self::err( 'approved_by is required' ); } $approved_by = mb_substr( sanitize_text_field( $approved_by ), 0, 190 ); $dry_run = ! empty( $body['dry_run'] ) && 'false' !== $body['dry_run']; $planned = array(); foreach ( $fields as $field => $value ) { if ( ! in_array( $field, self::FIELDS, true ) ) { return self::err( 'unknown field: ' . sanitize_key( (string) $field ) ); } if ( 'ai_suggestion' === $source && in_array( $field, self::NO_AI_FIELDS, true ) ) { return self::err( $field . ' cannot come from an AI suggestion (identifiers are never invented)', 400, 'flareops_forbidden_source' ); } if ( in_array( $field, self::CONTENT_FIELDS, true ) ) { if ( ! in_array( $source, self::CONTENT_SOURCES, true ) ) { return self::err( $field . ' accepts source ' . implode( ' or ', self::CONTENT_SOURCES ) . ' only' ); } if ( $p->is_type( 'variation' ) ) { return self::err( $field . ' must be set on the parent product, not a variation' ); } if ( ! Settings::content_allowed( $product_id ) ) { return self::err( 'product ' . $product_id . ' is not a treatment product; description and faq are refused so the experiment is not contaminated (setting content_any_product lifts this)', 409, 'flareops_not_treatment' ); } } if ( $p->is_type( 'variation' ) && in_array( $field, array( 'brand', 'title', 'short_description' ), true ) ) { return self::err( $field . ' must be set on the parent product, not a variation' ); } $v = self::normalize_value( $p, $field, $value ); if ( is_wp_error( $v ) ) { return $v; } $before = self::current_value( $p, $field ); $planned[ $field ] = array( 'before' => $before, 'after' => $v, 'changed' => $before !== $v, 'restore' => self::restore_data( $p, $field ), ); } $results = array(); foreach ( $planned as $field => $pl ) { $results[] = array( 'field' => $field, 'before' => $pl['before'], 'after' => $pl['after'], 'changed' => $pl['changed'], ); } $to_change = array_filter( $planned, static fn( $pl ) => $pl['changed'] ); if ( $dry_run || ! $to_change ) { return array( 'id' => $product_id, 'dry_run' => $dry_run, 'results' => $results, 'change_id' => null, ); } try { $needs_save = false; foreach ( $to_change as $field => $pl ) { self::write_field( $p, $field, $pl['after'] ); if ( 'brand' !== $field ) { $needs_save = true; } } if ( $needs_save || isset( $to_change['brand'] ) ) { // Brand lives outside the product object; still save to bump modified + fire update hooks. self::save_product( $p ); } if ( isset( $to_change['description'] ) ) { self::ensure_description( $product_id, $to_change['description']['after'] ); } } catch ( \Throwable $e ) { return self::err( 'could not apply: ' . $e->getMessage(), 400, 'flareops_apply_failed' ); } if ( array_intersect( array_keys( $to_change ), self::CONTENT_FIELDS ) ) { self::after_content_change( $product_id ); } $change_id = wp_generate_uuid4(); self::log_rows( $change_id, $product_id, $to_change, $source, $approved_by ); // Re-read actual stored values for the response. $fresh = wc_get_product( $product_id ); foreach ( $results as &$r ) { if ( $r['changed'] && $fresh ) { $r['after'] = self::current_value( $fresh, $r['field'] ); } } unset( $r ); return array( 'id' => $product_id, 'dry_run' => false, 'results' => $results, 'change_id' => $change_id, ); } private static function log_rows( string $change_id, int $product_id, array $rows, string $source, string $approved_by ): void { global $wpdb; $now = gmdate( 'Y-m-d H:i:s' ); foreach ( $rows as $field => $pl ) { $wpdb->insert( // phpcs:ignore WordPress.DB Install::table( 'changes' ), array( 'change_id' => $change_id, 'product_id' => $product_id, 'field' => $field, 'before_value' => $pl['before'], 'after_value' => $pl['after'], 'restore_data' => wp_json_encode( $pl['restore'] ), 'at' => $now, 'source' => $source, 'approved_by' => $approved_by, ) ); } } /** * Undo a change. * * @return array|\WP_Error */ public static function undo( string $change_id, string $by = '' ) { global $wpdb; $t = Install::table( 'changes' ); $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE change_id = %s', $t, $change_id ), ARRAY_A ); // phpcs:ignore WordPress.DB if ( ! $rows ) { return self::err( 'change not found', 404, 'flareops_not_found' ); } $pending = array_filter( $rows, static fn( $r ) => empty( $r['undone_at'] ) ); if ( ! $pending ) { return self::err( 'change already undone', 409, 'flareops_already_undone' ); } $product_id = (int) $rows[0]['product_id']; $p = wc_get_product( $product_id ); if ( ! $p ) { return self::err( 'product no longer exists', 410, 'flareops_gone' ); } $restored = array(); $restore_dc = null; try { $needs_save = false; foreach ( $pending as $r ) { $field = (string) $r['field']; $data = json_decode( (string) $r['restore_data'], true ); $data = is_array( $data ) ? $data : array( 'value' => (string) $r['before_value'] ); if ( 'brand' === $field ) { if ( 'tax' === ( $data['mode'] ?? '' ) && Products::brand_uses_taxonomy() ) { $res = wp_set_object_terms( $product_id, array_map( 'intval', (array) ( $data['term_ids'] ?? array() ) ), Products::BRAND_TAX, false ); if ( is_wp_error( $res ) ) { throw new \RuntimeException( $res->get_error_message() ); } } else { self::write_field( $p, 'brand', (string) ( $data['value'] ?? '' ) ); } } else { self::write_field( $p, $field, (string) ( $data['value'] ?? '' ) ); } if ( 'description' === $field ) { $restore_dc = (string) ( $data['value'] ?? '' ); } $needs_save = true; } if ( $needs_save ) { // Undo restores the merchant's own earlier values exactly; kses must not alter them. self::save_product( $p ); } if ( null !== $restore_dc ) { self::ensure_description( $product_id, $restore_dc ); } } catch ( \Throwable $e ) { return self::err( 'undo failed: ' . $e->getMessage(), 409, 'flareops_undo_failed' ); } if ( array_intersect( array_map( static fn( $r ) => (string) $r['field'], $pending ), self::CONTENT_FIELDS ) ) { self::after_content_change( $product_id ); } $fresh = wc_get_product( $product_id ); foreach ( $pending as $r ) { $restored[ $r['field'] ] = $fresh ? self::current_value( $fresh, (string) $r['field'] ) : (string) $r['before_value']; } $wpdb->query( $wpdb->prepare( 'UPDATE %i SET undone_at = %s WHERE change_id = %s AND undone_at IS NULL', $t, gmdate( 'Y-m-d H:i:s' ), $change_id ) ); // phpcs:ignore WordPress.DB return array( 'ok' => true, 'change_id' => $change_id, 'product_id' => $product_id, 'restored' => (object) $restored, ); } public static function list_since( ?string $since, int $limit = 500 ): array { global $wpdb; $t = Install::table( 'changes' ); // phpcs:disable WordPress.DB if ( $since ) { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE at >= %s ORDER BY id DESC LIMIT %d', $t, $since, $limit ), ARRAY_A ); } else { $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i ORDER BY id DESC LIMIT %d', $t, $limit ), ARRAY_A ); } // phpcs:enable return array_map( static fn( $r ) => array( 'change_id' => $r['change_id'], 'product_id' => (int) $r['product_id'], 'field' => $r['field'], 'before' => (string) $r['before_value'], 'after' => (string) $r['after_value'], 'at' => Util::mysql_to_iso( $r['at'] ), 'source' => $r['source'], 'approved_by' => $r['approved_by'], 'undone_at' => Util::mysql_to_iso( $r['undone_at'] ?? null ), ), (array) $rows ); } }