'POST', 'callback' => array( $this, 'handle_refresh' ), 'permission_callback' => array( $this, 'check_site_token' ), ) ); } public function check_site_token( WP_REST_Request $req ) { $expected = (string) get_option( self::SITE_TOKEN_OPTION, '' ); if ( '' === $expected ) { return false; } $provided = (string) $req->get_header( self::SITE_TOKEN_HEADER ); if ( '' === $provided ) { return false; } return hash_equals( $expected, $provided ); } public function handle_refresh( WP_REST_Request $req ) { $body = $req->get_json_params(); $actions = is_array( $body ) && isset( $body['actions'] ) && is_array( $body['actions'] ) ? $body['actions'] : array(); $params = is_array( $body ) && isset( $body['params'] ) && is_array( $body['params'] ) ? $body['params'] : array(); $executed = array(); $skipped = array(); $errors = array(); foreach ( $actions as $action ) { if ( ! is_string( $action ) || '' === $action ) { continue; } try { switch ( $action ) { case 'emitter_probe_refresh': if ( class_exists( 'Xpay_Emitter_Probe' ) ) { Xpay_Emitter_Probe::cron_refresh_all(); $executed[] = $action; } else { $skipped[] = $action; } break; case 'flush_rewrites': flush_rewrite_rules( false ); $executed[] = $action; break; case 'reconcile_shop_assist_page': // PUBLISH (never unpublish) the full-page shopper, to match the // dashboard's shopAssistEnabled/Slug. // // The page is otherwise only reconciled on wp-admin loads and an // HOURLY cron, and reconcile() refuses to publish while the store // isn't entitled. So a store that enables the full page BEFORE // entitlement resolves gets a 404 on its own link, and no way for // us to retry. This makes the fix pushable. // // ⛔ `$allow_unpublish = false` is load-bearing, not defensive. // We bust the transients first (or reconcile reads the stale, // pre-flip config and no-ops) — but that FORCES a cold re-fetch of // both the entitlement and the config. If either call times out, // a reconcile permitted to retract would read the failure as // "not entitled" and DRAFT the merchant's live page — and we would // then purge the cache so the resulting 404 propagated instantly. // This action exists to fix a 404; it must not be able to cause one. if ( class_exists( 'Xpay_Shop_Assist_Page' ) ) { delete_transient( 'xpay_wc_storefront_entitlement' ); delete_transient( 'xpay_wc_widget_config' ); $changed = Xpay_Shop_Assist_Page::instance()->reconcile( false ); // Only if the page actually changed: the cached 404 for a newly // published URL has to go. A no-op reconcile must not flush a // merchant's entire page cache. if ( $changed ) { self::purge_site_page_cache(); } $executed[] = $action; } else { $skipped[] = $action; } break; case 'clear_discovery_cache': if ( class_exists( 'Xpay_Emitter_Probe' ) ) { foreach ( Xpay_Emitter_Probe::known_paths() as $path ) { delete_transient( Xpay_Emitter_Probe::TRANSIENT_PREFIX . md5( $path ) ); } $executed[] = $action; } else { $skipped[] = $action; } break; case 'clear_storefront_widget_cache': // Flush the two transients that gate the storefront widget so // a backend-side flip (merchant consent toggle, entitlement // change, appearance edit) propagates within seconds instead // of waiting on the 6h/1h TTLs. The next storefront pageview // re-fetches both from the backend with the latest values. delete_transient( 'xpay_wc_storefront_entitlement' ); delete_transient( 'xpay_wc_widget_config' ); // ⛔ Re-confirm SYNCHRONOUSLY before purging. // Dropping the transients alone no longer forces a // re-fetch: the render path falls back to the last // confirmed answer. So a consent flip would purge the // page cache, the purge would trigger a preload, and the // whole site would be re-cached from the STALE copy — // leaving the assistant rendering on a store whose owner // had just switched it off, until each page's TTL expired. // Blocking is correct here: this is an operator request, // not a shopper's pageview. if ( class_exists( 'Xpay_Storefront_Widget' ) ) { Xpay_Storefront_Widget::refresh_caches(); } // …and drop the full-page cache. Clearing the transients alone // only fixes pages PHP actually renders — on WP Rocket / // LiteSpeed the cached HTML is served before PHP runs, so the // loader would stay missing until each page expired on its own. // The loader sits on every public page, so the purge is site-wide. self::purge_site_page_cache(); $executed[] = $action; break; case 'diagnose_storefront_widget': // Why the loader is (or isn't) on the page, answered from // INSIDE the store. // // Everything about this decision is invisible from // outside: fetching the merchant's HTML tells you only // that the tag is missing, never which of the four gates // dropped it, and sampling a URL repeatedly measures // their page cache rather than our logic. Latency is the // same story — measured from anywhere else it is mostly // the measurer's own network. // // So this reports the gates and times both lookups from // the merchant's own server, which is the only vantage // point where those numbers mean anything. $sf_slug = class_exists( 'Xpay_Plugin' ) ? Xpay_Plugin::merchant_slug() : ''; // Kept BELOW the operator's 10s abort and the Lambda's 15s // so two probes can never time the whole request out — the // one case you'd actually be running this in. $sf_probe_timeout = 3; $sf_time = function ( $url ) use ( $sf_probe_timeout ) { $t0 = microtime( true ); $res = wp_remote_get( $url, array( 'timeout' => $sf_probe_timeout ) ); $ms = (int) round( ( microtime( true ) - $t0 ) * 1000 ); return array( 'ms' => $ms, 'code' => is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ), 'error' => is_wp_error( $res ) ? $res->get_error_message() : '', ); }; $sf_ent_url = trailingslashit( XPAY_WC_AGENT_COMMERCE_BASE ) . 'widget/entitlement?slug=' . rawurlencode( $sf_slug ); $sf_cfg_base = defined( 'XPAY_WC_MERCHANT_API' ) ? XPAY_WC_MERCHANT_API : 'https://nehl6uu58j.execute-api.us-east-1.amazonaws.com'; // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- xpay's merchant API, called server-side with wp_remote_get for JSON config; no asset is loaded from it (readme External services, item 12). $sf_cfg_url = trailingslashit( $sf_cfg_base ) . 'merchant/widget-config/public/' . rawurlencode( $sf_slug ); // ⛔ Snapshot the cache BEFORE evaluating the gates. // `gates.entitled` calls is_storefront_widget_entitled(), // which runs the render path — it can schedule a refresh // and repopulate transients. PHP evaluates array literals // in order, so reading the cache inside the same literal // after the gates would report state this very request had // just created, and the diagnostic would describe itself. $sf_cached_cfg = get_transient( 'xpay_wc_widget_config' ); $sf_cached_ent = get_transient( 'xpay_wc_storefront_entitlement' ); $sf_lkg_cfg = get_option( 'xpay_wc_widget_config_lkg', array() ); $sf_lkg_ent = class_exists( 'Xpay_Plugin' ) ? get_option( Xpay_Plugin::LKG_ENTITLEMENT_OPTION, '' ) : ''; $sf_cache = array( 'entitlement_transient' => $sf_cached_ent, 'entitlement_lkg' => is_array( $sf_lkg_ent ) && isset( $sf_lkg_ent['state'] ) ? $sf_lkg_ent['state'] : $sf_lkg_ent, 'entitlement_lkg_age_s' => is_array( $sf_lkg_ent ) && ! empty( $sf_lkg_ent['at'] ) ? time() - (int) $sf_lkg_ent['at'] : null, 'config_transient_set' => is_array( $sf_cached_cfg ), 'config_transient_is_empty_breaker' => is_array( $sf_cached_cfg ) && empty( $sf_cached_cfg ), 'config_lkg_present' => is_array( $sf_lkg_cfg ) && ! empty( $sf_lkg_cfg['cfg'] ), 'config_lkg_widget_enabled' => is_array( $sf_lkg_cfg ) && ! empty( $sf_lkg_cfg['cfg']['widgetEnabled'] ), 'config_lkg_age_s' => is_array( $sf_lkg_cfg ) && ! empty( $sf_lkg_cfg['at'] ) ? time() - (int) $sf_lkg_cfg['at'] : null, 'refresh_fail_count' => (int) get_option( 'xpay_wc_storefront_refresh_fails', 0 ), ); $sf_report = array( 'merchant_slug' => $sf_slug, // The four gates in enqueue(), in the order they run. 'gates' => array( 'is_connected' => class_exists( 'Xpay_Plugin' ) && Xpay_Plugin::is_connected(), 'entitled' => class_exists( 'Xpay_Plugin' ) && Xpay_Plugin::is_storefront_widget_entitled(), 'local_toggle' => (bool) get_option( 'xpay_wc_storefront_widget_enabled', 0 ), 'has_slug' => '' !== $sf_slug, ), // Snapshot taken above, before the gates ran. 'cache' => $sf_cache, // Live timings FROM THIS SERVER. Compare against the // 4s default in xpay_wc_storefront_fetch_timeout. 'latency' => array( 'entitlement' => $sf_time( $sf_ent_url ), 'widget_config' => $sf_time( $sf_cfg_url ), // ⛔ TWO different budgets, and conflating them misreads the // result. `probe_timeout_ms` is what the two measurements // above were capped at; `render_timeout_ms` is what the // storefront actually uses. A 3003ms reading against a // 4000ms render timeout looks like it fit — it did not, it // hit the probe's own 3000ms ceiling. 'probe_timeout_ms' => $sf_probe_timeout * 1000, 'render_timeout_ms' => (int) apply_filters( 'xpay_wc_storefront_fetch_timeout', 4 ) * 1000, ), 'next_refresh' => class_exists( 'Xpay_Storefront_Widget' ) ? wp_next_scheduled( Xpay_Storefront_Widget::REFRESH_HOOK ) : null, ); $report_holder = isset( $report_holder ) ? $report_holder : array(); $report_holder['diagnose_storefront_widget'] = $sf_report; $executed[] = $action; break; case 'diagnose_order_events': // Self-test the order-attribution pipeline. Reports plugin- // side state (connection, option, hook registrations, latest // order meta) and optionally replays the last completed // order's on_event() so we can observe the outbound POST // result without waiting on a new shopper. // // Params: // replay bool — also fire on_event for the latest // completed order (clears the // _xpay_order_event_sent_at meta first // so the dedupe doesn't short-circuit). $report = array( 'is_connected' => class_exists( 'Xpay_Plugin' ) && Xpay_Plugin::is_connected(), 'merchant_slug' => function_exists( 'get_option' ) ? get_option( 'xpay_wc_merchant_slug', '' ) : '', 'api_key_length' => strlen( (string) get_option( 'xpay_wc_api_key', '' ) ), 'order_events_enabled_option' => (bool) get_option( 'xpay_wc_order_events_enabled', 1 ), 'hooks' => array( 'woocommerce_payment_complete' => has_action( 'woocommerce_payment_complete' ), 'woocommerce_order_status_completed' => has_action( 'woocommerce_order_status_completed' ), 'woocommerce_order_status_changed' => has_action( 'woocommerce_order_status_changed' ), ), ); $latest = wc_get_orders( array( 'limit' => 1, 'orderby' => 'date', 'order' => 'DESC', 'status' => array( 'completed', 'processing' ), 'return' => 'objects', ) ); if ( ! empty( $latest ) && $latest[0] instanceof WC_Order ) { $latest_order = $latest[0]; $report['latest_order'] = array( 'id' => $latest_order->get_id(), 'status' => $latest_order->get_status(), 'total' => (string) $latest_order->get_total(), 'currency' => $latest_order->get_currency(), 'placed_at' => $latest_order->get_date_created() ? $latest_order->get_date_created()->date( 'c' ) : null, 'meta_sent_at' => $latest_order->get_meta( '_xpay_order_event_sent_at', true ) ?: null, 'meta_source' => $latest_order->get_meta( '_xpay_source', true ) ?: null, 'meta_inbound' => $latest_order->get_meta( '_xpay_ref_inbound', true ) ?: null, ); if ( ! empty( $params['replay'] ) && class_exists( 'Xpay_Order_Events' ) ) { // Clear the dedupe meta so on_event posts even though // we may have already attempted on a prior trigger. $latest_order->delete_meta_data( '_xpay_order_event_sent_at' ); $latest_order->save(); $t0 = microtime( true ); Xpay_Order_Events::instance()->on_event( $latest_order->get_id() ); $ms = (int) round( ( microtime( true ) - $t0 ) * 1000 ); // Reload to see if the sent-at meta got stamped (i.e. POST succeeded). $reloaded = wc_get_order( $latest_order->get_id() ); $report['replay'] = array( 'duration_ms' => $ms, 'sent_at_after_replay' => $reloaded ? ( $reloaded->get_meta( '_xpay_order_event_sent_at', true ) ?: null ) : null, 'success' => $reloaded && $reloaded->get_meta( '_xpay_order_event_sent_at', true ) ? true : false, ); } } $executed[] = $action; $report_holder = isset( $report_holder ) ? $report_holder : array(); $report_holder['diagnose_order_events'] = $report; break; case 'refresh_chatgpt_pixel': // Our backend linked (or unlinked) a ChatGPT Ads pixel for this // store. Re-read the relay config now; refresh_config() purges // the page cache itself when the pixel changed. if ( class_exists( 'Xpay_ChatGPT_Pixel' ) ) { $px = Xpay_ChatGPT_Pixel::refresh_config(); $report_holder = isset( $report_holder ) ? $report_holder : array(); $report_holder['refresh_chatgpt_pixel'] = array( 'pixel_linked' => ! empty( $px['pixel_id'] ), 'forwarding' => isset( $px['forwarding'] ) ? $px['forwarding'] : 'local', 'active' => Xpay_ChatGPT_Pixel::is_active(), ); $executed[] = $action; } else { $skipped[] = $action; } break; case 'refresh_proxy_policy': // Re-read the proxy policy (blog + any additional sections) // now instead of at the next hourly cron, and warm the // additional sections' sitemap allow-lists. if ( class_exists( 'Xpay_Blog_Proxy' ) && class_exists( 'Xpay_Modules' ) && Xpay_Modules::is_booted( 'blog-proxy' ) ) { $fetched = Xpay_Blog_Proxy::refresh_policy(); $report_holder = isset( $report_holder ) ? $report_holder : array(); $report_holder['refresh_proxy_policy'] = array_merge( array( 'fetched' => (bool) $fetched ), Xpay_Blog_Proxy::status_report() ); $executed[] = $action; } else { $skipped[] = $action; } break; case 'clear_owned_path_cache': // Forget the cached sitemap allow-list of every proxied // section so a newly published page is served on the next // request instead of after the cache expires. Local only. if ( class_exists( 'Xpay_Blog_Proxy' ) ) { $report_holder = isset( $report_holder ) ? $report_holder : array(); $report_holder['clear_owned_path_cache'] = array( 'sections_cleared' => Xpay_Blog_Proxy::clear_owned_path_cache() ); $executed[] = $action; } else { $skipped[] = $action; } break; case 'clear_ucp_profile_cache': // xpay_wc_ucp_profile holds the backend-pushed UCP body. // Deleting it forces Xpay_REST::serve_ucp_profile() to fall // through to the in-plugin template until the next finalize // or backend-pushed update lands. delete_option( 'xpay_wc_ucp_profile' ); delete_option( 'xpay_wc_ucp_signing_keys' ); $executed[] = $action; break; case 'set_llms_txt_extra_sections': // Backend pushes per-merchant extra Markdown sections to // append at the END of /llms.txt (after our existing // Store / Commerce protocols / Cart handoff / Top categories // / For AI shopping agents blocks). Sanitized + capped so a // hostile or misconfigured backend can't blow up the file. $raw = isset( $params['llms_txt_extra_sections'] ) && is_array( $params['llms_txt_extra_sections'] ) ? $params['llms_txt_extra_sections'] : array(); $clean = self::sanitize_llms_txt_extra_sections( $raw ); if ( empty( $clean ) ) { delete_option( 'xpay_wc_llms_txt_extra_sections' ); } else { $encoded = wp_json_encode( $clean ); if ( false !== $encoded ) { update_option( 'xpay_wc_llms_txt_extra_sections', $encoded ); } } $executed[] = $action; break; case 'clear_llms_txt_extra_sections': delete_option( 'xpay_wc_llms_txt_extra_sections' ); $executed[] = $action; break; case 'set_product_faqs': // Backend pushes the APPROVED per-product FAQ + return policy as // a JSON map keyed by SKU. The schema emitter renders FAQPage // JSON-LD for these products, and — only when faq_visible is on — // a shopper-facing FAQ block too. // // The three display params are optional and each is only written // when present, so a push that carries just `product_faqs` (every // caller before 0.6.0) leaves the display config untouched. $prior = self::product_faq_skus(); $raw = isset( $params['product_faqs'] ) && is_array( $params['product_faqs'] ) ? $params['product_faqs'] : array(); $clean = self::sanitize_product_faqs( $raw ); // Shrink guard — the safety net on the destructive path. A partial or // stale read (a transient upstream error, a catalog still rebuilding, // a truncated payload) arrives as far fewer SKUs than the store holds, // and a full-replace deletes every omitted one — an empty map wipes // them all. Refuse a >50% shrink unless the caller explicitly confirms // a full replace, turning a silent mass-delete into a loud, recoverable // failure. A deliberate reconcile passes confirm_replace:true; an // intentional wipe still has its own explicit clear_product_faqs action. $prior_n = count( $prior ); $incoming_n = count( $clean ); $confirm = isset( $params['confirm_replace'] ) && self::truthy( $params['confirm_replace'] ); if ( $prior_n > 0 && ! $confirm && $incoming_n < $prior_n * self::FAQ_SHRINK_GUARD_RATIO ) { throw new \RuntimeException( sprintf( 'xpay_faq_shrink_guard: refusing to replace %d FAQ product(s) with %d (>%d%% shrink) without confirm_replace. Use merge_product_faqs for a partial update, or resend with confirm_replace:true for a deliberate full reconcile.', $prior_n, $incoming_n, (int) round( ( 1 - self::FAQ_SHRINK_GUARD_RATIO ) * 100 ) ) ); } self::write_product_faq_map( $clean ); self::apply_faq_display_params( $params ); // Every product whose FAQ changed, plus every product that HAD one // and no longer does (its block must come down). self::purge_product_caches( array_unique( array_merge( $prior, array_keys( $clean ) ) ) ); $executed[] = $action; break; case 'clear_product_faqs': $prior = self::product_faq_skus(); delete_option( 'xpay_wc_product_faqs' ); self::purge_product_caches( $prior ); $executed[] = $action; break; case 'merge_product_faqs': // Additive upsert — the partial-push primitive. Named SKUs are // merged into the stored map; OMITTED SKUs are left untouched, so a // single-product change (a heading, one edited answer, a newly // approved product) can never delete anything. This is the fix for // the full-replace omission landmine — prefer it over set_product_faqs // for anything short of a deliberate whole-catalog reconcile. $raw = isset( $params['product_faqs'] ) && is_array( $params['product_faqs'] ) ? $params['product_faqs'] : array(); $incoming = self::sanitize_product_faqs( $raw ); if ( empty( $incoming ) ) { // Nothing well-formed to merge — a no-op, never a wipe. $skipped[] = $action; break; } // array_replace, NOT array_merge: numeric SKUs (e.g. "149103") become // PHP integer array keys, and array_merge RENUMBERS integer keys // (0,1,2,…), destroying every SKU→FAQ association. array_replace keys // by value and still lets $incoming win on a conflict. $merged = array_replace( self::product_faq_map(), $incoming ); self::write_product_faq_map( $merged ); // Display params (heading/visibility) ride merge too, so a single-SKU // change via merge never drops a localized heading (regression guard). self::apply_faq_display_params( $params ); // Purge only the products we touched, not the whole catalog. self::purge_product_caches( array_keys( $incoming ) ); $executed[] = $action; break; case 'remove_product_faqs': // Subtractive — delete ONLY the named SKUs. A removal now happens // because the caller saw the SKU is gone, not as a side effect of // omitting it from a full-replace payload. $skus = isset( $params['skus'] ) && is_array( $params['skus'] ) ? $params['skus'] : array(); $map = self::product_faq_map(); $removed = array(); foreach ( $skus as $sku ) { $key = sanitize_text_field( (string) $sku ); if ( '' !== $key && array_key_exists( $key, $map ) ) { unset( $map[ $key ] ); $removed[] = $key; } } if ( $removed ) { self::write_product_faq_map( $map ); self::purge_product_caches( $removed ); // their blocks must come down } $executed[] = $action; break; case 'get_product_faqs': // Read-back so the backend can diff against ground truth and send the // MINIMUM ops (merge for upserts, remove for real deletions) instead // of a full-replace it has to hope matches the store. SKU => content // hash is enough for a diff; full text isn't needed. $fingerprint = array(); foreach ( self::product_faq_map() as $sku => $entry ) { $json = wp_json_encode( $entry ); $fingerprint[ (string) $sku ] = md5( false !== $json ? $json : '' ); } $report_holder = isset( $report_holder ) ? $report_holder : array(); $report_holder['get_product_faqs'] = array( 'count' => count( $fingerprint ), 'faqs' => $fingerprint, ); $executed[] = $action; break; default: $skipped[] = $action; } } catch ( \Throwable $e ) { $errors[] = array( 'action' => $action, 'message' => $e->getMessage(), ); } } $payload = array( 'ok' => true, 'plugin_version' => XPAY_WC_VERSION, 'executed' => $executed, 'skipped' => $skipped, 'errors' => $errors, ); if ( isset( $report_holder ) && is_array( $report_holder ) ) { $payload['reports'] = $report_holder; } return rest_ensure_response( $payload ); } const LLMS_TXT_MAX_SECTIONS = 20; const LLMS_TXT_MAX_HEADING_LEN = 200; const LLMS_TXT_MAX_BODY_LEN = 4096; const FAQ_MAX_HEADING_LEN = 120; // set_product_faqs refuses a full-replace that shrinks the stored set below this // fraction of its current size without an explicit confirm_replace. const FAQ_SHRINK_GUARD_RATIO = 0.5; /** Accepts JSON true/false as well as the 1/0/"true" a hand-rolled caller might send. */ private static function truthy( $v ) { if ( is_bool( $v ) ) { return $v; } if ( is_numeric( $v ) ) { return (int) $v > 0; } return in_array( strtolower( trim( (string) $v ) ), array( 'true', 'yes', 'on', '1' ), true ); } /** The full decoded per-product FAQ map (SKU => { faq, return_policy }). */ private static function product_faq_map() { $raw = (string) get_option( 'xpay_wc_product_faqs', '' ); $decoded = $raw ? json_decode( $raw, true ) : array(); return is_array( $decoded ) ? $decoded : array(); } /** SKUs (or numeric ids) currently carrying a pushed FAQ entry. */ private static function product_faq_skus() { return array_keys( self::product_faq_map() ); } /** Single writer for the FAQ option: an empty map deletes it, else store JSON. */ private static function write_product_faq_map( $map ) { if ( empty( $map ) ) { delete_option( 'xpay_wc_product_faqs' ); return; } $encoded = wp_json_encode( $map ); if ( false !== $encoded ) { update_option( 'xpay_wc_product_faqs', $encoded ); } } /** * Write the shopper-facing display config that rides a FAQ push. Server-owned; * each key is written ONLY when present, so a push that omits one leaves the * store's current value alone. Shared by set_product_faqs and merge_product_faqs * so a localized heading is never lost when a single-SKU change goes via merge. */ private static function apply_faq_display_params( $params ) { // Visible, shopper-facing block. Default OFF: rendering UI on a merchant's // storefront is their call, made in the xpay dashboard, not inferred from data. if ( isset( $params['faq_visible'] ) ) { update_option( 'xpay_wc_faq_visible', self::truthy( $params['faq_visible'] ) ? 1 : 0 ); } if ( isset( $params['faq_placement'] ) ) { $placement = sanitize_key( (string) $params['faq_placement'] ); if ( in_array( $placement, array( 'auto', 'tab', 'hook', 'shortcode' ), true ) ) { update_option( 'xpay_wc_faq_placement', $placement ); } } // Heading rides the payload because the backend knows the merchant's locale — // a French store shouldn't wait on a WP.org release to stop seeing an English one. if ( isset( $params['faq_heading'] ) ) { $heading = mb_substr( sanitize_text_field( (string) $params['faq_heading'] ), 0, self::FAQ_MAX_HEADING_LEN ); if ( '' === $heading ) { delete_option( 'xpay_wc_faq_heading' ); } else { update_option( 'xpay_wc_faq_heading', $heading ); } } } /** * Purge the full-page cache for the products we just changed. * * Without this, a successful push is INVISIBLE. Full-page caches (LiteSpeed, WP * Rocket, WP Super Cache, W3TC, Cloudflare APO) serve stored HTML *before* PHP * runs, so our option write lands, the emitter renders the new FAQ, and the * shopper still gets the old page. Observed on a LiteSpeed store: the push * returned ok/executed, PHP rendered the new localized FAQ, and LiteSpeed kept * serving the stale HTML on the plain URL — only a cache-busting query * string revealed the update. "The plugin returned 200" never meant "the page * changed", and that was true of every push we make, not just FAQs. * * Every integration is guarded: we call into a caching plugin only if it's here. * Purging is best-effort by design — a cache we don't know about is a stale page, * not a failed request, so nothing below is allowed to throw the push. * * @param array $keys SKUs and/or numeric product ids. */ private static function purge_product_caches( $keys ) { if ( empty( $keys ) || ! function_exists( 'wc_get_product_id_by_sku' ) ) { return; } $ids = array(); foreach ( $keys as $key ) { $key = (string) $key; if ( '' === $key ) { continue; } $id = (int) wc_get_product_id_by_sku( $key ); if ( ! $id && ctype_digit( $key ) ) { // The emitter falls back to the numeric id when there's no SKU match, // so the purge has to resolve keys the same way the renderer does. $id = (int) $key; } if ( $id > 0 ) { $ids[ $id ] = true; } } if ( empty( $ids ) ) { return; } foreach ( array_keys( $ids ) as $id ) { // WordPress object cache. Core fires the `clean_post_cache` action from // inside here, which is also what Cloudflare APO and several caching // plugins hang their own purge off — so this does double duty. clean_post_cache( $id ); // LiteSpeed. Both surfaces: the documented `litespeed_purge_post` action // on current builds, the legacy API class on pre-3.0 ones. if ( class_exists( 'LiteSpeed_Cache_API' ) && method_exists( 'LiteSpeed_Cache_API', 'purge_post' ) ) { LiteSpeed_Cache_API::purge_post( $id ); } // `litespeed_purge_post` is LiteSpeed Cache's OWN hook — we are the caller, // not the owner. Prefixing it (xpay_litespeed_purge_post) would fire a hook // nothing listens to and silently break the purge, which is the entire point // of this method. PrefixAllGlobals can't tell "inventing a global hook" from // "calling a third-party plugin's documented API"; this is the latter. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party (LiteSpeed Cache) hook, intentionally invoked. do_action( 'litespeed_purge_post', $id ); // WP Rocket — same plugin that was swallowing our order attribution. if ( function_exists( 'rocket_clean_post' ) ) { rocket_clean_post( $id ); } // WP Super Cache. if ( function_exists( 'wp_cache_post_change' ) ) { wp_cache_post_change( $id ); } // W3 Total Cache. if ( function_exists( 'w3tc_flush_post' ) ) { w3tc_flush_post( $id ); } } } /** * Purge the FULL-PAGE cache site-wide. * * {@see purge_product_caches()} is per-post, which is right for a FAQ push — * it touches known products. The storefront widget is different: its loader is * injected into EVERY public page, so flipping it on or off invalidates the * whole site, not a product list. * * Without this, a store on WP Rocket / LiteSpeed turns the assistant on, sees * nothing, and concludes it's broken — the cached HTML is served BEFORE PHP * runs, so our loader is never injected until each page's cache happens to * expire. Pages that regenerate after the toggle work; the rest keep serving * a copy cached before it, with no loader in it. * * Best-effort by design, same as the per-post purge: a cache we don't know * about is a stale page, not a failed request. Nothing here may throw. * * ⛔ RATE-LIMITED, and it has to be. A site-wide flush is the heaviest thing * this plugin can ask a store to do — on WP Rocket with preload enabled, * rocket_clean_domain() doesn't just delete files, it kicks off a full-site * preload crawl. And the callers are more numerous than "a merchant toggled * the widget": clear_storefront_widget_cache also fires on an entitlement * change and on APPEARANCE edits, so dragging an accent-colour slider in the * dashboard would otherwise re-crawl the merchant's entire store. Worse, a * slow purge looks like a failed push to the backend, which retries — and each * retry flushes again. One purge per 5 minutes is plenty for a cache that * exists to be warm. */ public static function purge_site_page_cache() { if ( get_transient( self::PURGE_LOCK ) ) { // ⛔ COALESCE, never DROP. A rate limiter that simply discards the call // would lose the LAST one — and the last one is the one that matters. // Merchant enables the widget (purge, lock set) → tweaks a colour a minute // later (skipped) → turns the widget OFF two minutes later → skipped too, // so the cached HTML keeps serving the assistant on a storefront whose // owner just switched it off, until the cache expires on its own. Instead, // schedule exactly one purge for when the window closes. if ( ! wp_next_scheduled( self::DEFERRED_PURGE_HOOK ) ) { wp_schedule_single_event( time() + 5 * MINUTE_IN_SECONDS, self::DEFERRED_PURGE_HOOK ); } return false; } set_transient( self::PURGE_LOCK, 1, 5 * MINUTE_IN_SECONDS ); // WP Rocket. if ( function_exists( 'rocket_clean_domain' ) ) { rocket_clean_domain(); } // LiteSpeed. `litespeed_purge_all` is the DOCUMENTED third-party hook on // current builds and is what actually fires on a modern install; the // LiteSpeed_Cache_API class is the pre-3.0 surface, kept for legacy stores. // (Don't "clean up" the do_action — it is the live path, not the fallback.) if ( class_exists( 'LiteSpeed_Cache_API' ) && method_exists( 'LiteSpeed_Cache_API', 'purge_all' ) ) { LiteSpeed_Cache_API::purge_all(); } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party (LiteSpeed Cache) hook, intentionally invoked. do_action( 'litespeed_purge_all' ); // WP Super Cache. if ( function_exists( 'wp_cache_clear_cache' ) ) { wp_cache_clear_cache(); } // W3 Total Cache. if ( function_exists( 'w3tc_flush_all' ) ) { w3tc_flush_all(); } // WP Fastest Cache. if ( function_exists( 'wpfc_clear_all_cache' ) ) { wpfc_clear_all_cache( true ); } // Cache Enabler. if ( has_action( 'cache_enabler_clear_complete_cache' ) ) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party (Cache Enabler) hook, intentionally invoked. do_action( 'cache_enabler_clear_complete_cache' ); } // SiteGround Optimizer. if ( function_exists( 'sg_cachepress_purge_cache' ) ) { sg_cachepress_purge_cache(); } // Managed hosts. Each of these is a no-op when the host isn't present: // do_action() on a hook nobody registered simply does nothing. // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party (Nginx Helper) hook, intentionally invoked. do_action( 'rt_nginx_helper_purge_all' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party (Breeze / Cloudways) hook, intentionally invoked. do_action( 'breeze_clear_all_cache' ); if ( class_exists( 'WpeCommon' ) && method_exists( 'WpeCommon', 'purge_varnish_cache' ) ) { WpeCommon::purge_varnish_cache(); } /** * Escape hatch for every stack we cannot reach from here. * * ⚠️ Cloudflare APO is the honest gap: it caches HTML at the EDGE, and * purging it needs an authenticated Cloudflare API call with the site * owner's own token — there is no local hook we can fire. A store on APO * will still serve the pre-toggle page until its edge TTL expires. We * cannot fix that for them; we can give them somewhere to hook. * * Deliberately NOT wp_cache_flush(): that empties the OBJECT cache * (Redis/Memcached), which is not a page cache and would be a gratuitous * performance hit on exactly the largest stores. */ do_action( 'xpay_wc_purge_site_cache' ); return true; } /** * Normalise + bound backend-pushed sections so we never store unbounded * input. Each section is `{heading: string, body: string}`. HTML is * stripped from both fields; Markdown control characters are kept since * the body is rendered verbatim into a Markdown file. * * Silently drops malformed entries — backend gets an executed=ok response * either way (an empty array is an explicit "clear" semantically). */ private static function sanitize_llms_txt_extra_sections( $raw ) { $clean = array(); foreach ( $raw as $entry ) { if ( ! is_array( $entry ) ) { continue; } $heading = isset( $entry['heading'] ) && is_string( $entry['heading'] ) ? $entry['heading'] : ''; $body = isset( $entry['body'] ) && is_string( $entry['body'] ) ? $entry['body'] : ''; $heading = wp_strip_all_tags( $heading ); $body = wp_strip_all_tags( $body ); $heading = mb_substr( $heading, 0, self::LLMS_TXT_MAX_HEADING_LEN ); $body = mb_substr( $body, 0, self::LLMS_TXT_MAX_BODY_LEN ); if ( '' === $heading && '' === $body ) { continue; } $clean[] = array( 'heading' => $heading, 'body' => $body, ); if ( count( $clean ) >= self::LLMS_TXT_MAX_SECTIONS ) { break; } } return $clean; } /** * Sanitize the pushed per-product FAQ map: { "": { faq:[{q,a}], return_policy:{…} } }. * Caps count + lengths so a hostile/misconfigured backend can't bloat the option. * * @param array $raw * @return array */ private static function sanitize_product_faqs( $raw ) { $clean = array(); $max_skus = 5000; $count = 0; foreach ( $raw as $sku => $entry ) { if ( $count >= $max_skus ) { break; } $key = sanitize_text_field( (string) $sku ); if ( '' === $key || ! is_array( $entry ) ) { continue; } $out = array(); if ( ! empty( $entry['faq'] ) && is_array( $entry['faq'] ) ) { $faqs = array(); foreach ( array_slice( $entry['faq'], 0, 10 ) as $qa ) { if ( ! is_array( $qa ) ) { continue; } $q = isset( $qa['q'] ) ? sanitize_text_field( substr( (string) $qa['q'], 0, 300 ) ) : ''; $a = isset( $qa['a'] ) ? sanitize_textarea_field( substr( (string) $qa['a'], 0, 1200 ) ) : ''; if ( '' !== $q && '' !== $a ) { $faqs[] = array( 'q' => $q, 'a' => $a, ); } } if ( $faqs ) { $out['faq'] = $faqs; } } if ( ! empty( $entry['return_policy'] ) && is_array( $entry['return_policy'] ) ) { $rp = $entry['return_policy']; $out['return_policy'] = array( 'country' => isset( $rp['country'] ) ? sanitize_text_field( substr( (string) $rp['country'], 0, 4 ) ) : '', 'days' => isset( $rp['days'] ) ? (int) $rp['days'] : 14, 'category' => isset( $rp['category'] ) ? esc_url_raw( (string) $rp['category'] ) : '', 'method' => isset( $rp['method'] ) ? esc_url_raw( (string) $rp['method'] ) : '', 'fees' => isset( $rp['fees'] ) ? esc_url_raw( (string) $rp['fees'] ) : '', 'url' => isset( $rp['url'] ) ? esc_url_raw( (string) $rp['url'] ) : '', ); } if ( $out ) { $clean[ $key ] = $out; ++$count; } } return $clean; } /** * Reader used by Xpay_REST::serve_llms_txt() to append the backend-pushed * sections at the end of the file. Returns an array of `{heading, body}` * or empty array if nothing has been pushed. */ public static function get_llms_txt_extra_sections() { $encoded = (string) get_option( 'xpay_wc_llms_txt_extra_sections', '' ); if ( '' === $encoded ) { return array(); } $decoded = json_decode( $encoded, true ); if ( ! is_array( $decoded ) ) { return array(); } // Re-sanitize on read in case the option was hand-edited (defence in // depth — same bounds as the writer). return self::sanitize_llms_txt_extra_sections( $decoded ); } }