HEX
Server: Apache
System: Linux p3plzcpnl476737.prod.phx3.secureserver.net 4.18.0-553.54.1.lve.el8.x86_64 #1 SMP Wed Jun 4 13:01:13 UTC 2025 x86_64
User: p8pyefaexf70 (9161224)
PHP: 7.4.33
Disabled: NONE
Upload Files
File: /home/p8pyefaexf70/www/wp-content/plugins/facebook-for-woocommerce/includes/CollectionPage.php
<?php
/**
 * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved
 *
 * This source code is licensed under the license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @package MetaCommerce
 */

namespace WooCommerce\Facebook;

use WooCommerce\Facebook\Framework\Logger;

defined( 'ABSPATH' ) || exit;

/**
 * Facebook Commerce Recommendation Override for /fbcollection/
 * New URL Example: /fbcollection/?clicked_product_id=SKU123_123&shown_product_ids=SKU456_456,SKU789_789
 */
class CollectionPage {
	/** @var string the website url suffix for the collection page */
	const ENDPOINT_PATH = '/fbcollection/';

	/** @var string the field that should be used for sync'ing the collection page endpoint */
	const PRODUCT_FEED_FIELD = 'custom_label_4';

	/** @var string Option name for tracking Meta log count */
	const META_LOG_COUNTER_OPTION = 'wc_facebook_fbcollection_meta_log_counter';

	/** @var int Maximum number of logs to send to Meta per plugin version (for beta testing) */
	const META_LOG_MAX_COUNT = 20;

	public function __construct() {
		add_action( 'init', [ $this, 'register_rewrite_rule' ] );
		add_filter( 'query_vars', [ $this, 'add_query_vars' ] );
		add_action( 'woocommerce_product_query', [ $this, 'modify_product_query' ] );
		add_filter( 'woocommerce_loop_display_mode', [ $this, 'force_products_display_mode' ], PHP_INT_MAX );
	}

	/**
	 * Register /fbcollection/ as a virtual WooCommerce archive page.
	 */
	public function register_rewrite_rule() {
		add_rewrite_rule( '^fbcollection/?$', 'index.php?post_type=product&custom_fbcollection_page=1', 'top' );
	}

	/**
	 * Add custom query variable.
	 *
	 * @param array $vars Query variables.
	 * @return array
	 */
	public function add_query_vars( $vars ) {
		$vars[] = 'custom_fbcollection_page';
		return $vars;
	}

	/**
	 * Force "products" display mode on the fbcollection page.
	 * Prevents the WooCommerce "Shop page display" setting from showing
	 * product categories instead of the intended product list.
	 *
	 * @param string $display_mode The current display mode.
	 * @return string
	 */
	public function force_products_display_mode( $display_mode ) {
		if ( 1 === intval( get_query_var( 'custom_fbcollection_page' ) ) ) {
			return 'products';
		}
		return $display_mode;
	}

	/**
	 * Modify WooCommerce product query to inject custom product IDs.
	 *
	 * @param WP_Query $query The WooCommerce product query.
	 */
	public function modify_product_query( $query ) {
		if ( 1 !== intval( get_query_var( 'custom_fbcollection_page' ) ) ) {
			return;
		}

		$final_product_ids = [];

		// Parse clicked_product_id (urldecode handles double-encoded URLs from Facebook)
		$clicked_product_id_raw = $this->get_url_params( 'clicked_product_id' );
		$clicked_product_id     = $this->extract_woo_id_from_retailer_id( $clicked_product_id_raw );

		if ( $clicked_product_id ) {
			$product_id = $this->get_product_id_handle_variations( $clicked_product_id );
			if ( false !== $product_id ) {
				$clicked_product_id  = $product_id;
				$final_product_ids[] = $clicked_product_id;
			}
		}

		// Parse shown_product_ids (urldecode handles double-encoded URLs from Facebook)
		$shown_product_ids_raw = explode( ',', $this->get_url_params( 'shown_product_ids' ) );
		$shown_product_ids     = array_map( array( $this, 'extract_woo_id_from_retailer_id' ), array_map( 'sanitize_text_field', $shown_product_ids_raw ) );
		$shown_product_ids     = array_unique( array_filter( $shown_product_ids ) ); // Remove empty/invalid
		$shown_product_ids     = array_slice( $shown_product_ids, 0, 30 ); // Limit to 30

		$shown_product_ids = array_filter(
			array_filter(
				array_unique(
					array_map(
						array( $this, 'get_product_id_handle_variations' ),
						$shown_product_ids
					)
				)
			),
			fn( $id ) => $clicked_product_id !== $id
		);

		$final_product_ids = array_merge( $final_product_ids, $shown_product_ids );

		if ( ! empty( $final_product_ids ) ) {
			$query->set( 'post__in', $final_product_ids );
			$query->set( 'orderby', 'post__in' );
			$query->set( 'posts_per_page', count( $final_product_ids ) );

			// Prevent WooCommerce core and themes from overriding the product order.
			// Removes itself after first invocation to avoid affecting other queries in the same request.
			$ordering_override = function ( $_args ) use ( &$ordering_override ) {
				remove_filter( 'woocommerce_get_catalog_ordering_args', $ordering_override, PHP_INT_MAX );
				return array(
					'orderby'  => 'post__in',
					'order'    => 'ASC',
					'meta_key' => '', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
				);
			};
			add_filter( 'woocommerce_get_catalog_ordering_args', $ordering_override, PHP_INT_MAX );
		} else {
			// Log when no valid products found
			// Only log for the first N occurrences per plugin version (for beta testing)
			if ( $this->should_log() ) {
				// phpcs:disable WordPress.Security.NonceVerification.Recommended -- Public read-only endpoint, URL generated by Facebook
				Logger::log(
					'FBCollection: No valid products found for request: ' . wp_json_encode( $_GET ),
					[],
					array(
						'should_send_log_to_meta'        => true,
						'should_save_log_in_woocommerce' => true,
						'woocommerce_log_level'          => \WC_Log_Levels::WARNING,
					)
				);
				// phpcs:enable WordPress.Security.NonceVerification.Recommended
			}

			$query->set( 'orderby', 'popularity' );
			$query->set( 'posts_per_page', 8 );
		}
	}

