• File: RemoteInboxNotificationsDataSourcePoller.php
  • Full Path: /home/slvgwrnz/public_html/wp-content/plugins/woocommerce/src/Admin/RemoteInboxNotifications/RemoteInboxNotificationsDataSourcePoller.php
  • Date Modified: 10/26/2026 10:38 AM
  • File size: 9.5 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php
/**
 * Handles polling and storage of specs
 */

namespace Automattic\WooCommerce\Admin\RemoteInboxNotifications;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Admin\RemoteSpecs\DataSourcePoller;
use Automattic\WooCommerce\Admin\RemoteSpecs\RuleProcessors\GetRuleProcessor;
use WC_Helper;

/**
 * Specs data source poller class.
 * This handles polling specs from JSON endpoints, and
 * stores the specs in to the database as an option.
 */
class RemoteInboxNotificationsDataSourcePoller extends DataSourcePoller {
	const ID = 'remote_inbox_notifications';

	/**
	 * Transient holding the latest read's fetch time and published slugs, or null slugs for an inconclusive read.
	 */
	private const PUBLISHED_SNAPSHOT_TRANSIENT = 'woocommerce_admin_remote_inbox_notifications_published_snapshot';

	/**
	 * Published identities from this read's canonical response, or null when it is not authoritative.
	 *
	 * @var string[]|null
	 */
	private $published_slugs = null;

	/**
	 * Default data sources array.
	 *
	 * @deprecated since 9.5.0. Use get_data_sources() instead.
	 */
	const DATA_SOURCES = array();

	/**
	 * Class instance.
	 *
	 * @var RemoteInboxNotificationsDataSourcePoller instance
	 */
	protected static $instance = null;

	/**
	 * Get class instance.
	 */
	public static function get_instance() {
		if ( ! self::$instance ) {
			self::$instance = new self(
				self::ID,
				self::get_data_sources(),
				array(
					'spec_key'         => 'slug',
					'transient_expiry' => DAY_IN_SECONDS,
				)
			);
		}
		return self::$instance;
	}

	/**
	 * Read the data sources and record the identities published by the canonical WooCommerce.com feed.
	 *
	 * Invalid specs with usable slugs still count as published. An inconclusive response retains only its timestamp to reject older reads.
	 *
	 * @since 11.3.0
	 *
	 * @return bool Whether any specs were read.
	 */
	public function read_specs_from_data_sources() {
		$fetched_at            = time();
		$this->published_slugs = null;

		$result = parent::read_specs_from_data_sources();

		$this->save_published_snapshot( $fetched_at, $this->published_slugs );

		return $result;
	}

	/**
	 * Get the published identities from the latest complete canonical response.
	 *
	 * Inconclusive reads store their fetch time with null slugs to reject older reads, but this method returns null for them.
	 *
	 * @internal
	 * @since 11.3.0
	 *
	 * @return array{fetched_at: int, slugs: string[]}|null The snapshot, or null when the latest read is inconclusive or absent.
	 */
	public function get_published_snapshot(): ?array {
		$snapshot = get_transient( self::PUBLISHED_SNAPSHOT_TRANSIENT );

		if ( ! is_array( $snapshot ) || ! is_int( $snapshot['fetched_at'] ?? null ) || ! is_array( $snapshot['slugs'] ?? null ) ) {
			return null;
		}

		return array(
			'fetched_at' => $snapshot['fetched_at'],
			'slugs'      => array_map( 'strval', array_values( array_filter( $snapshot['slugs'], 'is_scalar' ) ) ),
		);
	}

	/**
	 * Merge runnable specs and track canonical publication identities independently of validation.
	 *
	 * @since 11.3.0
	 *
	 * @param array  $specs_to_merge_in The specs to merge in to $specs.
	 * @param array  $specs             The list of specs being merged into.
	 * @param string $url               The url of the feed being merged in.
	 * @return void
	 */
	protected function merge_specs( $specs_to_merge_in, &$specs, $url ) {
		parent::merge_specs( $specs_to_merge_in, $specs, $url );

		if ( ! in_array( $url, self::get_data_sources(), true ) ) {
			return;
		}

		$this->published_slugs = null;
		$slugs                 = array();
		foreach ( $specs_to_merge_in as $spec ) {
			if ( ! isset( $spec->slug ) || ! is_scalar( $spec->slug ) || '' === (string) $spec->slug ) {
				return;
			}
			$slug = (string) $spec->slug;
			if ( isset( $slugs[ $slug ] ) ) {
				wc_get_logger()->warning(
					'Skipping Store Alert withdrawal cleanup because the canonical feed contains a duplicate slug.',
					array(
						'source' => $url,
						'slug'   => $slug,
					)
				);
				return;
			}
			$slugs[ $slug ] = $slug;
		}
		// An empty list is inconclusive: WooCommerce.com also serves one when its own notifications query fails.
		if ( ! empty( $slugs ) ) {
			$this->published_slugs = array_values( $slugs );
		}
	}

