prefix . 'goconsent_' . $which; } /** * Today's salt (rotated daily). * * @return string */ private function salt() { $today = gmdate( 'Y-m-d' ); $salt = get_option( self::SALT ); if ( ! is_array( $salt ) || $salt['day'] !== $today ) { $salt = array( 'day' => $today, 'salt' => wp_generate_password( 32, true, true ), ); update_option( self::SALT, $salt, false ); } return $salt['salt']; } /** * Record one page view. * * @param array $hit {path, ref, source, medium, campaign, device}. * @param string $ip Visitor IP (hashed, never stored). * @param string $ua User agent (hashed, never stored). */ public function record( array $hit, $ip, $ua ) { global $wpdb; $day = gmdate( 'Y-m-d', current_time( 'timestamp' ) ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested -- site-local day buckets. $hash = md5( $this->salt() . '|' . $ip . '|' . $ua . '|' . home_url() ); // INSERT IGNORE returns 1 only the first time this visitor is seen today. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- write path. $new_visitor = (int) $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (day, hash) VALUES (%s, %s)', $this->table( 'seen' ), $day, $hash ) ); // Per-dimension uniqueness (e.g. unique visitors per page) uses a second hash. $dims = array( 'total' => '', 'page' => $hit['path'], 'ref' => $hit['ref'], 'device' => $hit['device'], 'campaign' => $hit['campaign'] ? $hit['campaign'] . '|' . $hit['source'] : '', ); foreach ( $dims as $metric => $dim ) { if ( 'total' !== $metric && '' === $dim ) { continue; } $unique = $new_visitor; if ( 'total' !== $metric ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $unique = (int) $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (day, hash) VALUES (%s, %s)', $this->table( 'seen' ), $day, md5( $hash . '|' . $metric . '|' . $dim ) ) ); } $this->bump( $day, $metric, substr( $dim, 0, 191 ), $unique ); } } /** * Increment a counter row. * * @param string $day Y-m-d. * @param string $metric Metric. * @param string $dim Dimension value. * @param int $unique 1 if this is a new visitor for the row. */ private function bump( $day, $metric, $dim, $unique ) { global $wpdb; $t = $this->table( 'stats' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $updated = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET views = views + 1, visitors = visitors + %d WHERE day = %s AND metric = %s AND dim = %s', $t, $unique, $day, $metric, $dim ) ); if ( ! $updated ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (day, metric, dim, views, visitors) VALUES (%s, %s, %s, 1, %d)', $t, $day, $metric, $dim, $unique ) ); } } /** * Delete fingerprints from previous days (hourly cron). */ public function cleanup() { global $wpdb; $today = gmdate( 'Y-m-d', current_time( 'timestamp' ) ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE day < %s', $this->table( 'seen' ), $today ) ); $this->salt(); } /** * Report for the last N days. * * @param int $days 7|30|90. * @return array */ public function report( $days ) { global $wpdb; $days = in_array( (int) $days, array( 7, 30, 90 ), true ) ? (int) $days : 30; $now = current_time( 'timestamp' ); // phpcs:ignore WordPress.DateTime.CurrentTimeTimestamp.Requested $end = gmdate( 'Y-m-d', $now ); $start = gmdate( 'Y-m-d', $now - ( $days - 1 ) * DAY_IN_SECONDS ); $pend = gmdate( 'Y-m-d', $now - $days * DAY_IN_SECONDS ); $pst = gmdate( 'Y-m-d', $now - ( 2 * $days - 1 ) * DAY_IN_SECONDS ); $t = $this->table( 'stats' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT day, views, visitors FROM %i WHERE metric = 'total' AND day BETWEEN %s AND %s ORDER BY day", $t, $start, $end ), ARRAY_A ); $by = array(); foreach ( $rows as $r ) { $by[ $r['day'] ] = $r; } $series = array(); for ( $i = $days - 1; $i >= 0; $i-- ) { $d = gmdate( 'Y-m-d', $now - $i * DAY_IN_SECONDS ); $series[] = array( 'day' => $d, 'visitors' => isset( $by[ $d ] ) ? (int) $by[ $d ]['visitors'] : 0, 'views' => isset( $by[ $d ] ) ? (int) $by[ $d ]['views'] : 0, ); } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $prev = $wpdb->get_row( $wpdb->prepare( "SELECT COALESCE(SUM(views),0) v, COALESCE(SUM(visitors),0) u FROM %i WHERE metric = 'total' AND day BETWEEN %s AND %s", $t, $pst, $pend ), ARRAY_A ); return array( 'days' => $days, 'series' => $series, 'visitors' => array_sum( wp_list_pluck( $series, 'visitors' ) ), 'views' => array_sum( wp_list_pluck( $series, 'views' ) ), 'prev' => array( 'visitors' => (int) $prev['u'], 'views' => (int) $prev['v'], ), 'pages' => $this->top( 'page', $start, $end ), 'refs' => $this->top( 'ref', $start, $end ), 'devices' => $this->top( 'device', $start, $end ), 'campaign' => $this->top( 'campaign', $start, $end ), ); } /** * Top values for a metric. * * @param string $metric Metric. * @param string $start Start day. * @param string $end End day. * @return array */ private function top( $metric, $start, $end ) { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT dim, SUM(views) views, SUM(visitors) visitors FROM %i WHERE metric = %s AND day BETWEEN %s AND %s GROUP BY dim ORDER BY visitors DESC, views DESC LIMIT 8', $this->table( 'stats' ), $metric, $start, $end ), ARRAY_A ); return array_map( static function ( $r ) { return array( 'dim' => $r['dim'], 'views' => (int) $r['views'], 'visitors' => (int) $r['visitors'], ); }, (array) $rows ); } /** * Drop tables (uninstall). */ public static function drop() { global $wpdb; foreach ( array( 'goconsent_stats', 'goconsent_seen' ) as $table ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange -- custom table. $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ); } } }