Files
2026-04-28 15:13:50 +02:00

396 lines
11 KiB
PHP

<?php
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Abstract WPPFM_Async_Request class derived from https://github.com/A5hleyRich/wp-background-processing.
*
* @package WPPFM-Background-Processing
* @abstract
*/
abstract class WPPFM_Async_Request {
/**
* Prefix
*
* @var string
*/
protected $prefix = 'wppfm';
/**
* Action
*
* @var string
*/
protected $action = 'async_request';
/**
* Identifier
*
* @var mixed
*/
protected $identifier;
/**
* Data
*
* @var array
*/
protected $data = array();
/**
* File Path
*
* @var string
*/
protected $file_path = '';
/**
* Contains the general data of the feed
*
* @var string
*/
protected $feed_data = '';
/**
* Contains general pre feed production data
*
* @var array
*/
protected $pre_data;
/**
* Contains the channels category title and description title
*
* @var array
*/
protected $channel_details;
/**
* Contains the relations between the WooCommerce and channel fields
*
* @var array
*/
protected $relations_table;
/**
* Initiate new async request
*/
public function __construct() {
$this->identifier = $this->prefix . '_' . $this->action;
add_action( 'wp_ajax_' . $this->identifier, array( $this, 'maybe_handle' ) );
add_action( 'wp_ajax_nopriv_' . $this->identifier, array( $this, 'maybe_handle' ) );
}
/**
* Set data used during the request
*
* @param array $data Data.
*
* @return $this
*/
public function data( $data ) {
$this->data = $data;
return $this;
}
/**
* Dispatch the async request to trigger the feed process with a remote post.
*
* @param string $feed_id
*/
public function dispatch( $feed_id ) {
if ( get_option( 'wppfm_disabled_background_mode', 'false' ) === 'false' ) {
// Clean up any existing feed data before starting
delete_site_option('wppfm_feed_data');
// Set the feed_id in the data array
$this->data['feed_id'] = $feed_id;
$url = add_query_arg( $this->get_query_args( $feed_id ), $this->get_query_url() );
$args = $this->get_post_args();
do_action( 'wppfm_register_remote_post_args', $feed_id, $url, $args );
// Log dispatch intent (high-signal: helps debug loopback/cron environments).
do_action(
'wppfm_feed_generation_message',
$feed_id,
sprintf(
'Dispatching async request via wp_remote_post (blocking=%s, timeout=%ds, url=%s).',
isset( $args['blocking'] ) && false === $args['blocking'] ? 'false' : 'true',
isset( $args['timeout'] ) ? intval( $args['timeout'] ) : 0,
$url
)
);
$response = wp_remote_post( esc_url_raw( $url ), $args );
if ( is_wp_error( $response ) ) {
do_action( 'wppfm_feed_generation_message', $feed_id, 'wp_remote_post failed: ' . $response->get_error_message(), 'ERROR' );
do_action( 'wppfm_dispatch_failed', $feed_id, $response, $url, $args );
$this->flag_pending_dispatch( $feed_id );
$this->schedule_health_check_fallback();
} else {
// Even with non-blocking requests, WordPress may provide a response structure; log what we can.
$code = function_exists( 'wp_remote_retrieve_response_code' ) ? wp_remote_retrieve_response_code( $response ) : 0;
$message = function_exists( 'wp_remote_retrieve_response_message' ) ? wp_remote_retrieve_response_message( $response ) : '';
do_action(
'wppfm_feed_generation_message',
$feed_id,
sprintf(
'wp_remote_post returned (code=%s, message=%s, blocking=%s).',
$code ? strval( $code ) : 'n/a',
$message ? $message : 'n/a',
isset( $args['blocking'] ) && false === $args['blocking'] ? 'false' : 'true'
)
);
}
do_action( 'wppfm_wp_remote_post_response', $feed_id, $response );
} else {
$this->maybe_handle();
}
}
/**
* Get query args
*
* @param int $feed_id Feed ID.
*
* @return array
*/
protected function get_query_args( $feed_id ) {
$nonce_key = 'wppfm_feed_generation_process';
$nonce = wp_create_nonce($nonce_key);
$nonce_data = array(
'created' => time(),
'feed_id' => $feed_id,
'identifier' => $this->identifier,
'request_id' => uniqid('req_', true),
'nonce_key' => $nonce_key
);
set_transient('wppfm_async_nonce_' . $nonce, $nonce_data, HOUR_IN_SECONDS);
// Log nonce issuance so we can correlate dispatch requests to later nonce/verification failures.
do_action(
'wppfm_feed_generation_message',
$feed_id,
sprintf(
'Issued async nonce (request_id=%s, created=%d, identifier=%s).',
$nonce_data['request_id'],
intval( $nonce_data['created'] ),
$nonce_data['identifier']
)
);
return array(
'action' => $this->identifier,
'nonce' => $nonce,
'feed_id' => $feed_id,
);
}
/**
* Get query URL
*
* @return string
*/
protected function get_query_url() {
return admin_url( 'admin-ajax.php' );
}
/**
* Get post args.
*
* @return array
*/
protected function get_post_args() {
// Build headers for maximum robustness
$headers = array(
// Crucial for robustness: Disable 'Expect: 100-continue' which causes issues with some servers/proxies.
'Expect' => '',
// Identify the request as internal to WordPress. Might be useful for debugging or specific rules.
'X-WordPress-Internal-Request' => 'true',
// Standard header for AJAX requests. Some security rules might look for this on admin-ajax.php.
'X-Requested-With' => 'XMLHttpRequest',
// Explicitly set the Host header. Check if $_SERVER['HTTP_HOST'] is set (e.g., might not be in CLI).
'Host' => isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '',
// Prevent intermediate caches from interfering.
'Cache-Control' => 'no-cache, no-store, must-revalidate',
'Pragma' => 'no-cache', // HTTP/1.0 backwards compatibility for caching
'Expires' => '0', // Proxies
// Add accept header to indicate preference for response types (optional but good practice)
'Accept' => 'application/json, text/javascript, */*; q=0.01'
);
// Add standard headers: Forward the original IP if available. Useful for logging/debugging on the server side.
if ( isset( $_SERVER['REMOTE_ADDR'] ) ) {
$headers['X-Forwarded-For'] = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
}
// Get WordPress authentication cookies, potentially needed by admin-ajax.php. Check if $_COOKIE is available.
$cookies = array();
if ( ! empty( $_COOKIE ) ) {
foreach ( $_COOKIE as $name => $value ) {
// Capture standard WordPress login and test cookies.
if ( strpos( $name, 'wordpress_' ) === 0 || strpos( $name, 'wp-' ) === 0 ) {
// Do not sanitize cookie values as that may change them; unslash if needed.
$cookies[ $name ] = is_string( $value ) ? wp_unslash( $value ) : $value; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
}
}
}
return array(
'timeout' => 30, // Reasonable timeout for an async trigger.
'blocking' => false, // Set to false for a true non-blocking async trigger.
'headers' => $headers,
'cookies' => $cookies,
'sslverify' => false, // Often needed for loopback requests.
'body' => null // Explicitly set body to null if no data is being sent.
);
}
/**
* Maybe handle
*
* Check for correct nonce and pass to handler.
*/
public function maybe_handle() {
session_write_close();
$feed_id = isset( $_POST['feed_id'] ) ? intval( wp_unslash( $_POST['feed_id'] ) ) : 0;
$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
// Clean up old transients
$this->cleanup_old_transients();
// Get and verify nonce data
$nonce_data = get_transient('wppfm_async_nonce_' . $nonce);
if ( ! $nonce_data || ! wp_verify_nonce( $nonce, $nonce_data['nonce_key'] ) ) {
do_action(
'wppfm_feed_generation_message',
$feed_id ? $feed_id : 'unknown',
'Async request rejected: invalid or expired nonce (missing nonce_data or wp_verify_nonce failed).',
'ERROR'
);
wp_send_json_error('Invalid or expired nonce');
return;
}
// Check nonce age and feed ID
if ( time() - $nonce_data['created'] > HOUR_IN_SECONDS ||
$nonce_data['feed_id'] !== $feed_id ||
$nonce_data['identifier'] !== $this->identifier ) {
do_action(
'wppfm_feed_generation_message',
$feed_id ? $feed_id : 'unknown',
sprintf(
'Async request rejected: request validation failed (age=%ds, expected_feed_id=%s, got_feed_id=%s, expected_identifier=%s, got_identifier=%s).',
intval( time() - intval( $nonce_data['created'] ) ),
isset( $nonce_data['feed_id'] ) ? strval( $nonce_data['feed_id'] ) : 'n/a',
strval( $feed_id ),
isset( $nonce_data['identifier'] ) ? strval( $nonce_data['identifier'] ) : 'n/a',
strval( $this->identifier )
),
'ERROR'
);
wp_send_json_error('Invalid request');
return;
}
// Delete the nonce after verification
delete_transient('wppfm_async_nonce_' . $nonce);
do_action(
'wppfm_feed_generation_message',
$feed_id,
sprintf(
'Async request accepted (request_id=%s). Entering background handler.',
isset( $nonce_data['request_id'] ) ? strval( $nonce_data['request_id'] ) : 'n/a'
)
);
// Process the request
$this->handle();
wp_die();
}
/**
* Clean up old transients to prevent accumulation
*/
private function cleanup_old_transients() {
global $wpdb;
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Cleanup operation that needs to query directly. Caching transient cleanup doesn't make sense.
$wpdb->query(
$wpdb->prepare(
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s AND option_value < %d",
'_transient_wppfm_async_nonce_%',
time() - HOUR_IN_SECONDS
)
);
}
/**
* Handle
*
* Override this method to perform any actions required
* during the async request.
*/
abstract protected function handle();
/**
* Store a marker so the cron health check can escalate a failed dispatch.
*
* @param int $feed_id Feed identifier.
*
* @since 3.18.0
*/
private function flag_pending_dispatch( $feed_id ) {
if ( ! $feed_id ) {
return;
}
$ttl = max( MINUTE_IN_SECONDS, apply_filters( 'wppfm_pending_dispatch_ttl', 3 * MINUTE_IN_SECONDS ) );
$payload = array(
'feed_id' => $feed_id,
'created' => time(),
);
set_site_transient( 'wppfm_pending_dispatch_' . $feed_id, $payload, $ttl );
$pending = get_site_option( 'wppfm_pending_dispatch_feeds', array() );
if ( ! is_array( $pending ) ) {
$pending = array();
}
$pending[ $feed_id ] = $payload['created'];
update_site_option( 'wppfm_pending_dispatch_feeds', $pending );
}
/**
* Schedule a background-process health check soon after a failed dispatch.
*
* @since 3.18.0
*/
private function schedule_health_check_fallback() {
$hook = 'wppfm_feed_generation_process_cron';
$delay = max( 5, intval( apply_filters( 'wppfm_pending_dispatch_healthcheck_delay', 30 ) ) );
$timestamp = time() + $delay;
// Allow multiple single events; they coalesce if same timestamp exists.
wp_schedule_single_event( $timestamp, $hook );
}
}