'POST', 'callback' => array( $this, 'submit' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'form' => array( 'type' => 'string', 'default' => '', ), 'fields' => array( 'type' => 'array', 'required' => true, ), 'embed' => array( 'type' => 'string', 'default' => '', 'enum' => array_merge( array( '' ), array_keys( Forms\Registry::EMBEDS ) ), ), 'id' => array( 'type' => 'string', 'default' => '', ), 'found' => array( 'type' => 'string', 'default' => '', ), 'key' => array( 'type' => 'string', 'default' => '', ), 'title' => array( 'type' => 'string', 'default' => '', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/found', array( 'methods' => 'POST', 'callback' => array( $this, 'found' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'selector' => array( 'type' => 'string', 'required' => true, ), 'form' => array( 'type' => 'string', 'default' => '', ), 'newsletter' => array( 'type' => 'boolean', 'default' => false, ), 'title' => array( 'type' => 'string', 'default' => '', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/order', array( 'methods' => 'POST', 'callback' => array( $this, 'order' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'number' => array( 'type' => 'string', 'required' => true, ), 'value' => array( 'type' => 'number', 'required' => true, ), 'currency' => array( 'type' => 'string', 'default' => '', ), 'items' => array( 'type' => 'array', 'default' => array(), ), 'key' => array( 'type' => 'string', 'default' => '', ), 'customer' => array( 'type' => 'object', 'default' => array(), ), 'fields' => array( 'type' => 'array', 'default' => array(), ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/contact', array( 'methods' => 'POST', 'callback' => array( $this, 'contact' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'fields' => array( 'type' => 'array', 'required' => true, ), 'key' => array( 'type' => 'string', 'required' => true, ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/visit', array( 'methods' => 'POST', 'callback' => array( $this, 'visit' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'touch' => array( 'type' => 'object', 'required' => true, ), 'tz' => array( 'type' => 'string', 'default' => '', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/live', array( 'methods' => 'POST', 'callback' => array( $this, 'live' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'id' => array( 'type' => 'string', 'required' => true, ), 'page' => array( 'type' => 'string', 'default' => '', ), 'title' => array( 'type' => 'string', 'default' => '', ), 'form' => array( 'type' => 'string', 'default' => '', ), 'state' => array( 'type' => 'string', 'default' => '', ), 'nudge' => array( 'type' => 'string', 'default' => '', ), 'nudge_state' => array( 'type' => 'string', 'default' => '', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/form', array( 'methods' => 'POST', 'callback' => array( $this, 'form' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'form' => array( 'type' => 'string', 'required' => true, ), 'sent' => array( 'type' => 'boolean', 'default' => false, ), 'field' => array( 'type' => 'string', 'default' => '', ), 'source' => array( 'type' => 'string', 'default' => '', ), ), ) ); register_rest_route( self::NAMESPACE_V1, '/click', array( 'methods' => 'POST', 'callback' => array( $this, 'click' ), 'permission_callback' => array( $this, 'from_site' ), 'args' => array( 'type' => array( 'type' => 'string', 'required' => true, 'enum' => array_keys( Events::types() ), ), 'target' => array( 'type' => 'string', 'default' => '', ), 'page' => array( 'type' => 'string', 'default' => '', ), ), ) ); } /** * Counts a form that was sent or left unfinished. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function form( \WP_REST_Request $request ) { if ( ! Settings::get( 'form_stats' ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } if ( Visits::is_bot( (string) $request->get_header( 'user_agent' ) ) ) { return new \WP_REST_Response( array( 'ok' => true ), 200 ); } $denied = $this->deny( $request, 'form', 60 ); if ( $denied ) { return $denied; } $recorded = Form_Stats::record( sanitize_text_field( (string) $request->get_param( 'form' ) ), (bool) $request->get_param( 'sent' ), sanitize_text_field( (string) $request->get_param( 'field' ) ), sanitize_text_field( (string) $request->get_param( 'source' ) ) ); return new \WP_REST_Response( array( 'ok' => $recorded ), $recorded ? 201 : 200 ); } /** * Stores a form submission. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function submit( \WP_REST_Request $request ) { $denied = $this->deny( $request, 'submit', 10 ); if ( $denied ) { return $denied; } $fields = array(); foreach ( array_slice( (array) $request->get_param( 'fields' ), 0, self::MAX_FIELDS ) as $field ) { if ( ! is_array( $field ) ) { continue; } $value = $field['value'] ?? ''; $fields[] = array( 'label' => sanitize_text_field( (string) ( $field['label'] ?? '' ) ), 'name' => sanitize_text_field( (string) ( $field['name'] ?? '' ) ), 'type' => sanitize_key( (string) ( $field['type'] ?? '' ) ), 'value' => is_array( $value ) ? array_map( 'sanitize_textarea_field', array_map( 'strval', array_filter( $value, 'is_scalar' ) ) ) : sanitize_textarea_field( is_scalar( $value ) ? (string) $value : '' ), ); } if ( ! $fields || $this->seen_recently( 'submit', wp_json_encode( $fields ), 2 * MINUTE_IN_SECONDS ) ) { return new \WP_REST_Response( array( 'ok' => true ), 200 ); } $form = mb_substr( sanitize_text_field( (string) $request->get_param( 'form' ) ), 0, 191 ); $embed = sanitize_key( (string) $request->get_param( 'embed' ) ); $found = (string) $request->get_param( 'found' ); // A form the scan did not find is only saved when it was switched on by itself or on // the Forms screen; a newsletter sign-up never is. if ( '' !== $found && ! Form_Discovery::record( $found, $form, (string) $request->get_header( 'referer' ), (string) $request->get_param( 'title' ), self::is_newsletter( $fields ) ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } if ( isset( Forms\Registry::EMBEDS[ $embed ] ) ) { $lead_id = Capture::submission( $embed, mb_substr( preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $request->get_param( 'id' ) ), 0, 64 ), $form, $fields ); } else { $lead_id = Capture::submission( 'html', sanitize_title( $form ), $form, $fields ); } Orders::remember( (string) $request->get_param( 'key' ), $lead_id ); return new \WP_REST_Response( array( 'ok' => true ), 201 ); } /** * Notes a form a visitor sent that is not saved as a lead, so it is listed on the Forms * screen. Only its selector and name arrive, nothing that was typed. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function found( \WP_REST_Request $request ) { $denied = $this->deny( $request, 'found', 10 ); if ( $denied ) { return $denied; } $selector = (string) $request->get_param( 'selector' ); if ( ! Form_Discovery::is_valid( $selector ) ) { return new \WP_Error( 'dealroot_invalid', 'Invalid form', array( 'status' => 400 ) ); } Form_Discovery::record( $selector, (string) $request->get_param( 'form' ), (string) $request->get_header( 'referer' ), (string) $request->get_param( 'title' ), (bool) $request->get_param( 'newsletter' ) ); return new \WP_REST_Response( array( 'ok' => true ), 201 ); } /** * Saves an order the shop reported as a purchase in the browser. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function order( \WP_REST_Request $request ) { if ( ! Orders::enabled() ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } $denied = $this->deny( $request, 'order', 5 ); if ( $denied ) { return $denied; } $items = array(); foreach ( array_slice( (array) $request->get_param( 'items' ), 0, 20 ) as $item ) { if ( is_scalar( $item ) ) { $items[] = mb_substr( sanitize_text_field( (string) $item ), 0, 200 ); } } $customer = (array) $request->get_param( 'customer' ); $buyer = array_merge( Capture::contact_of( self::fields_of( $request ) ), array_filter( array( 'name' => sanitize_text_field( (string) ( $customer['name'] ?? '' ) ), 'email' => sanitize_email( (string) ( $customer['email'] ?? '' ) ), 'phone' => sanitize_text_field( (string) ( $customer['phone'] ?? '' ) ), ), 'strlen' ) ); $lead_id = Orders::save( sanitize_text_field( (string) $request->get_param( 'number' ) ), (float) $request->get_param( 'value' ), sanitize_text_field( (string) $request->get_param( 'currency' ) ), $items, sanitize_key( (string) $request->get_param( 'key' ) ), $buyer ); return new \WP_REST_Response( array( 'ok' => $lead_id > 0 ), $lead_id ? 201 : 200 ); } /** * The filled-in checkout a visitor leaves for the payment, e.g. at PayPal: the name, email * address and phone number are kept for a day for the order that may follow, never as a lead. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function contact( \WP_REST_Request $request ) { if ( ! Orders::enabled() ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } $denied = $this->deny( $request, 'contact', 10 ); if ( $denied ) { return $denied; } Orders::checkout_contact( sanitize_key( (string) $request->get_param( 'key' ) ), Capture::contact_of( self::fields_of( $request ) ) ); return new \WP_REST_Response( array( 'ok' => true ), 200 ); } /** * Sanitized fields of a request. * * @param \WP_REST_Request $request Request. * @return array */ private static function fields_of( \WP_REST_Request $request ) { $fields = array(); foreach ( array_slice( (array) $request->get_param( 'fields' ), 0, self::MAX_FIELDS ) as $field ) { if ( is_array( $field ) && is_scalar( $field['value'] ?? '' ) ) { $fields[] = array( 'label' => sanitize_text_field( (string) ( $field['label'] ?? '' ) ), 'name' => sanitize_text_field( (string) ( $field['name'] ?? '' ) ), 'type' => sanitize_key( (string) ( $field['type'] ?? '' ) ), 'value' => sanitize_text_field( (string) ( $field['value'] ?? '' ) ), ); } } return $fields; } /** * Whether a submission is a sign-up that asks for an email address and little else. * * @param array $fields Sanitized fields. * @return bool */ private static function is_newsletter( array $fields ) { $filled = array_filter( $fields, static function ( $field ) { return ! in_array( $field['type'], array( 'hidden', 'checkbox', 'radio' ), true ); } ); $types = wp_list_pluck( $filled, 'type' ); return count( $filled ) <= 2 && in_array( 'email', $types, true ) && ! array_intersect( array( 'tel', 'textarea' ), $types ); } /** * Notes that a page is open, for the live view. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function live( \WP_REST_Request $request ) { if ( ! Live::enabled() || Visits::is_bot( (string) $request->get_header( 'user_agent' ) ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } $denied = $this->deny( $request, 'live', 600 ); if ( $denied ) { return $denied; } $answer = Live::record( (string) $request->get_param( 'id' ), array( 'page' => (string) $request->get_param( 'page' ), 'title' => (string) $request->get_param( 'title' ), 'form' => (string) $request->get_param( 'form' ), 'state' => (string) $request->get_param( 'state' ), 'nudge' => (string) $request->get_param( 'nudge' ), 'nudge_state' => (string) $request->get_param( 'nudge_state' ), ) ); return new \WP_REST_Response( $answer, 200 ); } /** * Counts the start of a visit. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function visit( \WP_REST_Request $request ) { if ( ! Settings::get( 'count_visits' ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } $user_agent = (string) $request->get_header( 'user_agent' ); if ( Visits::is_bot( $user_agent ) ) { return new \WP_REST_Response( array( 'ok' => true ), 200 ); } $denied = $this->deny( $request, 'visit', 60 ); if ( $denied ) { return $denied; } $touch = Attribution::sanitize_touch( (array) $request->get_param( 'touch' ) ); Visits::record( $touch, Visitor::columns( sanitize_text_field( (string) $request->get_param( 'tz' ) ) ) ); return new \WP_REST_Response( array( 'ok' => true ), 201 ); } /** * Stores a click on a phone, WhatsApp, email or similar link. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response|\WP_Error */ public function click( \WP_REST_Request $request ) { if ( ! Settings::get( 'track_clicks' ) ) { return new \WP_REST_Response( array( 'ok' => false ), 200 ); } $denied = $this->deny( $request, 'click', 30 ); if ( $denied ) { return $denied; } $type = sanitize_key( (string) $request->get_param( 'type' ) ); $target = mb_substr( sanitize_text_field( (string) $request->get_param( 'target' ) ), 0, 191 ); // A visitor double-tapping a button is one click. if ( $this->seen_recently( 'click', $type . '|' . $target, 30 ) ) { return new \WP_REST_Response( array( 'ok' => true ), 200 ); } Events::insert( $type, $target, sanitize_text_field( (string) $request->get_param( 'page' ) ) ); return new \WP_REST_Response( array( 'ok' => true ), 201 ); } /** * Permission of the routes: only the pages of this site may call them. Browsers send the * address of the page with every request, and a request without one is refused. * * @param \WP_REST_Request $request Request. * @return true|\WP_Error */ public function from_site( \WP_REST_Request $request ) { $origin = $request->get_header( 'origin' ); if ( ! $origin ) { $origin = $request->get_header( 'referer' ); } $host = wp_parse_url( (string) $origin, PHP_URL_HOST ); if ( ! $host || strtolower( $host ) !== strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ) { return new \WP_Error( 'dealroot_forbidden', __( 'Only the pages of this site may send this.', 'dealroot' ), array( 'status' => 403 ) ); } return true; } /** * Same-site and rate limit checks. * * @param \WP_REST_Request $request Request. * @param string $bucket Limit bucket. * @param int $limit Requests per window. * @return \WP_Error|null */ private function deny( \WP_REST_Request $request, $bucket, $limit ) { $site = $this->from_site( $request ); if ( is_wp_error( $site ) ) { return $site; } $key = 'dealroot_rl_' . $bucket . '_' . md5( $this->client_ip() . wp_salt( 'nonce' ) ); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return new \WP_Error( 'dealroot_rate_limited', 'Too many requests', array( 'status' => 429 ) ); } set_transient( $key, $count + 1, self::RATE_WINDOW ); return null; } /** * Whether the same payload from the same visitor arrived within the window. * * @param string $bucket Bucket. * @param string $payload Payload. * @param int $seconds Window. * @return bool */ private function seen_recently( $bucket, $payload, $seconds ) { $key = 'dealroot_dup_' . $bucket . '_' . md5( $this->client_ip() . '|' . $payload ); if ( get_transient( $key ) ) { return true; } set_transient( $key, 1, $seconds ); return false; } /** * Visitor IP, used only to build short-lived hashed keys. * * @return string */ private function client_ip() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; } }