> [ time, type, match, email, ip, user, repeats? ]. */ public static function all() { $entries = get_option( self::OPTION, array() ); $cutoff = time() - self::DAYS * DAY_IN_SECONDS; return array_values( array_filter( is_array( $entries ) ? $entries : array(), static fn( $e ) => is_array( $e ) && (int) ( $e['time'] ?? 0 ) >= $cutoff ) ); } /** * Add an entry. Skipped when the same attempt was logged in the last REPEAT seconds, or when * another request keeps the log locked (the checkout is blocked either way). * * @since 1.0.0 * @param array $entry Entry (type, match, email, ip, user). * @return void */ public static function add( array $entry ) { $now = time(); $entry = array( 'time' => $now, 'type' => self::cap( $entry['type'] ?? '', self::TEXT_LENGTH ), 'match' => self::cap( $entry['match'] ?? '', self::TEXT_LENGTH ), 'email' => self::cap( $entry['email'] ?? '', self::EMAIL_LENGTH ), 'ip' => self::cap( $entry['ip'] ?? '', self::TEXT_LENGTH ), 'user' => (int) ( $entry['user'] ?? 0 ), 'repeats' => 0, ); $token = ''; for ( $try = 0; $try < 3 && '' === $token; $try++ ) { if ( $try ) { usleep( 50000 ); } $token = Lock::acquire( self::LOCK, 10 ); } if ( '' === $token ) { return; } try { // Read the stored list, not this request's copy: another request may have added to it. wp_cache_delete( self::OPTION, 'options' ); $entries = self::all(); foreach ( $entries as $old ) { if ( (int) ( $old['time'] ?? 0 ) < $now - self::REPEAT ) { break; // Newest first: the rest are older. } if ( self::same( $old, $entry ) ) { return; } } $latest = self::over_ip_limit( $entries, $entry['ip'], $now ); if ( null !== $latest ) { $entries[ $latest ]['repeats'] = (int) ( $entries[ $latest ]['repeats'] ?? 0 ) + 1; update_option( self::OPTION, $entries, false ); return; } array_unshift( $entries, $entry ); update_option( self::OPTION, array_slice( $entries, 0, self::MAX ), false ); } finally { Lock::release( self::LOCK, $token ); } } /** * When this IP address already added its limit of rows in the last IP_WINDOW seconds: the * index of its newest row, where the attempt is counted instead. Otherwise null. * * @param array> $entries Entries, newest first. * @param string $ip IP address ('' is never limited). * @param int $now Current time. * @return int|null */ private static function over_ip_limit( array $entries, $ip, $now ) { if ( '' === $ip ) { return null; } $limit = self::ip_limit(); $rows = 0; $latest = null; foreach ( $entries as $index => $old ) { if ( (int) ( $old['time'] ?? 0 ) < $now - self::IP_WINDOW ) { break; } if ( (string) ( $old['ip'] ?? '' ) === $ip ) { $latest = $latest ?? $index; ++$rows; } } return $rows >= $limit ? $latest : null; } /** * Rows one IP address may add per IP_WINDOW seconds (1–200). * * @since 1.0.0 * @return int */ public static function ip_limit() { /** * Filter how many rows one IP address may add to the blocked-attempts log per 10 minutes. * Further attempts from it are counted on its newest row. Kept within 1–200. * * @since 1.0.0 * @param int $limit Rows. Default 10. */ $limit = apply_filters( 'apwc_blocklist_log_ip_limit', self::IP_LIMIT ); $limit = is_numeric( $limit ) ? (int) $limit : self::IP_LIMIT; return max( 1, min( self::MAX, $limit ) ); } /** * Whether two entries record the same attempt (time aside). * * @param array $a Stored entry. * @param array $b New entry. * @return bool */ private static function same( array $a, array $b ) { foreach ( array( 'type', 'match', 'email', 'ip' ) as $key ) { if ( (string) ( $a[ $key ] ?? '' ) !== $b[ $key ] ) { return false; } } return (int) ( $a['user'] ?? 0 ) === $b['user']; } /** * A string cut to at most $length characters (never mid-character). * * @param mixed $value Value. * @param int $length Characters. * @return string */ private static function cap( $value, $length ) { $value = Str::scalar( $value ); $value = wp_check_invalid_utf8( $value, true ); return mb_strlen( $value ) > $length ? mb_substr( $value, 0, $length ) : $value; } /** * Drop expired entries (daily clean-up; the list also trims itself on every write). * * @since 1.0.0 * @return void */ public static function prune() { $stored = get_option( self::OPTION, null ); if ( ! is_array( $stored ) ) { return; } $fresh = self::all(); if ( ! $fresh ) { delete_option( self::OPTION ); } elseif ( count( $fresh ) !== count( $stored ) ) { update_option( self::OPTION, $fresh, false ); } } }