	/**
	 * Store a read's publication evidence unless a newer read is already stored.
	 *
	 * @param int           $fetched_at When the read started.
	 * @param string[]|null $slugs      Published slugs, or null for an inconclusive read.
	 */
	private function save_published_snapshot( int $fetched_at, ?array $slugs ): void {
		$stored = get_transient( self::PUBLISHED_SNAPSHOT_TRANSIENT );
		if ( is_array( $stored ) && is_int( $stored['fetched_at'] ?? null ) && $stored['fetched_at'] > $fetched_at ) {
			return;
		}

		set_transient(
			self::PUBLISHED_SNAPSHOT_TRANSIENT,
			array(
				'fetched_at' => $fetched_at,
				'slugs'      => $slugs,
			),
			$this->args['transient_expiry']
		);
	}

	/**
	 * Validate the spec.
	 *
	 * @param object $spec The spec to validate.
	 * @param string $url  The url of the feed that provided the spec.
	 *
	 * @return bool The result of the validation.
	 */
	protected function validate_spec( $spec, $url ) {
		$logger         = self::get_logger();
		$logger_context = array( 'source' => $url );

		if ( ! isset( $spec->slug ) || ! is_scalar( $spec->slug ) || '' === (string) $spec->slug ) {
			$logger->error(
				'Spec is invalid because the slug is missing or invalid in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $spec, true ), $logger_context );

			return false;
		}

		if ( ! isset( $spec->status ) ) {
			$logger->error(
				'Spec is invalid because the status is missing in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $spec, true ), $logger_context );

			return false;
		}

		if ( ! isset( $spec->locales ) || ! is_array( $spec->locales ) ) {
			$logger->error(
				'Spec is invalid because the status is missing or empty in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $spec, true ), $logger_context );

			return false;
		}

		if ( null === SpecRunner::get_locale( $spec->locales ) ) {
			$logger->error(
				'Spec is invalid because the locale could not be retrieved in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $spec, true ), $logger_context );

			return false;
		}

		if ( ! isset( $spec->type ) ) {
			$logger->error(
				'Spec is invalid because the type is missing in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $spec, true ), $logger_context );

			return false;
		}

		if ( isset( $spec->actions ) && is_array( $spec->actions ) ) {
			foreach ( $spec->actions as $action ) {
				if ( ! $this->validate_action( $action, $url ) ) {
					$logger->error(
						'Spec is invalid because an action is invalid in feed',
						$logger_context
					);
					// phpcs:ignore
					$logger->error( print_r( $spec, true ), $logger_context );

					return false;
				}
			}
		}

		if ( isset( $spec->rules ) && is_array( $spec->rules ) ) {
			foreach ( $spec->rules as $rule ) {
				if ( ! isset( $rule->type ) ) {
					$logger->error(
						'Spec is invalid because a rule type is empty in feed',
						$logger_context
					);
					// phpcs:ignore
					$logger->error( print_r( $rule, true ), $logger_context );
					// phpcs:ignore
					$logger->error( print_r( $spec, true ), $logger_context );

					return false;
				}

				$processor = GetRuleProcessor::get_processor( $rule->type );

				if ( ! $processor->validate( $rule ) ) {
					$logger->error(
						'Spec is invalid because a rule is invalid in feed',
						$logger_context
					);
					// phpcs:ignore
					$logger->error( print_r( $rule, true ), $logger_context );
					// phpcs:ignore
					$logger->error( print_r( $spec, true ), $logger_context );

					return false;
				}
			}
		}

		return true;
	}

	/**
	 * Validate the action.
	 *
	 * @param object $action The action to validate.
	 * @param string $url    The url of the feed containing the action (for error reporting).
	 *
	 * @return bool The result of the validation.
	 */
	private function validate_action( $action, $url ) {
		$logger         = self::get_logger();
		$logger_context = array( 'source' => $url );

		if ( ! isset( $action->locales ) || ! is_array( $action->locales ) ) {
			$logger->error(
				'Action is invalid because it has empty or missing locales in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $action, true ), $logger_context );

			return false;
		}

		if ( null === SpecRunner::get_action_locale( $action->locales ) ) {
			$logger->error(
				'Action is invalid because the locale could not be retrieved in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $action, true ), $logger_context );

			return false;
		}

		if ( ! isset( $action->name ) ) {
			$logger->error(
				'Action is invalid because the name is missing in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $action, true ), $logger_context );

			return false;
		}

		if ( ! isset( $action->status ) ) {
			$logger->error(
				'Action is invalid because the status is missing in feed',
				$logger_context
			);
			// phpcs:ignore
			$logger->error( print_r( $action, true ), $logger_context );

			return false;
		}

		return true;
	}

	/**
	 * Get data sources.
	 *
	 * @return array
	 */
	public static function get_data_sources() {
		return array(
			WC_Helper::get_woocommerce_com_base_url() . 'wp-json/wccom/inbox-notifications/2.0/notifications.json',
		);
	}
}