HEX
Server: Apache
System: Linux web15f74.uni5.net 5.4.282-1.el8.elrepo.x86_64 #1 SMP Mon Aug 19 18:33:22 EDT 2024 x86_64
User: lucendi (859622)
PHP: 7.4.33
Disabled: apache_child_terminate,c99_buff_prepare,c99_sess_put,dl,exec,leak,link,myshellexec,openlog,passthru,pclose,pcntl_exec,php_check_syntax,php_strip_whitespace,popen,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid,proc_close,proc_get_status,proc_nice,proc_open,proc_terminate,shell_exec,show_source,symlink,system,socket_listen,socket_create_listen,putenv
Upload Files
File: /home/lucendi/www/wp-content/plugins/creame-whatsapp-me/includes/class-joinchat-tracking.php
<?php
/**
 * Track Joinchat clicks.
 *
 * @package Joinchat
 */

defined( 'WPINC' ) || exit;

/**
 * Joinchat tracking class.
 *
 * @since      6.2.0
 * @package    Joinchat
 * @subpackage Joinchat/includes
 * @author     Creame <hola@crea.me>
 */
class Joinchat_Tracking {

	/**
	 * Option name used to store daily click counters.
	 */
	const OPTION_NAME = 'joinchat_tracking_clicks';

	/**
	 * Option name used to store recent event fingerprints for deduplication.
	 */
	const OPTION_DEDUP = 'joinchat_tracking_dedup';

	/**
	 * REST namespace.
	 */
	const REST_NAMESPACE = 'joinchat/v1';

	/**
	 * REST route.
	 */
	const REST_ROUTE = '/track-click';

	/**
	 * REST nonce action.
	 */
	const NONCE_ACTION = 'joinchat_rest';

	/**
	 * Deduplication window in seconds.
	 */
	const DEDUP_WINDOW = 60;

	/**
	 * Cache for tracking enabled status.
	 *
	 * @var bool|null
	 */
	private $is_enabled = null;

