204 ) ); } check_ajax_referer( 'srpp_track', 'nonce' ); $raw = isset( $_POST['events'] ) ? wp_unslash( $_POST['events'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- Raw JSON payload; each field is sanitized in self::record(). $events = json_decode( $raw, true ); if ( ! is_array( $events ) ) { wp_die( '', '', array( 'response' => 204 ) ); } foreach ( array_slice( $events, 0, 40 ) as $event ) { self::record( $event ); } wp_die( '', '', array( 'response' => 204 ) ); } /** * Store one event. * * @param array $event Event data. */ protected static function record( $event ) { global $wpdb; $type = isset( $event['type'] ) ? sanitize_key( $event['type'] ) : ''; if ( ! in_array( $type, array( 'view', 'click' ), true ) ) { return; } $display_id = isset( $event['d'] ) ? absint( $event['d'] ) : 0; $source_id = isset( $event['s'] ) ? absint( $event['s'] ) : 0; $template = isset( $event['tpl'] ) ? substr( sanitize_key( $event['tpl'] ), 0, 32 ) : ''; $placement = isset( $event['plc'] ) ? substr( sanitize_key( $event['plc'] ), 0, 48 ) : ''; $targets = isset( $event['t'] ) ? array_slice( srpp_int_list( $event['t'] ), 0, 30 ) : array(); if ( ! $display_id || empty( $targets ) ) { return; } $table = SRPP_Install::stats_table(); $date = current_time( 'Y-m-d' ); foreach ( $targets as $target_id ) { $views = ( 'view' === $type ) ? 1 : 0; $clicks = ( 'click' === $type ) ? 1 : 0; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table with no core API; the name comes from $wpdb->prefix and every value is prepared. A write cannot be cached. $wpdb->query( $wpdb->prepare( "INSERT INTO {$table} (display_id, source_id, target_id, template, placement, stat_date, impressions, clicks) VALUES (%d, %d, %d, %s, %s, %s, %d, %d) ON DUPLICATE KEY UPDATE impressions = impressions + VALUES(impressions), clicks = clicks + VALUES(clicks), template = VALUES(template), placement = VALUES(placement)", $display_id, $source_id, $target_id, $template, $placement, $date, $views, $clicks ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter } } /** * Totals for a period. * * @param int $days Days back. * @return array */ public static function totals( $days = 30 ) { global $wpdb; $days = absint( $days ); $key = self::cache_key( 'totals', array( $days ) ); $out = wp_cache_get( $key, self::CACHE_GROUP ); if ( is_array( $out ) ) { return $out; } $table = SRPP_Install::stats_table(); $since = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table with no core API; the name comes from $wpdb->prefix and the value is prepared. Result cached below. $row = $wpdb->get_row( $wpdb->prepare( "SELECT SUM(impressions) AS impressions, SUM(clicks) AS clicks FROM {$table} WHERE stat_date >= %s", $since ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $impressions = $row ? (int) $row->impressions : 0; $clicks = $row ? (int) $row->clicks : 0; $out = array( 'impressions' => $impressions, 'clicks' => $clicks, 'ctr' => $impressions ? round( ( $clicks / $impressions ) * 100, 2 ) : 0, ); wp_cache_set( $key, $out, self::CACHE_GROUP, self::CACHE_TTL ); return $out; } /** * Grouped performance report. * * @param string $group template|placement|display_id. * @param int $days Days back. * @param int $limit Rows. * @return array */ public static function by( $group, $days = 30, $limit = 12 ) { global $wpdb; $allowed = array( 'template', 'placement', 'display_id', 'target_id' ); if ( ! in_array( $group, $allowed, true ) ) { return array(); } $days = absint( $days ); $limit = absint( $limit ); $key = self::cache_key( 'by', array( $group, $days, $limit ) ); $rows = wp_cache_get( $key, self::CACHE_GROUP ); if ( is_array( $rows ) ) { return $rows; } $table = SRPP_Install::stats_table(); $since = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table with no core API; the table name comes from $wpdb->prefix, $group is matched against $allowed above and the values are prepared. Result cached below. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$group} AS label, SUM(impressions) AS impressions, SUM(clicks) AS clicks FROM {$table} WHERE stat_date >= %s GROUP BY {$group} HAVING impressions > 0 ORDER BY clicks DESC LIMIT %d", $since, $limit ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $rows = is_array( $rows ) ? $rows : array(); foreach ( $rows as $row ) { $row->impressions = (int) $row->impressions; $row->clicks = (int) $row->clicks; $row->ctr = $row->impressions ? round( ( $row->clicks / $row->impressions ) * 100, 2 ) : 0; } wp_cache_set( $key, $rows, self::CACHE_GROUP, self::CACHE_TTL ); return $rows; } /** * Daily series for the dashboard chart. * * @param int $days Days back. * @return array */ public static function series( $days = 30 ) { global $wpdb; $days = absint( $days ); $key = self::cache_key( 'series', array( $days ) ); $out = wp_cache_get( $key, self::CACHE_GROUP ); if ( is_array( $out ) ) { return $out; } $table = SRPP_Install::stats_table(); $since = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table with no core API; the name comes from $wpdb->prefix and the value is prepared. Result cached below. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT stat_date, SUM(impressions) AS impressions, SUM(clicks) AS clicks FROM {$table} WHERE stat_date >= %s GROUP BY stat_date ORDER BY stat_date ASC", $since ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter $out = array(); foreach ( (array) $rows as $row ) { $out[ $row->stat_date ] = array( 'impressions' => (int) $row->impressions, 'clicks' => (int) $row->clicks, ); } wp_cache_set( $key, $out, self::CACHE_GROUP, self::CACHE_TTL ); return $out; } /** * Delete rows past the retention window. */ public static function cleanup() { global $wpdb; $days = (int) srpp_get_option( 'analytics_retention', 180 ); if ( $days < 1 ) { return; } $table = SRPP_Install::stats_table(); $since = gmdate( 'Y-m-d', time() - ( $days * DAY_IN_SECONDS ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table with no core API; the name comes from $wpdb->prefix and the value is prepared. A delete cannot be cached. $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE stat_date < %s", $since ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter self::flush_cache(); } /** * Wipe all statistics. */ public static function reset() { global $wpdb; $table = SRPP_Install::stats_table(); // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom table with no core API; the name comes from $wpdb->prefix. A truncate cannot be cached or prepared. $wpdb->query( "TRUNCATE TABLE {$table}" ); // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter self::flush_cache(); } }