> */ private static array $buffer = array(); /** * Wire up the hooks this service owns. */ public function register(): void { add_action( 'template_redirect', array( $this, 'record_view' ), 20 ); add_action( 'shutdown', array( self::class, 'flush' ) ); add_action( 'admin_init', array( $this, 'privacy_policy' ) ); } /** * Suggest text for the site's privacy policy (Settings → Privacy). */ public function privacy_policy(): void { if ( ! function_exists( 'wp_add_privacy_policy_content' ) ) { return; } wp_add_privacy_policy_content( 'Helpora', wp_kses_post( wpautop( __( 'When you read our documentation, search it, or tell us whether an article helped, we record that the article was viewed, what was searched for, and how you voted, so we can improve the documentation. We do not store your IP address, browser details, or account. To avoid counting the same visit twice, we keep a one-way code derived from your connection that changes every day and cannot be traced back to you. These records are kept for a limited time and then deleted. Comments you choose to leave with a vote are kept with that record.', 'helpora' ) ) ) ); } /** * Whether this request should be recorded at all. */ public static function should_record(): bool { $record = Options::get_bool( 'analytics_enabled', true ); if ( $record && is_user_logged_in() && ! Options::get_bool( 'track_logged_in' ) ) { $record = false; } $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; if ( $record && ( '' === $agent || preg_match( self::BOT_PATTERN, $agent ) ) ) { $record = false; } // Speculative loads: the browser fetched the page because the reader // might open it, not because they did. $purpose = isset( $_SERVER['HTTP_SEC_PURPOSE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_SEC_PURPOSE'] ) ) : ''; $purpose = '' !== $purpose ? $purpose : ( isset( $_SERVER['HTTP_PURPOSE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_PURPOSE'] ) ) : '' ); if ( $record && preg_match( '/prefetch|prerender/i', $purpose ) ) { $record = false; } /** * Filters whether the current request is recorded in analytics. * * Return false to switch recording off for a request, a user, or the * whole site — for example until a visitor accepts a consent banner. * * @param bool $record Whether to record. */ return (bool) apply_filters( 'helpora_should_record', $record ); } /** * Record a view when an article renders. */ public function record_view(): void { if ( ! is_singular( Post_Types::POST_TYPE ) || is_preview() || is_404() || ! isset( $_SERVER['REQUEST_METHOD'] ) || 'GET' !== $_SERVER['REQUEST_METHOD'] ) { return; } $post_id = (int) get_queried_object_id(); if ( 'publish' !== get_post_status( $post_id ) || ! Visibility::can_read( $post_id ) || ! self::should_record() ) { return; } if ( self::seen_today( 'view', $post_id ) ) { return; } self::record( 'view', array( 'post_id' => $post_id ) ); } /** * Record a search. * * Stored as `search`, or `search_empty` when nothing was found. * * @param string $query The query as typed. * @param int $results Number of results. */ public static function record_search( string $query, int $results ): void { $query = self::normalize_query( $query ); if ( '' === $query || ! self::should_record() ) { return; } self::record( $results > 0 ? 'search' : 'search_empty', array( 'query' => $query, 'results' => min( 65535, $results ), ) ); } /** * Queue an event. * * Callers are responsible for {@see Recorder::should_record()}; this only * validates and buffers. * * @param string $type Event type, one of {@see Recorder::TYPES}. * @param array $data post_id, term_id, query, results, meta. */ public static function record( string $type, array $data = array() ): void { if ( ! in_array( $type, self::TYPES, true ) ) { return; } self::$buffer[] = array( 'event_type' => $type, 'post_id' => (int) ( $data['post_id'] ?? 0 ), 'term_id' => (int) ( $data['term_id'] ?? 0 ), 'query' => mb_substr( (string) ( $data['query'] ?? '' ), 0, 191 ), 'results' => (int) ( $data['results'] ?? 0 ), 'meta' => isset( $data['meta'] ) ? wp_json_encode( $data['meta'] ) : null, 'visitor_hash' => self::visitor_hash(), 'created_at' => current_time( 'mysql', true ), ); } /** * Write buffered events in one INSERT. */ public static function flush(): void { if ( array() === self::$buffer ) { return; } global $wpdb; $rows = self::$buffer; self::$buffer = array(); $values = array(); $params = array(); foreach ( $rows as $row ) { $values[] = '(%s, %d, %d, %s, %d, %s, %s, %s)'; array_push( $params, $row['event_type'], $row['post_id'], $row['term_id'], $row['query'], $row['results'], $row['meta'] ?? '', $row['visitor_hash'], $row['created_at'] ); } $sql = 'INSERT INTO %i (event_type, post_id, term_id, query, results, meta, visitor_hash, created_at) VALUES ' . implode( ', ', $values ); $wpdb->query( $wpdb->prepare( $sql, Schema::table( 'events' ), ...$params ) ); // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table; placeholders built above. } /** * Whether this visitor already has an event of a type on an article today. * * Checks the buffer too, so two calls in one request do not both pass. * * @param string|string[] $type Event type, or several. * @param int $post_id Article ID. */ public static function seen_today( $type, int $post_id ): bool { $types = (array) $type; $hash = self::visitor_hash(); foreach ( self::$buffer as $row ) { if ( in_array( $row['event_type'], $types, true ) && $row['post_id'] === $post_id && $row['visitor_hash'] === $hash ) { return true; } } global $wpdb; $placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $sql = "SELECT 1 FROM %i WHERE post_id = %d AND created_at >= %s AND visitor_hash = %s AND event_type IN ({$placeholders}) LIMIT 1"; return null !== $wpdb->get_var( $wpdb->prepare( $sql, array_merge( array( Schema::table( 'events' ), $post_id, self::day_start_gmt(), $hash ), $types ) ) ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Own table; must be live. } /** * The current visitor's daily hash. * * The salted client hash plus the site-local date, hashed again: it * changes at midnight, so the same reader cannot be linked across days. */ public static function visitor_hash(): string { return md5( Rate_Limit::client_hash() . wp_date( 'Y-m-d' ) ); } /** * Midnight today in the site's timezone, as a GMT datetime string. * * Days are the site's days everywhere in analytics, so "today" in a * report matches the dedupe window here. */ public static function day_start_gmt(): string { return get_gmt_from_date( wp_date( 'Y-m-d 00:00:00' ) ); } /** * A query as it is stored: trimmed, lowercased, whitespace collapsed. * * Lowercased and accent-folded so "Refund", "refund " and "réfund" count * as one query in the reports, while staying readable (unlike the * tokenised form, which drops stopwords). * * @param string $query Raw query. */ public static function normalize_query( string $query ): string { $query = mb_strtolower( remove_accents( wp_strip_all_tags( $query ) ), 'UTF-8' ); $query = trim( (string) preg_replace( '/\s+/u', ' ', $query ) ); // A query that is nothing but stopwords or punctuation is noise. if ( array() === Tokenizer::tokens( $query, false ) ) { return ''; } return mb_substr( $query, 0, 191 ); } }