//sitemap.xml` when the * proxy is enabled for this merchant, so the article sitemap (served by the * proxy at the apex) is discoverable by search engines. Additive + idempotent: * runs at PHP_INT_MAX so it appends after other SEO plugins' output rather than * being clobbered, and no-ops if the line is already present. Only applies to a * WordPress-generated (virtual) robots.txt — a physical robots.txt file bypasses * this filter entirely (documented in the go-live playbook as the manual case). */ public static function append_sitemap_directive( $output, $public ) { $policy = self::policy(); if ( ! $policy ) { return $output; // proxy off => don't advertise a sitemap that won't serve } $runtime = empty( $policy['paths'] ) ? array() : self::runtime_reserved_paths(); $entries = Xpay_Owned_Path_Matcher::build_entries( $policy, $runtime ); // Blog first (its line is unchanged), then each additional section. $ordered = array(); foreach ( $entries as $entry ) { if ( ! empty( $entry['is_blog'] ) ) { array_unshift( $ordered, $entry ); } else { $ordered[] = $entry; } } $lines = array(); foreach ( $ordered as $entry ) { // A section with its own sitemapUrl advertises that URL; otherwise the // apex `{prefix}/sitemap.xml`, which the proxy serves. $sitemap_url = ! empty( $entry['custom_sitemap'] ) ? $entry['sitemap_url'] : home_url( $entry['prefix'] . '/sitemap.xml' ); if ( false !== strpos( (string) $output, $sitemap_url ) ) { continue; // already advertised — don't duplicate } $line = 'Sitemap: ' . esc_url_raw( $sitemap_url ); if ( ! in_array( $line, $lines, true ) ) { $lines[] = $line; } } if ( empty( $lines ) ) { return $output; } return rtrim( (string) $output, "\n" ) . "\n\n" . implode( "\n", $lines ) . "\n"; } public static function maybe_proxy() { // ---- fail-open guards: any of these => do nothing ---- if ( is_admin() || wp_doing_cron() ) { return; } if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return; } if ( defined( 'WP_CLI' ) && WP_CLI ) { return; } if ( ! Xpay_Plugin::is_connected() ) { return; // unconnected stores never proxy } $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; if ( 'GET' !== $method && 'HEAD' !== $method ) { return; // never touch POST/PUT/etc } // Our own self-probe header, and the proxy loop-guard header — never proxy. if ( ! empty( $_SERVER['HTTP_X_XPAY_PROBE'] ) || ! empty( $_SERVER['HTTP_X_XPAY_PROXY'] ) ) { return; } $policy = self::policy(); if ( ! $policy ) { return; // no policy => no-op } // The blog section is enabled exactly as before (enabled, agentSurfaceReady, // https origin, non-root prefix); additional sections are each enabled on // their own. Nothing enabled => no-op. $entries = Xpay_Owned_Path_Matcher::build_entries( $policy ); if ( empty( $entries ) ) { return; } // Parse the request path (lower-cased for matching; original URI preserved // for the upstream fetch + canonical). $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '/'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- prefix-matched + forwarded, never echoed as HTML. $uri = is_string( $uri ) ? $uri : '/'; $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); $path = ( '' === $path ) ? '/' : $path; $lpath = Xpay_Owned_Path_Matcher::normalize_path( $path ); // Only paths at or under a section prefix (longest prefix wins, whole // segments only). $entry = Xpay_Owned_Path_Matcher::match( $entries, $path ); if ( ! $entry ) { return; } if ( empty( $entry['is_blog'] ) ) { // An additional section may never claim a path this store already uses // (shop/cart/checkout/account pages, product bases, REST, posts page). // Checked only once a request actually falls inside such a section. $entry = Xpay_Owned_Path_Matcher::match( Xpay_Owned_Path_Matcher::build_entries( $policy, self::runtime_reserved_paths() ), $path ); if ( ! $entry || ! empty( $entry['is_blog'] ) ) { return; } } // Hard safety excludes (+ any extra from the policy). if ( Xpay_Owned_Path_Matcher::is_excluded( $lpath, $entry, self::ALWAYS_EXCLUDE ) ) { return; } // For an additional section, let WordPress win BEFORE we spend a remote // call on its sitemap: a page WordPress serves is never touched. if ( empty( $entry['is_blog'] ) && ! is_404() ) { return; } // The section sitemap ({prefix}/sitemap.xml) is served by the origin but is // NOT a member of its own list, so it can never pass the owned-paths // gate below. Let it through explicitly so search engines can fetch it // under the apex (example.com/blog/sitemap.xml). Paired with the // robots.txt Sitemap directive (append_sitemap_directive). $is_section_sitemap = Xpay_Owned_Path_Matcher::is_section_sitemap( $lpath, $entry ); // Owned-paths gate: the requested path must be one the origin actually // publishes (its own sitemap is the source of truth). This covers the // prefix index, its pages, and category/tag archives — and nothing else — // so we never hit the origin for, or shadow, a path we don't own. if ( ! $is_section_sitemap ) { $owned = self::owned_paths( $entry ); if ( empty( $owned ) || ! in_array( $lpath, $owned, true ) ) { return; // not one of ours (or sitemap unavailable) — let WP serve } } // Precedence rule (the anti-shadow keystone): if WordPress itself resolved // ANY real content at this URL — a published post/page, the blog posts-page // (Settings → Reading), a category/archive, anything that is NOT a 404 — it // WINS. We bail and let WP render it. Two things fall out of this: // 1. A merchant who already uses /blog for their own blog is never // shadowed — their /blog isn't a 404, so we don't touch it. // 2. "Push to WP + publish" (Xpay_Content_Pages) cleanly takes ownership — // once the page is a real published post, it stops being a 404. // We only ever render into the GAPS WordPress would otherwise 404 on. if ( ! is_404() ) { return; } $origin_base = $entry['origin_base']; // Loop guard: our origin host must differ from the apex host. $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; $origin_host = (string) wp_parse_url( $origin_base, PHP_URL_HOST ); if ( '' !== $host && '' !== $origin_host && strtolower( $host ) === strtolower( $origin_host ) ) { return; } $cache_seconds = (int) $entry['cache_seconds']; $swr = isset( $policy['staleWhileRevalidate'] ) ? max( 0, (int) $policy['staleWhileRevalidate'] ) : DAY_IN_SECONDS; // ---- serve: WP-transient body cache first, then the origin fetch ---- // The cache key ignores utm_* and click-id parameters (one cached copy per // page). The upstream fetch below uses the SAME stripped query, so a cached // body always corresponds to its key. $query = Xpay_Owned_Path_Matcher::cache_query( (string) wp_parse_url( $uri, PHP_URL_QUERY ) ); $cache_key = Xpay_Owned_Path_Matcher::body_cache_key( $path, $query ); $cached = ( $cache_seconds > 0 ) ? get_transient( $cache_key ) : false; if ( is_array( $cached ) && isset( $cached['body'], $cached['status'], $cached['ctype'] ) ) { self::emit( (int) $cached['status'], (string) $cached['ctype'], (string) $cached['body'], $host, $path, $cache_seconds, $swr, $method, true, $entry['id'] ); return; // emit() exits; defensive return } $origin = rtrim( $origin_base, '/' ) . $path . ( '' !== $query ? '?' . $query : '' ); $resp = wp_remote_get( $origin, array( 'timeout' => self::FETCH_TIMEOUT, 'redirection' => 2, 'headers' => array( 'Accept' => isset( $_SERVER['HTTP_ACCEPT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) : 'text/html', 'X-Xpay-Proxy' => '1', 'X-Xpay-Tenant' => $host, 'X-Forwarded-Host' => $host, ), ) ); if ( is_wp_error( $resp ) ) { return; // FAIL-OPEN } $status = (int) wp_remote_retrieve_response_code( $resp ); if ( $status < 200 || $status >= 400 ) { return; // FAIL-OPEN on 4xx/5xx (incl. the origin's own 404) } $body = (string) wp_remote_retrieve_body( $resp ); $ctype = (string) wp_remote_retrieve_header( $resp, 'content-type' ); if ( '' === $ctype ) { $ctype = 'text/html; charset=UTF-8'; } if ( $cache_seconds > 0 ) { set_transient( $cache_key, array( 'status' => $status, 'ctype' => $ctype, 'body' => $body ), $cache_seconds ); } self::emit( $status, $ctype, $body, $host, $path, $cache_seconds, $swr, $method, false, $entry['id'] ); } /** * Emit the proxied response under the apex URL and exit. Sets a runtime marker * so Xpay_Agent_Analytics (shutdown hook, after this exit) can record the hit. */ private static function emit( $status, $ctype, $body, $host, $path, $cache_seconds, $swr, $method, $cache_hit, $section = Xpay_Owned_Path_Matcher::BLOG_ID ) { $GLOBALS['xpay_wc_blog_proxied'] = array( 'path' => $path, 'status' => $status, 'cache_hit' => (bool) $cache_hit, 'section' => (string) $section, ); status_header( $status ); header( 'Content-Type: ' . $ctype ); header( 'Cache-Control: public, s-maxage=' . (int) $cache_seconds . ', stale-while-revalidate=' . (int) $swr ); // Defensive canonical back to the apex path (the renderer should already // emit one, but a proxied response should never point at the origin host). if ( '' !== $host ) { $canonical = ( is_ssl() ? 'https://' : 'http://' ) . $host . $path; header( 'Link: <' . esc_url_raw( $canonical ) . '>; rel="canonical"', false ); } header( 'X-Xpay-Proxied: 1' ); if ( 'HEAD' !== $method ) { echo $body; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- upstream-rendered trusted HTML echoed verbatim (reverse-proxy body). } exit; } /** * The set of URL paths the origin publishes under a section, read from its * sitemap (`{originBase}{prefix}/sitemap.xml`, or the section's own * sitemapUrl) and cached. Paths are normalised (lower-cased, no trailing * slash) so they compare directly against the request path. Returns array() * on any fetch failure — the gate then fails open (serves WP normally) * rather than proxying blindly. * * The blog section uses exactly the cache key and fetch it always did. For an * additional section a failure is remembered for PATHS_FAIL_TTL so an origin * outage costs one remote call per 5 minutes, not one per request. * * @param array $entry Section (from Xpay_Owned_Path_Matcher). * @param bool $force Refetch even when cached (background warm-up only). * @return array */ private static function owned_paths( $entry, $force = false ) { $is_blog = ! empty( $entry['is_blog'] ); $key = Xpay_Owned_Path_Matcher::paths_cache_key( $entry ); if ( ! $force ) { $cached = get_transient( $key ); if ( is_array( $cached ) ) { return $cached; } if ( ! $is_blog && false !== get_transient( Xpay_Owned_Path_Matcher::paths_fail_key( $entry ) ) ) { return array(); // failed recently — fail open without another remote call } } $resp = wp_remote_get( $entry['sitemap_url'], array( 'timeout' => self::FETCH_TIMEOUT, 'headers' => array( 'X-Xpay-Proxy' => '1' ), ) ); if ( is_wp_error( $resp ) || 200 !== (int) wp_remote_retrieve_response_code( $resp ) ) { if ( ! $is_blog ) { set_transient( Xpay_Owned_Path_Matcher::paths_fail_key( $entry ), 1, self::PATHS_FAIL_TTL ); } return array(); // fail-open (a warm-up failure keeps the previous list) } $body = (string) wp_remote_retrieve_body( $resp ); $paths = array(); if ( preg_match_all( '#\s*([^<]+?)\s*#i', $body, $m ) ) { foreach ( $m[1] as $loc ) { $p = (string) wp_parse_url( html_entity_decode( $loc ), PHP_URL_PATH ); if ( '' === $p ) { continue; } $p = strtolower( rtrim( $p, '/' ) ); $paths[] = ( '' === $p ) ? '/' : $p; } } $paths = array_values( array_unique( $paths ) ); set_transient( $key, $paths, self::PATHS_TTL ); if ( ! $is_blog ) { delete_transient( Xpay_Owned_Path_Matcher::paths_fail_key( $entry ) ); } return $paths; } /** * Paths this store already uses and that no additional section may claim: * the WooCommerce shop, cart, checkout and account pages, the product, * category, tag and attribute bases, the REST prefix and the posts page. * Lower-case, no trailing slash. Computed once per request, and only when a * request falls inside an additional section (or for robots.txt / the cron). * * @return array */ private static function runtime_reserved_paths() { static $paths = null; if ( null !== $paths ) { return $paths; } $paths = array(); if ( function_exists( 'wc_get_page_id' ) && function_exists( 'wc_get_page_permalink' ) ) { foreach ( array( 'shop', 'cart', 'checkout', 'myaccount' ) as $page ) { if ( (int) wc_get_page_id( $page ) > 0 ) { $paths[] = self::url_path( wc_get_page_permalink( $page ) ); } } } $home_path = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' ); $bases = get_option( 'woocommerce_permalinks' ); if ( is_array( $bases ) ) { foreach ( array( 'product_base', 'category_base', 'tag_base', 'attribute_base' ) as $k ) { if ( empty( $bases[ $k ] ) || ! is_string( $bases[ $k ] ) ) { continue; } $base = trim( (string) preg_replace( '#/+#', '/', (string) preg_replace( '#%[^%/]+%#', '', $bases[ $k ] ) ), '/' ); if ( '' !== $base ) { $paths[] = strtolower( $home_path . '/' . $base ); } } } if ( function_exists( 'rest_get_url_prefix' ) ) { $rest = trim( (string) rest_get_url_prefix(), '/' ); if ( '' !== $rest ) { $paths[] = strtolower( $home_path . '/' . $rest ); } } $posts_page = (int) get_option( 'page_for_posts' ); if ( $posts_page > 0 ) { $paths[] = self::url_path( get_permalink( $posts_page ) ); } $paths = array_values( array_unique( array_filter( $paths, 'strlen' ) ) ); return $paths; } /** * Lower-case path of a URL without a trailing slash ('' for the site root). * * @param mixed $url URL. * @return string */ private static function url_path( $url ) { if ( ! is_string( $url ) || '' === $url ) { return ''; } return strtolower( rtrim( (string) wp_parse_url( $url, PHP_URL_PATH ), '/' ) ); } /** * Operator action (Xpay_Admin_REST `clear_owned_path_cache`): forget every * section's cached sitemap allow-list and failure marker, so the next request * re-reads the sitemap. Local only; makes no remote call. * * @return int Number of sections cleared. */ public static function clear_owned_path_cache() { $policy = self::policy(); if ( ! $policy ) { return 0; } $n = 0; foreach ( Xpay_Owned_Path_Matcher::build_entries( $policy ) as $entry ) { delete_transient( Xpay_Owned_Path_Matcher::paths_cache_key( $entry ) ); delete_transient( Xpay_Owned_Path_Matcher::paths_fail_key( $entry ) ); ++$n; } return $n; } /** * Short, non-secret summary for the operator refresh endpoint. * * @return array */ public static function status_report() { $policy = self::policy(); $sections = array(); if ( $policy ) { $runtime = empty( $policy['paths'] ) ? array() : self::runtime_reserved_paths(); foreach ( Xpay_Owned_Path_Matcher::build_entries( $policy, $runtime ) as $entry ) { $sections[] = array( 'id' => $entry['id'], 'prefix' => $entry['prefix'], 'cached' => is_array( get_transient( Xpay_Owned_Path_Matcher::paths_cache_key( $entry ) ) ), ); } } return array( 'policy_cached' => (bool) $policy, 'sections' => $sections, ); } }