$row Optional `phone`, `user_id`, `gateway`, `via`, `message`, `ip`. */ public static function record( string $event, string $result, array $row = [] ): void { global $wpdb; $ip = array_key_exists( 'ip', $row ) ? (string) $row['ip'] : RateLimiter::clientIp(); $suppress = $wpdb->suppress_errors( true ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->insert( Schema::table( 'otp_log' ), [ 'event' => substr( $event, 0, 16 ), 'result' => substr( $result, 0, 16 ), 'phone' => substr( (string) ( $row['phone'] ?? '' ), 0, 32 ), 'user_id' => max( 0, (int) ( $row['user_id'] ?? 0 ) ), 'gateway' => substr( (string) ( $row['gateway'] ?? '' ), 0, 32 ), 'via' => substr( (string) ( $row['via'] ?? '' ), 0, 16 ), // Provider errors can be long and can echo request content back; // truncated rather than let a strict server reject the insert. 'message' => mb_substr( (string) ( $row['message'] ?? '' ), 0, 250 ), 'ip' => substr( $ip, 0, 45 ), 'created_at' => gmdate( 'Y-m-d H:i:s' ), ], [ '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s' ] ); $wpdb->suppress_errors( $suppress ); } /** * Rows matching the filters, newest first. * * @param array $filters `phone`, `event`, `result`, `gateway`. * @param int $limit Rows. * @param int $offset Rows to skip. * * @return list> */ public static function search( array $filters, int $limit, int $offset = 0 ): array { global $wpdb; [ $where, $args ] = self::where( $filters ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is built in where() from literal fragments; every value is a bound placeholder. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i {$where} ORDER BY id DESC LIMIT %d OFFSET %d", array_merge( [ Schema::table( 'otp_log' ) ], $args, [ max( 1, min( 500, $limit ) ), max( 0, $offset ) ] ) ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter return is_array( $rows ) ? array_values( $rows ) : []; } /** * How many rows match the filters. * * @param array $filters See {@see self::search()}. */ public static function count( array $filters ): int { global $wpdb; [ $where, $args ] = self::where( $filters ); // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where is built in where() from literal fragments; every value is a bound placeholder. $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i {$where}", array_merge( [ Schema::table( 'otp_log' ) ], $args ) ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter return (int) $count; } /** * How many real sends in a row have failed, most recent first. * * Test-mode rows are skipped rather than counted either way: a tester seeing * codes on screen says nothing about whether the gateway works. */ public static function consecutiveFailures(): int { global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $results = $wpdb->get_col( $wpdb->prepare( 'SELECT result FROM %i WHERE event = %s AND result <> %s ORDER BY id DESC LIMIT 100', Schema::table( 'otp_log' ), self::EVENT_SEND, self::RESULT_TEST ) ); $failures = 0; foreach ( (array) $results as $result ) { if ( self::RESULT_OK === $result ) { break; } ++$failures; } return $failures; } /** * Delete rows past the retention window. * * @return int Rows removed. */ public static function purge(): int { global $wpdb; $days = self::retentionDays(); if ( $days <= 0 ) { return 0; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery $removed = $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE created_at < %s LIMIT 5000', Schema::table( 'otp_log' ), gmdate( 'Y-m-d H:i:s', time() - $days * DAY_IN_SECONDS ) ) ); return (int) $removed; } /** * Days rows are kept for. Zero keeps them forever. */ public static function retentionDays(): int { return max( 0, (int) Settings::get( OtpAuthModule::SLUG . '.log_days', self::DEFAULT_DAYS ) ); } /** * The WHERE clause and its arguments for a set of filters. * * @param array $filters See {@see self::search()}. * * @return array{0: string, 1: list} */ private static function where( array $filters ): array { global $wpdb; $clauses = []; $args = []; if ( '' !== ( $filters['phone'] ?? '' ) ) { $clauses[] = 'phone LIKE %s'; $args[] = '%' . $wpdb->esc_like( $filters['phone'] ) . '%'; } foreach ( [ 'event', 'result', 'gateway' ] as $column ) { if ( '' !== ( $filters[ $column ] ?? '' ) ) { $clauses[] = $column . ' = %s'; $args[] = $filters[ $column ]; } } return [ [] === $clauses ? '' : 'WHERE ' . implode( ' AND ', $clauses ), $args ]; } }