insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- the plugin's own log table: no WordPress API writes it. Database::table( 'events' ), array( 'type' => (string) $type, 'ip_hash' => (string) $ip_hash, 'context' => (string) $context, 'source' => substr( (string) $source, 0, 16 ), 'created_at' => current_time( 'mysql', true ), ), array( '%s', '%s', '%s', '%s', '%s' ) ); } /** * Recent security-relevant events (failed lookups, throttling, bans) for the * admin overview. Only hashed identifiers and coarse context are returned. */ public function recent_security( $limit = 20, $since_gmt = '' ) { global $wpdb; $table = Database::table( 'events' ); // `turnstile_failed`: a refusal logged before 0.84.1, when the check was the free part's. $types = array( 'lookup_failed', 'bot_timing', 'bot_check_failed', 'turnstile_failed', 'honeypot', 'rate_limited', 'ip_banned' ); $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $params = array_merge( array( $table ), $types ); $where = "type IN ({$in})"; if ( '' !== $since_gmt ) { $where .= ' AND created_at >= %s'; $params[] = $since_gmt; } $params[] = absint( $limit ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own log table, live data; $where is %s placeholders. return $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $params holds one value per placeholder of $where, then the limit. "SELECT type, ip_hash, context, source, created_at FROM %i WHERE {$where} ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- %s placeholders only. $params ), ARRAY_A ); } /** * Event types the free part writes: the refusals of its two public forms * (Rest, Track_Rest) and the messages of its own that never reached their * recipient (Notifications). The free part's log trimming (Retention) * deletes these and nothing else: a row an add-on writes may be how it * remembers it already sent something, and trimmed it would send it again * (04-ARCHITECTURE §4). An add-on trims its own rows itself (prune()). * `turnstile_failed` is a refusal logged before 0.84.1, trimmed with the rest. */ const OWN_TYPES = array( 'lookup_failed', 'bot_timing', 'bot_check_failed', 'turnstile_failed', 'honeypot', 'rate_limited', 'ip_banned', 'ack_failed', 'ack_no_address', 'admin_notice_failed' ); /** Event types recording a message that never reached its recipient. */ const DELIVERY_TYPES = array( 'ack_failed', 'ack_no_address', 'admin_notice_failed', 'digest_failed' ); /** * Recent delivery failures, newest first, for the banner on the request list. * * The context of each of these is the request id, which is what makes them * worth listing rather than counting: a number an operator can open. * * @param string $since_gmt Cut-off, or '' for no cut-off. * @param int $limit Most rows to return. * @return array> */ public function recent_delivery_failures( $since_gmt = '', $limit = 20 ) { global $wpdb; $table = Database::table( 'events' ); $types = self::DELIVERY_TYPES; $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); $params = array_merge( array( $table ), $types ); $where = "type IN ({$in})"; if ( '' !== (string) $since_gmt ) { $where .= ' AND created_at >= %s'; $params[] = (string) $since_gmt; } $params[] = absint( $limit ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- the plugin's own log table, live data; $where is %s placeholders. return (array) $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- $params holds one value per placeholder of $where, then the limit. "SELECT type, context, created_at FROM %i WHERE {$where} ORDER BY created_at DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- %s placeholders only. $params ), ARRAY_A ); } /** * The moment an event of this type was last written, or '' when never. * * Asked for operator actions rather than refusals: those are counted, this * one is a single fact about the last time somebody did something. */ public function latest( $type ) { global $wpdb; $table = Database::table( 'events' ); $at = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table; live data, not cached. $wpdb->prepare( 'SELECT created_at FROM %i WHERE type = %s ORDER BY created_at DESC LIMIT 1', $table, (string) $type ) ); return null === $at ? '' : (string) $at; } /** * Deletes the events written before the cut-off: of the types named, or of * every type when none are named. * * @param string $before_gmt Cut-off, `Y-m-d H:i:s` in UTC. * @param string[]|null $types Types to delete; null for all of them. * @return int|bool Rows deleted, or false on a database error. */ public function prune( $before_gmt, ?array $types = null ) { global $wpdb; $table = Database::table( 'events' ); if ( null === $types ) { return $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- a bulk delete in the plugin's own log table; nothing to cache. $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s', $table, $before_gmt ) ); } $types = array_values( array_unique( array_filter( array_map( 'strval', $types ), 'strlen' ) ) ); if ( ! $types ) { return 0; } $in = implode( ',', array_fill( 0, count( $types ), '%s' ) ); return $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- a bulk delete in the plugin's own log table; nothing to cache. $wpdb->prepare( // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- the table, the cut-off, then one value per %s of the IN list. "DELETE FROM %i WHERE created_at < %s AND type IN ({$in})", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- a list of %s placeholders. array_merge( array( $table, (string) $before_gmt ), $types ) ) ); } /** True when an event of this type and context was already recorded. */ public function has( $type, $context ) { global $wpdb; $table = Database::table( 'events' ); return (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table; live data, not cached. $wpdb->prepare( 'SELECT id FROM %i WHERE type = %s AND context = %s LIMIT 1', $table, (string) $type, (string) $context ) ); } public function count_since( $type, $since_gmt ) { global $wpdb; $table = Database::table( 'events' ); return (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- the plugin's own log table; live data, not cached. $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE type = %s AND created_at >= %s', $table, $type, $since_gmt ) ); } }