version = $version; } /** * Render the affiliate product shortcode. * * @param array $atts Shortcode attributes. * @return string The rendered HTML. */ public function render_affiliate_product( $atts ) { $payload = $this->resolve_product_payload( $atts ); if ( is_string( $payload ) ) { return $payload; } return AffiliateBoxes_Renderer::render( $payload['html'], $payload['css'], $payload['class'] ); } /** * Look up a product's stored payload for the given shortcode/block attributes, syncing * it first if nothing has been synced yet or a privileged refresh was requested. * * @param array $atts Shortcode/block attributes (slug, identifier, class). * @return string|array{html: string, css: string, class: string} Error markup (or an * empty string) on failure, otherwise the raw payload. */ private function resolve_product_payload( $atts ) { $atts = shortcode_atts( array( 'slug' => '', 'identifier' => '', 'class' => 'affiliate-product', ), $atts, 'affiliateboxes_product' ); if ( empty( $atts['slug'] ) && empty( $atts['identifier'] ) ) { return '
' . esc_html__( 'Product slug or identifier is required', 'affiliateboxes' ) . '
'; } $args = array( 'post_type' => 'affiliate_product', 'post_status' => 'publish', 'posts_per_page' => 1, ); if ( ! empty( $atts['identifier'] ) ) { $args['meta_key'] = '_affprod_identifier'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $args['meta_value'] = $atts['identifier']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value } else { $args['meta_key'] = '_affprod_slug'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key $args['meta_value'] = $atts['slug']; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value } // Find the product. $product = get_posts( $args ); if ( empty( $product ) ) { return '' . esc_html__( 'Product not found', 'affiliateboxes' ) . '
'; } $product = $product[0]; $product_id = $product->ID; $product_slug = get_post_meta( $product_id, '_affprod_slug', true ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended $force_refresh = isset( $_GET['refresh_product'] ) && $_GET['refresh_product'] === $product_slug && current_user_can( 'manage_options' ); // Get the saved payload. $html = (string) get_post_meta( $product_id, '_affprod_html', true ); $css = (string) get_post_meta( $product_id, '_affprod_css', true ); // A product synced by a version before 1.1.0 has no markup stored, only the retired // script payload, so it re-syncs here and comes back in the sanitized format. $is_empty = '' === trim( $html ); // If the payload is empty, or a refresh is requested by someone allowed to ask. if ( $is_empty || $force_refresh ) { try { AffiliateBoxes_Utils::upsert_product_by_slug( $product_slug ); if ( function_exists( 'wp_cache_delete' ) ) { wp_cache_delete( $product_id, 'post_meta' ); } $html = (string) get_post_meta( $product_id, '_affprod_html', true ); $css = (string) get_post_meta( $product_id, '_affprod_css', true ); $is_empty = '' === trim( $html ); } catch ( Exception $e ) { if ( $is_empty ) { return '' . esc_html__( 'Error loading product content: ', 'affiliateboxes' ) . esc_html( $e->getMessage() ) . '
'; } } } if ( $is_empty ) { return ''; } // Verify SRI hash to detect post-storage content tampering. $sri_hash = get_post_meta( $product_id, '_affprod_sri_hash', true ); if ( ! empty( $sri_hash ) && ! AffiliateBoxes_Utils::verify_sri_hash( $html . $css, $sri_hash ) ) { return '' . esc_html__( 'Product content integrity check failed. Please re-sync this product.', 'affiliateboxes' ) . '
'; } return array( 'html' => $html, 'css' => $css, 'class' => $atts['class'], ); } /** * Register the Gutenberg blocks. */ public function register_blocks() { if ( ! function_exists( 'register_block_type' ) ) { return; } // Register REST API routes. add_action( 'rest_api_init', array( $this, 'register_preview_route' ) ); add_action( 'rest_api_init', array( $this, 'register_refresh_route' ) ); // Register block editor script. wp_register_script( 'affiliateboxes-block-editor', AFFILIATEBOXES_PLUGIN_URL . 'admin/js/block-editor.js', array( 'wp-blocks', 'wp-i18n', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-data', 'wp-api-fetch', 'wp-url', 'wp-hooks', 'wp-date', 'choices-js', ), $this->version, true ); /* * Register the editor styles here rather than at enqueue time so both documents can * pull them in by handle. Since WordPress 6.3 the block canvas is a separate iframed * document: enqueue_block_editor_assets reaches only the editor chrome, which is why * the picker rendered correctly in the sidebar and unstyled in the block placeholder. */ wp_register_style( 'choices-css', AFFILIATEBOXES_PLUGIN_URL . 'admin/css/choices.min.css', array(), $this->version ); wp_register_style( 'affiliateboxes-editor', AFFILIATEBOXES_PLUGIN_URL . 'admin/css/affiliateboxes-block-editor.css', array( 'choices-css' ), $this->version ); // Register block. register_block_type( 'affiliateboxes/affiliate-product', array( 'api_version' => 2, 'editor_script' => 'affiliateboxes-block-editor', 'render_callback' => array( $this, 'render_block' ), 'attributes' => array( 'slug' => array( 'type' => 'string', 'default' => '', ), 'className' => array( 'type' => 'string', 'default' => '', ), ), ) ); } /** * Enqueue editor styles inside the block editor's iframed canvas. * * The enqueue_block_assets hook reaches inside the iframe, where the block placeholder and * the rendered block content live. The sidebar keeps getting these from * enqueue_block_editor_assets, which only reaches the surrounding editor chrome. */ public function enqueue_block_canvas_assets() { // This hook also fires on the front end, where neither stylesheet belongs. if ( ! is_admin() ) { return; } wp_enqueue_style( 'affiliateboxes-editor' ); } /** * Render the Gutenberg block. * * @param array $attributes Block attributes. * @return string The rendered HTML. */ public function render_block( $attributes ) { if ( empty( $attributes['slug'] ) ) { return '' . esc_html__( 'Please select a product to display.', 'affiliateboxes' ) . '
'; } $class = 'wp-block-affiliateboxes-affiliate-product'; if ( ! empty( $attributes['className'] ) ) { $class .= ' ' . esc_attr( $attributes['className'] ); } return $this->render_affiliate_product( array( 'slug' => $attributes['slug'], 'class' => $class, ) ); } /** * Register public webhook REST routes for connect and receive. */ public function register_webhook_routes() { register_rest_route( 'affiliateboxes/v1', '/connect', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_connect' ), 'permission_callback' => '__return_true', ) ); register_rest_route( 'affiliateboxes/v1', '/receive', array( 'methods' => 'POST', 'callback' => array( $this, 'handle_receive' ), 'permission_callback' => '__return_true', ) ); } /** * Apply rate limiting based on client IP. * * @return WP_Error|null WP_Error if rate limited, null otherwise. */ private function check_rate_limit() { $client_ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '0.0.0.0'; $rate_key = 'affiliateboxes_rl_' . md5( $client_ip ); $rate_count = (int) get_transient( $rate_key ); if ( $rate_count >= 120 ) { return new WP_Error( 'rate_limited', 'Rate limit exceeded. Try again later.', array( 'status' => 429 ) ); } set_transient( $rate_key, $rate_count + 1, MINUTE_IN_SECONDS ); return null; } /** * Persist a rolling debug trail for inbound receive webhook requests. * * @param string $stage Lifecycle stage for this request. * @param array $context Sanitized context payload. */ private function log_receive_debug_event( $stage, $context = array() ) { $context = is_array( $context ) ? $context : array(); $event = array( 'time' => current_time( 'mysql' ), 'stage' => sanitize_text_field( (string) $stage ), 'ip' => isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '', 'method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '', 'uri' => isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '', 'context' => $context, ); $events = get_option( 'affiliateboxes_receive_debug_log', array() ); if ( ! is_array( $events ) ) { $events = array(); } $events[] = $event; if ( count( $events ) > 50 ) { $events = array_slice( $events, -50 ); } update_option( 'affiliateboxes_receive_debug_log', $events, false ); AffiliateBoxes_Logger::log( sprintf( '[receive:%s] %s', $event['stage'], wp_json_encode( $context ) ) ); } /** * Mask secrets in debug output while preserving enough detail to correlate calls. * * @param string $token Secret value. * @return string */ private function mask_token( $token ) { $token = (string) $token; $len = strlen( $token ); if ( 0 === $len ) { return ''; } if ( $len <= 8 ) { return str_repeat( '*', $len ); } return substr( $token, 0, 4 ) . '...' . substr( $token, -4 ); } /** * Build a short stable hash for comparing sensitive values without logging them raw. * * @param string $value Value to hash. * @return string */ private function short_hash( $value ) { return substr( hash( 'sha256', (string) $value ), 0, 16 ); } /** * Handle the connect webhook (replaces connect.php). * * Receives an API key and one-time secret from the remote app and saves the key. * * @param WP_REST_Request $request The REST request object. * @return WP_REST_Response|WP_Error */ public function handle_connect( $request ) { $rate_error = $this->check_rate_limit(); if ( null !== $rate_error ) { return $rate_error; } $api_key = $request->get_param( 'api_key' ); $secret = $request->get_param( 'secret' ); if ( empty( $api_key ) ) { return new WP_Error( 'missing_api_key', 'Missing api_key field', array( 'status' => 400 ) ); } if ( empty( $secret ) ) { return new WP_Error( 'missing_secret', 'Missing secret', array( 'status' => 403 ) ); } $api_key = sanitize_text_field( $api_key ); $secret = sanitize_text_field( $secret ); $stored_secret = get_transient( 'affiliateboxes_connection_secret' ); if ( ! $stored_secret || ! hash_equals( $stored_secret, $secret ) ) { return new WP_Error( 'invalid_secret', 'Invalid or expired secret', array( 'status' => 403 ) ); } // Delete the secret so it can't be used again. delete_transient( 'affiliateboxes_connection_secret' ); // Save API key. $settings = get_option( 'affiliateboxes_settings', array() ); $settings['api_key'] = $api_key; $updated = update_option( 'affiliateboxes_settings', $settings ); $current_settings = get_option( 'affiliateboxes_settings' ); if ( $updated || ( isset( $current_settings['api_key'] ) && $current_settings['api_key'] === $api_key ) ) { return new WP_REST_Response( array( 'status' => 'success' ), 200 ); } return new WP_Error( 'save_failed', 'Failed to save API key', array( 'status' => 500 ) ); } /** * Handle the receive webhook (replaces receive.php). * * Receives product updates/deletes from the remote API and schedules processing. * * @param WP_REST_Request $request The REST request object. * @return WP_REST_Response|WP_Error */ public function handle_receive( $request ) { $raw_body = (string) $request->get_body(); $data = $request->get_json_params(); $this->log_receive_debug_event( 'request_received', array( 'content_type' => (string) $request->get_header( 'content-type' ), 'body_length' => strlen( $raw_body ), 'body_preview' => substr( $raw_body, 0, 200 ), 'json_is_array' => is_array( $data ), 'json_keys' => is_array( $data ) ? array_slice( array_keys( $data ), 0, 20 ) : array(), 'user_agent' => isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '', ) ); $rate_error = $this->check_rate_limit(); if ( null !== $rate_error ) { $this->log_receive_debug_event( 'rejected_rate_limited', array( 'status' => 429, ) ); return $rate_error; } if ( ! is_array( $data ) ) { $this->log_receive_debug_event( 'rejected_invalid_json', array( 'status' => 400, ) ); return new WP_Error( 'invalid_json', 'Invalid JSON payload', array( 'status' => 400 ) ); } // Verify API key. if ( empty( $data['api_key'] ) ) { $this->log_receive_debug_event( 'rejected_missing_api_key', array( 'status' => 400, ) ); return new WP_Error( 'missing_api_key', 'Missing required field: api_key', array( 'status' => 400 ) ); } $settings = get_option( 'affiliateboxes_settings', array() ); $valid_api_key = isset( $settings['api_key'] ) ? $settings['api_key'] : ''; if ( empty( $valid_api_key ) || ! hash_equals( $valid_api_key, $data['api_key'] ) ) { $this->log_receive_debug_event( 'rejected_invalid_api_key', array( 'status' => 401, 'incoming_key_mask' => $this->mask_token( (string) $data['api_key'] ), 'incoming_key_hash' => $this->short_hash( (string) $data['api_key'] ), 'stored_key_exists' => ! empty( $valid_api_key ), 'stored_key_hash' => ! empty( $valid_api_key ) ? $this->short_hash( (string) $valid_api_key ) : '', ) ); return new WP_Error( 'invalid_api_key', 'Invalid API key', array( 'status' => 401 ) ); } // Normalize input - support both single product and multiple products. $products = array(); if ( isset( $data['products'] ) && is_array( $data['products'] ) ) { $products = $data['products']; } elseif ( isset( $data['boxes'] ) && is_array( $data['boxes'] ) ) { // Legacy format: "boxes" array (backwards compatibility). $products = $data['boxes']; } elseif ( isset( $data['slug'] ) ) { // Legacy format: single product. $products[] = array( 'slug' => $data['slug'], 'name' => isset( $data['name'] ) ? $data['name'] : '', 'type' => isset( $data['type'] ) ? $data['type'] : 'box', 'identifier' => isset( $data['identifier'] ) ? $data['identifier'] : '', 'deleted' => isset( $data['deleted'] ) ? $data['deleted'] : false, ); } else { $this->log_receive_debug_event( 'rejected_missing_products', array( 'status' => 400, ) ); return new WP_Error( 'missing_products', 'Missing required field: products or slug', array( 'status' => 400 ) ); } if ( empty( $products ) ) { $this->log_receive_debug_event( 'rejected_empty_products', array( 'status' => 400, ) ); return new WP_Error( 'no_products', 'No products provided', array( 'status' => 400 ) ); } $this->log_receive_debug_event( 'products_normalized', array( 'count' => count( $products ), 'slugs' => array_values( array_slice( array_filter( array_map( function ( $product ) { return isset( $product['slug'] ) ? sanitize_text_field( (string) $product['slug'] ) : ''; }, $products ) ), 0, 10 ) ), 'from_format' => isset( $data['products'] ) ? 'products' : ( isset( $data['boxes'] ) ? 'boxes' : 'single_slug' ), ) ); // Process each product. $scheduled = array(); $processing_errors = array(); foreach ( $products as $product ) { if ( empty( $product['slug'] ) ) { $processing_errors[] = 'Product missing required field: slug'; continue; } $product_slug = sanitize_text_field( $product['slug'] ); $product_title = isset( $product['name'] ) ? sanitize_text_field( $product['name'] ) : ''; $product_type = isset( $product['type'] ) ? sanitize_text_field( $product['type'] ) : 'box'; // Infer type from slug if default 'box' is used. if ( 'box' === $product_type && 0 === strpos( $product_slug, 'table_' ) ) { $product_type = 'table'; } $product_identifier = isset( $product['identifier'] ) ? sanitize_text_field( $product['identifier'] ) : ''; $deleted = isset( $product['deleted'] ) ? (bool) $product['deleted'] : false; try { if ( $deleted ) { $action_id = AffiliateBoxes_Action_Scheduler::schedule_product_delete( $product_slug ); $scheduled[] = array( 'slug' => $product_slug, 'action' => 'delete', 'action_id' => $action_id, ); } else { $action_id = AffiliateBoxes_Action_Scheduler::schedule_product_update( $product_slug, $product_title, $product_type, $product_identifier ); $scheduled[] = array( 'slug' => $product_slug, 'action' => 'update', 'action_id' => $action_id, ); } } catch ( Exception $e ) { $processing_errors[] = sprintf( 'Failed to schedule product %s: %s', $product_slug, $e->getMessage() ); } } // Force run pending actions immediately. if ( ! empty( $scheduled ) ) { AffiliateBoxes_Action_Scheduler::force_run_queue(); $this->log_receive_debug_event( 'scheduled_and_forced', array( 'scheduled_count' => count( $scheduled ), 'error_count' => count( $processing_errors ), 'actions' => array_slice( $scheduled, 0, 10 ), 'queue_status' => AffiliateBoxes_Action_Scheduler::get_overall_queue_status(), ) ); } // Prepare response. $response = array( 'success' => true, 'message' => sprintf( 'Scheduled %d product(s) successfully', count( $scheduled ) ), 'scheduled' => $scheduled, 'scheduled_at' => current_time( 'mysql' ), ); if ( ! empty( $processing_errors ) ) { $response['errors'] = $processing_errors; $response['has_errors'] = true; } if ( empty( $scheduled ) ) { $this->log_receive_debug_event( 'failed_no_scheduled_actions', array( 'status' => 500, 'error_count' => count( $processing_errors ), 'first_errors' => array_slice( $processing_errors, 0, 5 ), ) ); return new WP_Error( 'schedule_failed', 'Failed to schedule any products', array( 'status' => 500 ) ); } $this->log_receive_debug_event( 'completed', array( 'status' => 200, 'scheduled_count' => count( $scheduled ), 'error_count' => count( $processing_errors ), ) ); return new WP_REST_Response( $response, 200 ); } /** * Register REST API route for preview. */ public function register_preview_route() { register_rest_route( 'affiliateboxes/v1', '/preview', array( 'methods' => 'GET', 'callback' => array( $this, 'get_product_preview' ), 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'slug' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * Register REST API route to force refresh a product's stored content. */ public function register_refresh_route() { register_rest_route( 'affiliateboxes/v1', '/refresh', array( 'methods' => 'POST', 'callback' => array( $this, 'refresh_product' ), 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'slug' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } /** * Refresh a product by slug using utility, return updated preview. * * @param WP_REST_Request $request The REST request object. * @return WP_Error|array The updated preview or error. */ public function refresh_product( $request ) { $slug = $request->get_param( 'slug' ); if ( empty( $slug ) ) { return new WP_Error( 'no_slug', __( 'No slug provided', 'affiliateboxes' ), array( 'status' => 400 ) ); } try { $post_id = AffiliateBoxes_Utils::upsert_product_by_slug( $slug ); } catch ( Exception $e ) { return new WP_Error( 'refresh_failed', $e->getMessage(), array( 'status' => 500 ) ); } $preview = $this->build_editor_preview( $slug ); $last_updated = get_post_meta( $post_id, '_affprod_last_synced', true ); return array( 'success' => true, 'post_id' => $post_id, 'html' => $preview['html'], 'css' => $preview['css'], 'slug' => $slug, 'last_updated' => $last_updated, ); } /** * Get product preview for REST API. * * @param WP_REST_Request $request The REST request object. * @return WP_Error|array The preview or error. */ public function get_product_preview( $request ) { $slug = $request->get_param( 'slug' ); if ( empty( $slug ) ) { return new WP_Error( 'no_slug', __( 'No slug provided', 'affiliateboxes' ), array( 'status' => 400 ) ); } $preview = $this->build_editor_preview( $slug ); // include last updated meta if present. $post = get_posts( array( 'post_type' => 'affiliate_product', 'meta_key' => '_affprod_slug', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $slug, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value 'post_status' => 'any', 'posts_per_page' => 1, ) ); $last_updated = ''; if ( ! empty( $post ) ) { $last_updated = get_post_meta( $post[0]->ID, '_affprod_last_synced', true ); } return array( 'html' => $preview['html'], 'css' => $preview['css'], 'slug' => $slug, 'last_updated' => $last_updated, ); } /** * Build a block editor preview fragment for a product slug. * * The preview panel injects this into a DOM node on an admin page that has already * finished loading, so the stylesheet is returned separately rather than baked into the * markup - see AffiliateBoxes_Renderer::render_preview(). * * @param string $slug Product slug. * @return array{html: string, css: string} */ private function build_editor_preview( $slug ) { $payload = $this->resolve_product_payload( array( 'slug' => $slug, 'class' => 'affiliateboxes-preview', ) ); if ( is_string( $payload ) ) { return array( 'html' => $payload, 'css' => '', ); } return AffiliateBoxes_Renderer::render_preview( $payload['html'], $payload['css'], $payload['class'] ); } }