	private function get_url_params( $parameter_name ) {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitization happens after urldecode to prevent encoded XSS
		return sanitize_text_field( urldecode( wp_unslash( $_GET[ $parameter_name ] ?? '' ) ) );
	}

	/**
	 * Check if we should log the issue and increment the counter.
	 * Returns true for the first N occurrences per plugin version, then false.
	 * Counter resets when the plugin is updated.
	 *
	 * @return bool Whether to send this log to Meta.
	 */
	private function should_log() {
		$current_version = defined( '\WooCommerce\Facebook\PLUGIN_VERSION' )
			? \WooCommerce\Facebook\PLUGIN_VERSION
			: facebook_for_woocommerce()->get_version();

		$option = get_option( self::META_LOG_COUNTER_OPTION, array() );

		// Reset counter if plugin version changed.
		if ( ! is_array( $option ) || ( $option['version'] ?? '' ) !== $current_version ) {
			$option = array(
				'version' => $current_version,
				'count'   => 0,
			);
		}

		// Check if we've reached the limit.
		if ( $option['count'] >= self::META_LOG_MAX_COUNT ) {
			return false;
		}

		// Increment and save.
		++$option['count'];
		update_option( self::META_LOG_COUNTER_OPTION, $option, false );

		return true;
	}

	/**
	 * Translate a product ID for multi-language sites (WPML/Polylang).
	 *
	 * @param int $product_id The original product ID.
	 * @return int The translated product ID for the current language, or original if no translation.
	 */
	private function translate_product_id( $product_id ) {
		if ( ! $product_id ) {
			return $product_id;
		}

		// WPML support - auto-detect post type to handle both products and variations
		if ( has_filter( 'wpml_object_id' ) ) {
			$post_type = get_post_type( $product_id );
			if ( $post_type ) {
				$translated_id = apply_filters( 'wpml_object_id', $product_id, $post_type, true );
				if ( $translated_id ) {
					return $translated_id;
				}
			}
		}

		// Polylang support
		if ( function_exists( 'pll_get_post' ) ) {
			$translated_id = pll_get_post( $product_id );
			if ( $translated_id ) {
				return $translated_id;
			}
		}

		return $product_id;
	}

	/**
	 * Get the displayable product ID, handling variations and translations.
	 * Converts variations to their parent products since archive pages display parent products only.
	 * Bundle/composite products are returned as-is if they are valid visible products.
	 *
	 * @param int $product_id The product ID (could be variation, bundle, composite, etc.).
	 * @return int|false The displayable product ID, or false if invalid/not visible.
	 */
	private function get_product_id_handle_variations( $product_id ) {
		if ( ! $product_id ) {
			return false;
		}

		// Translate for multi-language support
		$product_id = $this->translate_product_id( $product_id );

		$product = wc_get_product( $product_id );

		if ( ! $product ) {
			return false;
		}

		// Handle variations - return parent variable product
		if ( $product->is_type( 'variation' ) ) {
			$parent_id = $product->get_parent_id();
			if ( $parent_id ) {
				$parent_id = $this->translate_product_id( $parent_id );
				$product   = wc_get_product( $parent_id );
				if ( $product ) {
					$product_id = $parent_id;
				} else {
					return false;
				}
			} else {
				return false;
			}
		}

		// For all other product types (simple, variable, bundle, composite, grouped, external, etc.)
		// check visibility and return as-is if valid
		if ( ! $product->is_visible() ) {
			return false;
		}

		return $product_id;
	}

	/**
	 * Extracts the WooCommerce product ID from a Facebook retailer ID.
	 *
	 * @param string $retailer_id The retailer ID (e.g., "GTIN-12345_789_63", "SKU123_456_63", "wc_post_id_63").
	 * @return int|false The WooCommerce product ID, or false if invalid.
	 */
	private function extract_woo_id_from_retailer_id( $retailer_id ) {
		if ( empty( $retailer_id ) || ! is_string( $retailer_id ) ) {
			return false;
		}

		$retailer_id = trim( $retailer_id );

		// If it's already just a number, return it
		if ( ctype_digit( $retailer_id ) ) {
			return absint( $retailer_id );
		}

		// Find the last underscore position
		$last_underscore = strrpos( $retailer_id, '_' );

		if ( false === $last_underscore ) {
			return false;
		}

		// Extract everything after the last underscore
		$woo_id = substr( $retailer_id, $last_underscore + 1 );

		// Validate it's a positive integer
		if ( ! ctype_digit( $woo_id ) || '' === $woo_id ) {
			return false;
		}

		return absint( $woo_id );
	}
}