settings = $settings; $this->store = $store; } /** * Register the route. */ public function register() { add_action( 'rest_api_init', array( $this, 'route' ) ); } /** * Route definition. */ public function route() { register_rest_route( 'goconsent/v1', '/hit', array( 'methods' => 'POST', 'callback' => array( $this, 'hit' ), // Anonymous visitors send page views; nothing personal is accepted or stored. 'permission_callback' => '__return_true', ) ); } /** * Record a hit. Always answers 204 so bots learn nothing. * * @param \WP_REST_Request $request Request. * @return \WP_REST_Response */ public function hit( \WP_REST_Request $request ) { $done = new \WP_REST_Response( null, 204 ); if ( ! $this->settings->get( 'traffic' ) ) { return $done; } $ua = (string) $request->get_header( 'user_agent' ); if ( '' === $ua || self::is_bot( $ua ) ) { return $done; } // Only accept beacons sent from this site's own pages. $origin = (string) ( $request->get_header( 'origin' ) ? $request->get_header( 'origin' ) : $request->get_header( 'referer' ) ); $host = (string) wp_parse_url( $origin, PHP_URL_HOST ); if ( '' === $host || ! Classifier::same_site( $host ) ) { return $done; } $data = json_decode( (string) $request->get_body(), true ); if ( ! is_array( $data ) ) { return $done; } $path = isset( $data['p'] ) ? (string) $data['p'] : '/'; $path = '/' . ltrim( (string) wp_parse_url( $path, PHP_URL_PATH ), '/' ); $path = substr( sanitize_text_field( $path ), 0, 191 ); if ( preg_match( '#^/(wp-admin|wp-login\.php|wp-json)#', $path ) ) { return $done; } $ref = isset( $data['r'] ) ? strtolower( (string) $data['r'] ) : ''; $ref = preg_match( '/^[a-z0-9.-]{1,190}$/', $ref ) ? preg_replace( '/^www\./', '', $ref ) : ''; if ( '' !== $ref && Classifier::same_site( $ref ) ) { $ref = ''; } $width = isset( $data['w'] ) ? absint( $data['w'] ) : 0; $device = $width && $width < 768 ? 'Mobile' : ( $width && $width < 1100 ? 'Tablet' : 'Desktop' ); $utm = static function ( $key ) use ( $data ) { return isset( $data[ $key ] ) ? substr( strtolower( sanitize_text_field( (string) $data[ $key ] ) ), 0, 80 ) : ''; }; // Flood guard: at most 300 page views per visitor per hour (the IP is only hashed, never stored). $guard = 'goconsent_hit_' . md5( self::ip() . '|' . gmdate( 'YmdH' ) ); $count = (int) get_transient( $guard ); if ( $count >= 300 ) { return $done; } set_transient( $guard, $count + 1, HOUR_IN_SECONDS ); $this->store->record( array( 'path' => $path, 'ref' => $ref, 'device' => $device, 'source' => $utm( 'us' ), 'campaign' => $utm( 'uc' ), ), self::ip(), $ua ); return $done; } /** * Visitor IP (used only inside the daily hash). * * @return string */ private static function ip() { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : ''; /** * Filter the IP used for the daily visitor hash (e.g. behind a proxy). * * @param string $ip IP address. */ return (string) apply_filters( 'goconsent_visitor_ip', $ip ); } /** * Common crawlers, monitors and headless browsers. * * @param string $ua User agent. * @return bool */ public static function is_bot( $ua ) { return (bool) preg_match( '/bot|crawl|spider|slurp|facebookexternalhit|preview|monitor|uptime|pingdom|lighthouse|pagespeed|headless|phantom|curl|wget|python|java\/|go-http|scrapy|axios|node-fetch|GoConsent-Scanner/i', $ua ); } }