	/**
	 * Register REST routes.
	 *
	 * @since 6.2.0
	 * @return void
	 */
	public function register_rest_routes() {

		register_rest_route(
			self::REST_NAMESPACE,
			self::REST_ROUTE,
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'rest_track_click' ),
				'permission_callback' => '__return_true',
			)
		);
	}

	/**
	 * Register dashboard widget.
	 *
	 * @since 6.2.0
	 * @return void
	 */
	public function register_dashboard_widget() {

		wp_add_dashboard_widget(
			'joinchat_tracking_widget',
			esc_html__( 'Joinchat Clicks', 'creame-whatsapp-me' ),
			array( $this, 'render_dashboard_widget' )
		);
	}

	/**
	 * Render dashboard widget.
	 *
	 * @since 6.2.0
	 * @return void
	 */
	public function render_dashboard_widget() {

		$clicks = $this->get_clicks();
		$total  = array_sum( array_map( 'intval', $clicks ) );
		$today  = $this->get_day_clicks( current_time( 'Ymd' ) );
		$week   = $this->get_period_clicks( 7 );
		$rows   = $this->get_recent_rows( 30 );

		?>
		<div class="joinchat-tracking-widget">
			<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px;">
				<div><strong><?php esc_html_e( 'Total clicks', 'creame-whatsapp-me' ); ?>:</strong> <?php echo esc_html( number_format_i18n( $total ) ); ?></div>
				<div><strong><?php esc_html_e( 'Today', 'creame-whatsapp-me' ); ?>:</strong> <?php echo esc_html( number_format_i18n( $today ) ); ?></div>
				<div><strong><?php esc_html_e( 'Last 7 days', 'creame-whatsapp-me' ); ?>:</strong> <?php echo esc_html( number_format_i18n( $week ) ); ?></div>
			</div>

			<div style="margin-top:4px;">
				<?php echo $this->render_chart_svg( $rows ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
			</div>
		</div>
		<?php
	}

	/**
	 * REST callback that saves a click.
	 *
	 * @since 6.2.0
	 * @param WP_REST_Request $request Request instance.
	 * @return WP_REST_Response|WP_Error
	 */
	public function rest_track_click( WP_REST_Request $request ) {

		if ( ! $this->is_enabled() ) {
			return new WP_Error( 'joinchat_tracking_disabled', 'Tracking is disabled.', array( 'status' => 400 ) );
		}

		// Fail silently for invalid requests to avoid exposing endpoint details to bots/scripts.
		if ( self::requires_nonce() && ! $this->verify_nonce( $request ) ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				return new WP_Error( 'joinchat_invalid_nonce', 'Invalid nonce.', array( 'status' => 403 ) );
			}
			return rest_ensure_response( array( 'success' => true ) );
		}

		if ( ! is_user_logged_in() && $this->is_known_bot( $request ) ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				return new WP_Error( 'joinchat_bot_detected', 'Bot or crawler detected.', array( 'status' => 403 ) );
			}
			return rest_ensure_response( array( 'success' => true ) );
		}

		if ( ! is_user_logged_in() && $this->is_duplicate_event( $request ) ) {
			if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
				return new WP_Error( 'joinchat_duplicate_event', 'Duplicate event detected.', array( 'status' => 429 ) );
			}
			return rest_ensure_response( array( 'success' => true ) );
		}

		$data = array(
			'trigger'      => sanitize_key( (string) $request->get_param( 'trigger' ) ),
			'chat_channel' => sanitize_text_field( (string) $request->get_param( 'chat_channel' ) ),
			'chat_id'      => sanitize_text_field( (string) $request->get_param( 'chat_id' ) ),
			'is_mobile'    => filter_var( $request->get_param( 'is_mobile' ), FILTER_VALIDATE_BOOLEAN ) ? 1 : 0,
			'day'          => current_time( 'Ymd' ),
		);

		$data = (array) apply_filters( 'joinchat_track_click_data', $data, $request );
		$day  = isset( $data['day'] ) && preg_match( '/^\d{8}$/', (string) $data['day'] ) ? (string) $data['day'] : current_time( 'Ymd' );

		$count = $this->increment_day_clicks( $day );

		do_action( 'joinchat_track_click', $data, $count, $request );

		return rest_ensure_response( array( 'success' => true ) );
	}

	/**
	 * Get REST URL for the tracking endpoint.
	 *
	 * @since 6.2.0
	 * @return string
	 */
	public static function rest_url() {

		return rest_url( self::REST_NAMESPACE . self::REST_ROUTE );
	}

	/**
	 * Check whether nonce verification is enabled.
	 *
	 * @since 6.2.0
	 * @return bool
	 */
	public static function requires_nonce() {

		return (bool) apply_filters( 'joinchat_tracking_require_nonce', is_user_logged_in() );
	}

	/**
	 * Check if tracking is enabled in settings.
	 *
	 * @since 6.2.0
	 * @return bool
	 */
	public function is_enabled() {

		if ( null !== $this->is_enabled ) {
			return $this->is_enabled;
		}

		$settings = (array) get_option( JOINCHAT_SLUG, array() );

		$this->is_enabled = ! isset( $settings['tracking'] ) || 'yes' === $settings['tracking'];

		return $this->is_enabled;
	}

	/**
	 * Verify the REST nonce.
	 *
	 * @since 6.2.0
	 * @param WP_REST_Request $request Request instance.
	 * @return bool
	 */
	private function verify_nonce( WP_REST_Request $request ) {

		$nonce = (string) $request->get_param( 'nonce' );
		if ( '' === $nonce ) {
			$nonce = (string) $request->get_header( 'X-WP-Nonce' );
		}

		return (bool) wp_verify_nonce( $nonce, self::NONCE_ACTION );
	}

	/**
	 * Check if request is from a known bot/crawler/automation tool.
	 *
	 * Detects common bots and scripts that shouldn't trigger click events.
	 * This prevents automated crawlers, monitoring tools, and scripts from
	 * inflating click metrics.
	 *
	 * @since 6.2.2
	 * @param WP_REST_Request $request Request instance.
	 * @return bool True if request is from a known bot, false otherwise.
	 */
	private function is_known_bot( WP_REST_Request $request ) {

		$user_agent = strtolower( (string) $request->get_header( 'User-Agent' ) );

		if ( empty( $user_agent ) ) {
			return true;
		}

		$bot_patterns = array(
			// Search engine bots.
			'googlebot',
			'bingbot',
			'slurp',
			'duckduckbot',
			'baiduspider',
			'yandexbot',
			'exabot',
			'facebookexternalhit',
			'twitterbot',
			'linkedinbot',
			'whatsapp',
			'telegrambot',
			'slackbot',
			'discordbot',

			// Monitoring & analytics bots.
			'uptimerobot',
			'pingdom',
			'monitoring',
			'newrelic',
			'datadoghq',
			'elastic',
			'semrush',
			'ahrefs',
			'majestic',
			'similarweb',

			// Common crawlers & scrapers.
			'curl',
			'wget',
			'python',
			'requests',
			'scrapy',
			'selenium',
			'phantomjs',
			'headless',
			'puppeteer',
			'playwright',

			// Other automation.
			'postman',
			'insomnia',
			'apifox',
			'jmeter',
			'appium',
			'okhttp',
			'httpclient',

			// Generic empty or suspicious patterns.
			'bot',
			'crawler',
			'spider',
			'scraper',
			'downloader',
		);

		foreach ( $bot_patterns as $pattern ) {
			if ( false !== stripos( $user_agent, $pattern ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get client fingerprint hash for deduplication.
	 *
	 * Combines: client IP, user agent, channel, and chat ID.
	 * Used to detect and block automated/duplicate click events.
	 *
	 * @since 6.2.2
	 * @param WP_REST_Request $request Request instance.
	 * @return string SHA256 fingerprint hash.
	 */
	private function get_event_fingerprint( WP_REST_Request $request ) {

		$client_ip    = Joinchat_Util::get_client_ip();
		$user_agent   = (string) $request->get_header( 'User-Agent' );
		$chat_channel = sanitize_text_field( (string) $request->get_param( 'chat_channel' ) );
		$chat_id      = sanitize_text_field( (string) $request->get_param( 'chat_id' ) );

		// Combine all factors into a fingerprint string.
		$fingerprint_data = sprintf(
			'%s|%s|%s|%s',
			empty( $client_ip ) ? 'unknown' : $client_ip,
			$user_agent,
			$chat_channel,
			$chat_id
		);

		return hash( 'sha256', $fingerprint_data );
	}

	/**
	 * Check if an event is a duplicate within the dedup window and store its fingerprint if not.
	 *
	 * @since 6.2.2
	 * @param WP_REST_Request $request Request instance.
	 * @return bool True if event appears to be duplicate, false otherwise.
	 */
	private function is_duplicate_event( WP_REST_Request $request ) {

		$fingerprint = $this->get_event_fingerprint( $request );
		$stored_data = (array) get_option( self::OPTION_DEDUP, array() );

		// Clean expired entries (older than DEDUP_WINDOW).
		$current_time = current_time( 'timestamp' );
		$stored_data  = array_filter(
			$stored_data,
			function( $timestamp ) use ( $current_time ) {
				return ( $current_time - (int) $timestamp ) < self::DEDUP_WINDOW;
			}
		);

		if ( isset( $stored_data[ $fingerprint ] ) ) {
			return true;
		}

		// Add this fingerprint with current timestamp.
		$stored_data[ $fingerprint ] = $current_time;
		update_option( self::OPTION_DEDUP, $stored_data, false );

		return false;
	}

	/**
	 * Increment the click counter for a given day.
	 *
	 * @since 6.2.0
	 * @param string $day Ymd date.
	 * @return int
	 */
	private function increment_day_clicks( $day ) {

		$clicks = $this->get_clicks();

		if ( ! isset( $clicks[ $day ] ) ) {
			$clicks[ $day ] = 0;
		}

		$clicks[ $day ] = (int) $clicks[ $day ] + 1;
		$this->save_clicks( $clicks );

		return (int) $clicks[ $day ];
	}

	/**
	 * Get all stored clicks.
	 *
	 * @since 6.2.0
	 * @return array
	 */
	private function get_clicks() {

		$clicks = get_option( self::OPTION_NAME, array() );

		return is_array( $clicks ) ? $clicks : array();
	}

	/**
	 * Save click counters.
	 *
	 * @since 6.2.0
	 * @param array $clicks Click counters.
	 * @return void
	 */
	private function save_clicks( $clicks ) {

		update_option( self::OPTION_NAME, $clicks, false );
	}

	/**
	 * Get clicks for a specific day.
	 *
	 * @since 6.2.0
	 * @param string $day Ymd date.
	 * @return int
	 */
	private function get_day_clicks( $day ) {

		$clicks = $this->get_clicks();

		return isset( $clicks[ $day ] ) ? (int) $clicks[ $day ] : 0;
	}

	/**
	 * Get total clicks for a period.
	 *
	 * @since 6.2.0
	 * @param int $days Number of days.
	 * @return int
	 */
	private function get_period_clicks( $days ) {

		$total  = 0;
		$clicks = $this->get_clicks();
		$stamp  = strtotime( current_time( 'mysql' ) );

		for ( $i = 0; $i < $days; $i++ ) {
			$day = date_i18n( 'Ymd', $stamp - ( $i * DAY_IN_SECONDS ) );

			if ( isset( $clicks[ $day ] ) ) {
				$total += (int) $clicks[ $day ];
			}
		}

		return $total;
	}

	/**
	 * Get recent rows for dashboard display.
	 *
	 * @since 6.2.0
	 * @param int $days Number of days.
	 * @return array
	 */
	private function get_recent_rows( $days ) {

		$rows   = array();
		$clicks = $this->get_clicks();
		$stamp  = strtotime( current_time( 'mysql' ) );

		for ( $i = $days - 1; $i >= 0; $i-- ) {
			$day    = date_i18n( 'Ymd', $stamp - ( $i * DAY_IN_SECONDS ) );
			$rows[] = array(
				'day'    => $day,
				'clicks' => isset( $clicks[ $day ] ) ? (int) $clicks[ $day ] : 0,
			);
		}

		return $rows;
	}

	/**
	 * Render a SVG chart for recent clicks.
	 *
	 * @since 6.2.0
	 * @param array $rows Daily rows.
	 * @return string
	 */
	private function render_chart_svg( $rows ) {

		if ( empty( $rows ) ) {
			return '<p>' . esc_html__( 'No clicks yet.', 'creame-whatsapp-me' ) . '</p>';
		}

		$width       = 640;
		$height      = 180;
		$padding_x   = 12;
		$padding_y   = 14;
		$chart_width = $width - ( $padding_x * 2 );
		$chart_h     = $height - ( $padding_y * 2 ) - 14;
		$max_value   = max( 1, (int) max( array_column( $rows, 'clicks' ) ) );
		$step        = count( $rows ) > 1 ? $chart_width / ( count( $rows ) - 1 ) : $chart_width;

		$path   = array();
		$base_y = $height - 18;

		foreach ( $rows as $index => $row ) {
			$value = (int) $row['clicks'];
			$x     = $padding_x + ( $index * $step );
			$y     = $padding_y + ( $chart_h - ( ( $value / $max_value ) * $chart_h ) );

			if ( 0 === $index ) {
				$path[] = sprintf( 'M %1$.2f %2$.2f', $x, $y );
			} else {
				$path[] = sprintf( 'L %1$.2f %2$.2f', $x, $y );
			}
		}

		$start_x      = $padding_x;
		$end_x        = $padding_x + ( ( count( $rows ) - 1 ) * $step );
		$y_ticks      = 2;
		$y_label_x    = 2;
		$y_scale_rows = array();
		$seen_values  = array();

		for ( $i = 0; $i <= $y_ticks; $i++ ) {
			$tick_value = (int) round( $max_value * ( 1 - ( $i / $y_ticks ) ) );

			// Avoid duplicated labels for small ranges (e.g. max=1).
			if ( isset( $seen_values[ $tick_value ] ) ) {
				continue;
			}

			$seen_values[ $tick_value ] = true;
			$y_scale_rows[]             = array(
				'value' => $tick_value,
				'y'     => $padding_y + ( $chart_h - ( ( $tick_value / $max_value ) * $chart_h ) ),
			);
		}

		$area_path = implode( ' ', $path ) . sprintf( ' L %.2f %.2f L %.2f %.2f Z', $end_x, $base_y, $start_x, $base_y );

		ob_start();
		?>
<div style="background:#fff;border:1px solid #dcdcde;border-radius:4px;padding:10px 8px 0;">
	<style>
		#joinchat_tracking_widget .postbox-header h2 { justify-content:flex-start; }
		#joinchat_tracking_widget .postbox-header h2::before {content:''; width:20px; height:20px; margin:-2px 8px 0 -4px; background:url(<?php echo esc_url( plugin_dir_url( JOINCHAT_FILE ) . 'admin/img/menu-icon.svg' ); ?>) !important; filter:invert(1); }
		.joinchat-tracking-svg .chart-y-grid { stroke:#e2e4e7; stroke-width:1; }
		.joinchat-tracking-svg .chart-y-label { text-anchor:start; font-size:11px; fill:#646970; }
		.joinchat-tracking-svg .chart-dot { stroke:#3a87c6; stroke-width:1.5; }
		.joinchat-tracking-svg .chart-tip-box { fill:#fff; stroke:#dcdcde; stroke-width:1; }
		.joinchat-tracking-svg .chart-tip-day { text-anchor:middle; font-size:13px; font-weight:500; fill:#333; }
		.joinchat-tracking-svg .chart-tip-num { text-anchor:middle; font-size:14px; font-weight:600; fill:#1d1d1d; }
		.joinchat-tracking-svg .chart-point { cursor:pointer; }
		.joinchat-tracking-svg .chart-tip { opacity:0; transition:opacity 0.2s ease-in-out; pointer-events:none; }
		.joinchat-tracking-svg .chart-point:hover .chart-tip { opacity:1; }
	</style>
	<svg class="joinchat-tracking-svg" viewBox="0 0 <?php echo (int) $width; ?> <?php echo (int) $height; ?>" role="img" aria-label="<?php echo esc_attr__( 'Joinchat clicks over the last 30 days', 'creame-whatsapp-me' ); ?>" style="display:block;width:100%;height:auto;overflow:visible;">
		<?php foreach ( $y_scale_rows as $scale_row ) : ?>
			<?php if ( $scale_row['value'] > 0 ) : ?>
				<line class="chart-y-grid" x1="<?php echo (int) $padding_x; ?>" y1="<?php echo esc_attr( sprintf( '%.2f', $scale_row['y'] ) ); ?>" x2="<?php echo (int) ( $width - $padding_x ); ?>" y2="<?php echo esc_attr( sprintf( '%.2f', $scale_row['y'] ) ); ?>" />
			<?php endif; ?>
			<text class="chart-y-label" x="<?php echo (int) $y_label_x; ?>" y="<?php echo esc_attr( sprintf( '%.2f', $scale_row['y'] - 2 ) ); ?>"><?php echo esc_html( number_format_i18n( $scale_row['value'] ) ); ?></text>
		<?php endforeach; ?>
		<line x1="<?php echo (int) $padding_x; ?>" y1="<?php echo (int) $base_y; ?>" x2="<?php echo (int) ( $width - $padding_x ); ?>" y2="<?php echo (int) $base_y; ?>" stroke="#c3c4c7" stroke-width="1" />
		<path d="<?php echo esc_attr( $area_path ); ?>" fill="#3a87c6" fill-opacity="0.15" />
		<path d="<?php echo esc_attr( implode( ' ', $path ) ); ?>" fill="none" stroke="#3a87c6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
		<?php foreach ( $rows as $index => $row ) : ?>
			<?php
			$value     = (int) $row['clicks'];
			$x         = $padding_x + ( $index * $step );
			$y         = $padding_y + ( $chart_h - ( ( $value / $max_value ) * $chart_h ) );
			$fill      = $value > 0 ? '#3a87c6' : '#fff';
			$day_label = $this->format_day( $row['day'] );
			/* translators: %d: number of clicks. */
			$clicks_label = sprintf( _n( '%d click', '%d clicks', $value, 'creame-whatsapp-me' ), $value );
			?>
			<g class="chart-point">
				<circle class="chart-dot" cx="<?php echo esc_attr( sprintf( '%.2f', $x ) ); ?>" cy="<?php echo esc_attr( sprintf( '%.2f', $y ) ); ?>" r="4" fill="<?php echo esc_attr( $fill ); ?>" />
				<g class="chart-tip">
					<rect class="chart-tip-box" x="<?php echo esc_attr( sprintf( '%.2f', $x - 42 ) ); ?>" y="<?php echo esc_attr( sprintf( '%.2f', $y - 48 ) ); ?>" width="84" height="50" rx="4" />
					<text class="chart-tip-day" x="<?php echo esc_attr( sprintf( '%.2f', $x ) ); ?>" y="<?php echo esc_attr( sprintf( '%.2f', $y - 28 ) ); ?>"><?php echo esc_html( $day_label ); ?></text>
					<text class="chart-tip-num" x="<?php echo esc_attr( sprintf( '%.2f', $x ) ); ?>" y="<?php echo esc_attr( sprintf( '%.2f', $y - 12 ) ); ?>"><?php echo esc_html( $clicks_label ); ?></text>
				</g>
			</g>
		<?php endforeach; ?>
	</svg>
</div>
		<?php

		return (string) ob_get_clean();
	}

	/**
	 * Format a Ymd day for display.
	 *
	 * @since 6.2.0
	 * @param string $day Ymd date.
	 * @return string
	 */
	private function format_day( $day ) {

		if ( 8 !== strlen( $day ) ) {
			return $day;
		}

		$timestamp = strtotime( substr( $day, 0, 4 ) . '-' . substr( $day, 4, 2 ) . '-' . substr( $day, 6, 2 ) );
		return date_i18n( 'd M Y', $timestamp );
	}
}