JezK
Edit File: abstract-rest-api.php
<?php /** * Abstract REST API Class * * Base class for all REST API implementations (Facebook, TikTok, Pinterest) * * @package PixelYourSite */ namespace PixelYourSite; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. } /** * Abstract class for REST API event handling */ abstract class AbstractRestAPI { /** * REST API namespace * * @var string */ protected $namespace; /** * Server instance * * @var mixed */ protected $server_instance; /** * Get the REST API namespace * * @return string Namespace (e.g., 'pys-facebook/v1') */ abstract protected function getRestNamespace(); /** * Get the server instance for this platform * * @return mixed Server instance (FacebookServer, TikTokServer, etc.) */ abstract protected function getServerInstance(); /** * Get the pixel instance for this platform * * @return mixed Pixel instance (Facebook, TikTok, etc.) */ abstract protected function getPixelInstance(); /** * Check if server API is enabled for this platform * * @return bool */ abstract protected function isServerApiEnabled(); /** * Get the localized script variable name * * @return string Variable name (e.g., 'pysFacebookRest') */ abstract protected function getScriptVariableName(); /** * Constructor */ public function __construct() { $this->namespace = $this->getRestNamespace(); $this->server_instance = $this->getServerInstance(); $this->init(); } /** * Initialize hooks */ protected function init() { add_action( 'rest_api_init', [ $this, 'register_routes' ] ); add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_scripts' ] ); } /** * Register REST API routes */ public function register_routes() { register_rest_route( $this->namespace, '/event', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_event' ], 'permission_callback' => [ $this, 'check_permission' ], 'args' => $this->get_event_args(), ] ); } /** * Handle the event request * * @param \WP_REST_Request $request The request object * @return \WP_REST_Response|\WP_Error */ public function handle_event( $request ) { $this->getPixelInstance()->getLog()->debug( 'handleEvent send to ' . ucfirst( $this->getPlatformName() ) . ' server from REST API' . ' event: ' . $request->get_param( 'event' ) . ' eventID: ' . $request->get_param( 'eventID' ) ); $event = $request->get_param( 'event' ); $data = $request->get_param( 'data' ) ?: []; $ids = $request->get_param( 'ids' ); $event_id = $request->get_param( 'eventID' ); $woo_order = $request->get_param( 'woo_order' ); $edd_order = $request->get_param( 'edd_order' ); $event_slug = $request->get_param( 'event_slug' ); if ( empty( $event ) || empty( $ids ) ) { return new \WP_Error( 'missing_parameters', 'Missing mandatory parameters: event and ids', [ 'status' => 400 ] ); } if ( $event === 'hCR' ) { $event = 'CompleteRegistration'; } $single_event = $this->data_to_single_event( $event, $data, $event_id, $ids, $woo_order, $edd_order, $event_slug ); if($this->server_instance->isQueueEnabled() && PYS()->getOption("queue_use_for_ajax")) { $this->server_instance->sendEventsToQueue( [ $single_event ] ); } else { $this->server_instance->sendEventsNow( [ $single_event ] ); } return new \WP_REST_Response( [ 'success' => true ], 200 ); } /** * Get platform name for logging * * @return string */ protected function getPlatformName() { return str_replace( 'pys-', '', explode( '/', $this->namespace )[0] ); } /** * Check access permissions * * This is a public tracking endpoint: it must be reachable by anonymous * visitors and from aggressively cached HTML, so wp_verify_nonce() and * capability checks are intentionally NOT used here (REST nonces are * session-bound and roll every 12h, which breaks cached pages). * * Defense in Depth applied at this layer: * 1) Same-origin enforcement via Origin / Referer headers blocks the * most common cross-site abuse vector. * 2) Payload data is sanitized in sanitize_data() / get_event_args(). * 3) The `pys_rest_event_permission` filter lets site owners harden * this further (rate limits, bot rules) or relax it on environments * where Origin/Referer is stripped by proxies. * * @param \WP_REST_Request $request The request object. * @return bool */ public function check_permission( $request ) { $origin = $request->get_header( 'origin' ); $referer = $request->get_header( 'referer' ); $site = wp_parse_url( home_url() ); $site_host = isset( $site['host'] ) ? strtolower( $site['host'] ) : ''; $is_same_origin = false; if ( ! empty( $origin ) ) { $origin_parts = wp_parse_url( $origin ); $is_same_origin = ! empty( $site_host ) && isset( $origin_parts['host'] ) && strtolower( $origin_parts['host'] ) === $site_host; } elseif ( ! empty( $referer ) ) { $referer_parts = wp_parse_url( $referer ); $is_same_origin = ! empty( $site_host ) && isset( $referer_parts['host'] ) && strtolower( $referer_parts['host'] ) === $site_host; } // Per-IP rate limiting: max 60 requests per minute per namespace. if ( $is_same_origin ) { $ip = PYS()->get_user_ip(); $key = 'pys_rl_' . md5( $ip . '_' . $this->namespace ); $count = (int) get_transient( $key ); if ( $count >= 60 ) { return new \WP_Error( 'rate_limited', 'Too many requests', [ 'status' => 429 ] ); } set_transient( $key, $count + 1, 60 ); } /** * Filters whether a REST tracking request is allowed. * * Default: true if the request is same-origin (matching Origin or * Referer host), false otherwise. Return true to fully open the * endpoint, or implement custom rate limiting / bot filtering. * * @param bool $allowed Whether the request is allowed. * @param \WP_REST_Request $request The current request object. */ return (bool) apply_filters( 'pys_rest_event_permission', $is_same_origin, $request ); } /** * Get arguments for the event endpoint * * @return array */ protected function get_event_args() { return [ 'event' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => function( $value, $request, $param ) { // 'hCR' is an internal alias for CompleteRegistration — allow it if ( $value === 'hCR' ) { return true; } return (bool) preg_match( '/^[A-Za-z][A-Za-z0-9_]{0,39}$/', sanitize_text_field( $value ) ); }, ], 'data' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => [ $this, 'sanitize_data' ], ], 'ids' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => [ $this, 'sanitize_ids' ], ], 'eventID' => [ 'required' => false, 'type' => 'string', ], 'woo_order' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => [ $this, 'sanitize_order_id' ], ], 'edd_order' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => [ $this, 'sanitize_order_id' ], ], 'event_slug' => [ 'required' => false, 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ], ]; } /** * Sanitize event data * * Recursively sanitizes every scalar value inside the decoded payload * with `sanitize_text_field()` to prevent injection via nested keys/values. * * @param mixed $data Data to sanitize. * @return array */ public function sanitize_data( $data ) { if ( is_string( $data ) ) { $decoded = json_decode( $data, true ); if ( is_array( $decoded ) ) { return map_deep( $decoded, 'sanitize_text_field' ); } } if ( is_array( $data ) ) { return map_deep( $data, 'sanitize_text_field' ); } return []; } /** * Sanitize order ID * * @param mixed $order_id Order ID to sanitize * @return int */ public function sanitize_order_id( $order_id ) { if ( is_string( $order_id ) ) { $order_id = trim( $order_id ); if ( empty( $order_id ) || $order_id === 'null' || $order_id === 'undefined' ) { return 0; } return (int) $order_id; } if ( is_numeric( $order_id ) ) { return (int) $order_id; } return 0; } /** * Sanitize IDs * * @param mixed $ids IDs to sanitize * @return array */ public function sanitize_ids( $ids ) { if ( is_string( $ids ) ) { $decoded = json_decode( $ids, true ); if ( is_array( $decoded ) ) { return array_map( 'sanitize_text_field', $decoded ); } $ids_array = explode( ',', $ids ); return array_map( 'sanitize_text_field', $ids_array ); } if ( is_array( $ids ) ) { return array_map( 'sanitize_text_field', $ids ); } return []; } /** * Convert data to SingleEvent object * * @param string $event_name Event name * @param array $params Event parameters * @param string $event_id Event ID * @param array $ids Pixel IDs * @param int|null $woo_order WooCommerce order ID * @param int|null $edd_order EDD order ID * @param string $event_slug Event slug * @return SingleEvent */ protected function data_to_single_event( $event_name, $params, $event_id, $ids, $woo_order, $edd_order, $event_slug = '' ) { $single_event = new SingleEvent( $event_slug, '' ); if ( ! is_array( $params ) ) { $params = []; } if ( ! is_array( $ids ) ) { $ids = []; } $woo_order_id = is_numeric( $woo_order ) ? (int) $woo_order : 0; $edd_order_id = is_numeric( $edd_order ) ? (int) $edd_order : 0; $payload = [ 'name' => $event_name, 'eventID' => $event_id, 'woo_order' => $woo_order_id, 'edd_order' => $edd_order_id, 'pixelIds' => $ids, ]; $single_event->addParams( $params ); $single_event->addPayload( $payload ); return $single_event; } /** * Enqueue scripts for frontend */ public function enqueue_scripts() { wp_localize_script( 'jquery', $this->getScriptVariableName(), [ 'restApiUrl' => rest_url( $this->namespace . '/event' ), 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, ] ); } /** * Enqueue admin scripts */ public function enqueue_admin_scripts() { wp_localize_script( 'jquery', $this->getScriptVariableName(), [ 'restApiUrl' => rest_url( $this->namespace . '/event' ), 'debug' => defined( 'WP_DEBUG' ) && WP_DEBUG, ] ); } }