' . esc_html__( 'Dashboard', 'cb-bot-guard' ) . '' ); if ( self::show_pro() ) { $links[] = '' . esc_html__( 'Get Pro', 'cb-bot-guard' ) . ''; } return $links; } /** * Scripts and styles, only on this plugin's screen. * * @param string $hook Screen hook. */ public static function assets( $hook ) { if ( 'toplevel_page_' . self::SLUG !== $hook ) { return; } $url = plugins_url( 'assets/', CBBG_FILE ); wp_enqueue_style( 'cbbg-admin', $url . 'admin.css', array(), CBBG_VERSION ); wp_enqueue_script( 'cbbg-admin', $url . 'admin.js', array(), CBBG_VERSION, true ); wp_localize_script( 'cbbg-admin', 'cbbgAdmin', array( 'bulkEmpty' => __( 'Choose a bulk action and select at least one row.', 'cb-bot-guard' ), /* translators: 1: action name, 2: number of rows. */ 'bulkAsk' => __( 'Apply "%1$s" to %2$d selected row(s)?', 'cb-bot-guard' ), ) ); } // Helpers. /** * Whether to show Pro links (hidden once the Pro add-on is active). * * @return bool */ private static function show_pro() { return ! defined( 'CBBG_PRO_VERSION' ) && '' !== CBBG_PRO_URL && apply_filters( 'cbbg_show_pro', true ); } /** * Pro page URL with campaign tags. * * @param string $where Placement. * @return string */ private static function pro_url( $where ) { return add_query_arg( array( 'utm_source' => 'plugin', 'utm_medium' => sanitize_key( $where ), 'utm_campaign' => 'cb-bot-guard', ), CBBG_PRO_URL ); } /** * Allowed HTML for helper output. * * @return array */ private static function kses() { $common = array( 'class' => true, 'title' => true, 'style' => true, ); return array( 'a' => array_merge( $common, array( 'href' => true, 'target' => true, 'rel' => true, 'data-cbbg-confirm' => true, ) ), 'span' => $common, 'div' => $common, 'h2' => $common, 'strong' => array(), 'em' => array(), 'code' => array(), 'br' => array(), 'p' => $common, 'ul' => $common, 'li' => $common, 'table' => $common, 'thead' => array(), 'tbody' => array(), 'tr' => $common, 'th' => $common, 'td' => array_merge( $common, array( 'colspan' => true ) ), 'input' => array( 'type' => true, 'name' => true, 'value' => true, 'class' => true, ), ); } /** * Echo helper HTML through wp_kses. * * @param string $html HTML built from escaped parts. */ private static function out( $html ) { echo wp_kses( $html, self::kses() ); } /** * Translated label for an event type. * * @param string $type Type. * @return string */ public static function event_label( $type ) { $labels = array( 'rate_limit' => __( 'Rate limit exceeded', 'cb-bot-guard' ), 'rate_heavy' => __( 'Heavy-page flood', 'cb-bot-guard' ), 'bad_ua' => __( 'Bad user agent', 'cb-bot-guard' ), 'seo_bot' => __( 'Aggressive SEO crawler', 'cb-bot-guard' ), 'ai_bot' => __( 'AI crawler', 'cb-bot-guard' ), 'empty_ua' => __( 'Empty user agent', 'cb-bot-guard' ), 'fake_bot' => __( 'Fake search engine bot', 'cb-bot-guard' ), 'probe' => __( 'Vulnerability probe', 'cb-bot-guard' ), 'honeypot' => __( 'Honeypot trap', 'cb-bot-guard' ), 'xmlrpc' => __( 'XML-RPC request', 'cb-bot-guard' ), 'login_fail' => __( 'Failed login', 'cb-bot-guard' ), '404' => __( 'Page not found (404)', 'cb-bot-guard' ), 'comment_spam' => __( 'Spam comment', 'cb-bot-guard' ), 'blocked' => __( 'Blocked request', 'cb-bot-guard' ), 'manual' => __( 'Admin action', 'cb-bot-guard' ), ); return isset( $labels[ $type ] ) ? $labels[ $type ] : CBBG_Store::event_label( $type ); } /** * Admin page URL. * * @param array $args Query args. * @return string */ public static function url( $args = array() ) { return add_query_arg( array_merge( array( 'page' => self::SLUG ), $args ), admin_url( 'admin.php' ) ); } /** * Nonced admin-post URL for an action. * * @param string $action Action. * @param array $args Args. * @return string */ private static function action_url( $action, $args = array() ) { $url = add_query_arg( array_merge( array( 'action' => 'cbbg', 'cbbg_do' => $action, ), $args ), admin_url( 'admin-post.php' ) ); return wp_nonce_url( $url, 'cbbg_action' ); } /** * Open a POST form to admin-post.php. * * @param string $action Action. * @param string $css_class CSS class. */ private static function form_open( $action, $css_class = '' ) { printf( '
', esc_url( admin_url( 'admin-post.php' ) ), esc_attr( $css_class ), esc_attr( $action ) ); wp_nonce_field( 'cbbg_action' ); } /** * "5 minutes ago" / "in 3 hours" with exact time on hover. * * @param int $t Unix time. * @return string HTML. */ private static function ago( $t ) { $t = (int) $t; if ( ! $t ) { return '—'; } $now = time(); /* translators: %s: human-readable time difference. */ $text = $t <= $now ? sprintf( __( '%s ago', 'cb-bot-guard' ), human_time_diff( $t, $now ) ) : sprintf( __( 'in %s', 'cb-bot-guard' ), human_time_diff( $now, $t ) ); return '' . esc_html( $text ) . ''; } /** * Status labels. * * @return array */ private static function status_labels() { return array( 'blocked' => __( 'Blocked', 'cb-bot-guard' ), 'allowed' => __( 'Allowed', 'cb-bot-guard' ), 'verified' => __( 'Verified bot', 'cb-bot-guard' ), 'watch' => __( 'Watching', 'cb-bot-guard' ), 'expired' => __( 'Block expired', 'cb-bot-guard' ), ); } /** * Status badge. * * @param string $status Status. * @param object|null $row Row. * @return string HTML. */ private static function badge( $status, $row = null ) { if ( $row && CBBG_Store::STATUS_BLOCKED === $status && ! CBBG_Store::is_active_block( $row ) ) { $status = CBBG_Store::STATUS_EXPIRED; } $labels = self::status_labels(); $label = isset( $labels[ $status ] ) ? $labels[ $status ] : $status; return '' . esc_html( $label ) . ''; } /** * On/off badge for a feature. * * @param bool $on Whether the feature is on. * @return string HTML. */ private static function onoff( $on ) { return $on ? '' . esc_html__( 'On', 'cb-bot-guard' ) . '' : '' . esc_html__( 'Off', 'cb-bot-guard' ) . ''; } /** * Status badge for any IP, including one only covered by a blocked range. * * @param string $ip IP. * @param object|null $row Row. * @return string HTML. */ private static function ip_status( $ip, $row ) { if ( $row && ( CBBG_Store::is_active_block( $row ) || in_array( $row->status, array( 'allowed', 'verified' ), true ) ) ) { return self::badge( $row->status, $row ); } $rule = CBBG_Gate::blocked_rule( $ip ); if ( '' !== $rule && $rule !== $ip ) { /* translators: %s: IP range. */ $title = sprintf( __( 'Covered by %s', 'cb-bot-guard' ), $rule ); return '' . esc_html__( 'Blocked by range', 'cb-bot-guard' ) . ''; } return $row ? self::badge( $row->status, $row ) : '' . esc_html__( 'Not listed', 'cb-bot-guard' ) . ''; } /** * Event type badge. * * @param string $type Type. * @return string HTML. */ private static function type_badge( $type ) { return '' . esc_html( self::event_label( $type ) ) . ''; } /** * Expiry cell. * * @param object $row Row. * @return string HTML. */ private static function expires( $row ) { if ( CBBG_Store::STATUS_BLOCKED !== $row->status ) { return '—'; } return 0 === (int) $row->expires_at ? '' . esc_html__( 'Permanent', 'cb-bot-guard' ) . '' : self::ago( $row->expires_at ); } /** * Link to an IP's detail view. * * @param string $ip IP. * @return string HTML. */ private static function ip_link( $ip ) { return '' . esc_html( $ip ) . ''; } /** * Block / unblock / allow / remove links. * * @param object|string $row_or_ip Row or IP. * @return string HTML. */ private static function row_actions( $row_or_ip ) { $ip = is_object( $row_or_ip ) ? $row_or_ip->ip : $row_or_ip; $row = is_object( $row_or_ip ) ? $row_or_ip : CBBG_Store::get( $ip ); $out = array(); $active = $row && CBBG_Store::is_active_block( $row ); if ( $active ) { $out[] = '' . esc_html__( 'Unblock', 'cb-bot-guard' ) . ''; } else { $out[] = '' . esc_html__( 'Block', 'cb-bot-guard' ) . ''; } if ( ! $row || CBBG_Store::STATUS_ALLOWED !== $row->status ) { /* translators: %s: IP address. */ $ask = sprintf( __( 'Always allow %s? It will never be blocked.', 'cb-bot-guard' ), $ip ); $out[] = '' . esc_html__( 'Allow', 'cb-bot-guard' ) . ''; } if ( $row ) { /* translators: %s: IP address. */ $ask = sprintf( __( 'Remove %s from the list?', 'cb-bot-guard' ), $ip ); $out[] = '' . esc_html__( 'Remove', 'cb-bot-guard' ) . ''; } return '' . implode( ' · ', $out ) . ''; } /** * Truncated user agent with the full value on hover. * * @param string $ua User agent. * @param int $len Length. * @return string HTML. */ private static function short_ua( $ua, $len = 70 ) { $ua = (string) $ua; if ( '' === $ua ) { return '' . esc_html__( '(empty)', 'cb-bot-guard' ) . ''; } $s = function_exists( 'mb_strimwidth' ) ? mb_strimwidth( $ua, 0, $len, '…' ) : substr( $ua, 0, $len ); return '' . esc_html( $s ) . ''; } /** * Pager. * * @param int $total Total rows. * @param int $per Per page. * @param int $paged Current page. * @param array $args Query args. */ private static function pagination( $total, $per, $paged, $args ) { $pages = (int) ceil( $total / $per ); if ( $pages < 2 ) { return; } echo '
'; /* translators: %s: number of items. */ echo esc_html( sprintf( _n( '%s item', '%s items', $total, 'cb-bot-guard' ), number_format_i18n( $total ) ) ); echo ''; if ( $paged > 1 ) { echo '' . esc_html__( '‹ Previous', 'cb-bot-guard' ) . ''; } /* translators: 1: current page, 2: total pages. */ echo '' . esc_html( sprintf( __( 'Page %1$d of %2$d', 'cb-bot-guard' ), $paged, $pages ) ) . ''; if ( $paged < $pages ) { echo '' . esc_html__( 'Next ›', 'cb-bot-guard' ) . ''; } echo '
'; } /** * The current administrator's IP. * * @return string */ private static function my_ip() { return CBBG_IP::client( (bool) CBBG_Settings::get( 'cloudflare' ) ); } /** * Duration options. * * @param bool $with_auto Include "automatic". * @return array */ private static function durations( $with_auto = false ) { $d = array( 'perm' => __( 'Permanently', 'cb-bot-guard' ), '1h' => __( '1 hour', 'cb-bot-guard' ), '24h' => __( '24 hours', 'cb-bot-guard' ), '7d' => __( '7 days', 'cb-bot-guard' ), '30d' => __( '30 days', 'cb-bot-guard' ), ); if ( $with_auto ) { $d = array( 'auto' => __( 'Automatic length', 'cb-bot-guard' ) ) + $d; } return $d; } /** * Print a duration '; foreach ( self::durations() as $k => $label ) { echo ''; } echo ''; } // Actions. /** * Store a notice and redirect. * * @param string $msg Message. * @param string $type success|error|warning. */ private static function done( $msg, $type = 'success' ) { set_transient( 'cbbg_msg_' . get_current_user_id(), array( 'msg' => $msg, 'type' => $type, ), 120 ); $to = wp_get_referer(); wp_safe_redirect( $to ? $to : self::url() ); exit; } /** * Seconds for a duration key. * * @param string $key Key. * @return int|null Null = automatic, 0 = permanent. */ private static function duration_seconds( $key ) { $map = array( 'auto' => null, '1h' => HOUR_IN_SECONDS, '24h' => DAY_IN_SECONDS, '7d' => 7 * DAY_IN_SECONDS, '30d' => 30 * DAY_IN_SECONDS, 'perm' => 0, ); return array_key_exists( $key, $map ) ? $map[ $key ] : 0; } /** * Whether blocking $target would lock out the current administrator. * * @param string $target IP or range. * @return bool */ private static function would_lock_out( $target ) { $mine = self::my_ip(); return $mine && CBBG_IP::in_cidr( $mine, $target ); } /** * Read a request field as a sanitized string (nonce already verified). * * @param string $key Field. * @return string */ private static function field( $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Called only from handle() after check_admin_referer(). return isset( $_REQUEST[ $key ] ) ? sanitize_text_field( wp_unslash( $_REQUEST[ $key ] ) ) : ''; } /** * Handle all admin actions (admin-post.php?action=cbbg). */ public static function handle() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You are not allowed to do that.', 'cb-bot-guard' ), 403 ); } check_admin_referer( 'cbbg_action' ); $action = sanitize_key( self::field( 'cbbg_do' ) ); $ip = CBBG_IP::normalize( self::field( 'ip' ) ); $reason = self::field( 'reason' ); if ( 'block' === $action && 'allow' === self::field( 'mode' ) ) { $action = 'allow'; } switch ( $action ) { case 'block': case 'block_range': if ( 'block_range' === $action && $ip ) { $ip = CBBG_IP::network_of( $ip ); } if ( ! $ip ) { self::done( __( 'That is not a valid IP address or range.', 'cb-bot-guard' ), 'error' ); } if ( self::would_lock_out( $ip ) ) { /* translators: %s: IP or range. */ self::done( sprintf( __( 'Not blocked: %s includes your own IP address and would lock you out.', 'cb-bot-guard' ), $ip ), 'error' ); } $reason = '' !== $reason ? $reason : 'Blocked by admin'; $dur = sanitize_key( self::field( 'duration' ) ); CBBG_Store::block( $ip, $reason, self::duration_seconds( $dur ? $dur : 'perm' ), 'manual' ); CBBG_Store::log( $ip, 'manual', 1, 'Blocked: ' . $reason, '', '' ); /* translators: %s: IP or range. */ self::done( sprintf( __( 'Blocked %s.', 'cb-bot-guard' ), $ip ) ); break; case 'unblock': CBBG_Store::unblock( $ip ); CBBG_Store::log( $ip, 'manual', 1, 'Unblocked', '', '' ); /* translators: %s: IP or range. */ self::done( sprintf( __( 'Unblocked %s. It stays on the list as "Watching".', 'cb-bot-guard' ), $ip ) ); break; case 'allow': if ( ! $ip ) { self::done( __( 'That is not a valid IP address or range.', 'cb-bot-guard' ), 'error' ); } CBBG_Store::allow( $ip, '' !== $reason ? $reason : 'Allowed by admin' ); /* translators: %s: IP or range. */ self::done( sprintf( __( 'Allowed %s. It will never be blocked.', 'cb-bot-guard' ), $ip ) ); break; case 'delete': CBBG_Store::remove( $ip ); /* translators: %s: IP or range. */ self::done( sprintf( __( 'Removed %s from the list.', 'cb-bot-guard' ), $ip ) ); break; case 'bulk': $raw = isset( $_POST['ips'] ) ? array_map( 'sanitize_text_field', wp_unslash( (array) $_POST['ips'] ) ) : array(); $ips = array_filter( array_map( array( 'CBBG_IP', 'normalize' ), $raw ) ); $bulk = sanitize_key( self::field( 'bulk_action' ) ); $n = 0; foreach ( $ips as $one ) { if ( 'block' === $bulk && ! self::would_lock_out( $one ) ) { CBBG_Store::block( $one, 'Blocked by admin (bulk)', 0, 'manual' ); } elseif ( 'unblock' === $bulk ) { CBBG_Store::unblock( $one ); } elseif ( 'allow' === $bulk ) { CBBG_Store::allow( $one, 'Allowed by admin (bulk)' ); } elseif ( 'delete' === $bulk ) { CBBG_Store::remove( $one ); } else { continue; } ++$n; } if ( $n ) { /* translators: %d: number of rows. */ self::done( sprintf( _n( 'Updated %d row.', 'Updated %d rows.', $n, 'cb-bot-guard' ), $n ) ); } self::done( __( 'Nothing selected.', 'cb-bot-guard' ), 'warning' ); break; case 'save_settings': $was = CBBG_Settings::all(); $input = isset( $_POST['cbbg'] ) && is_array( $_POST['cbbg'] ) ? CBBG_Settings::sanitize_input( wp_unslash( $_POST['cbbg'] ) ) : array(); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized by CBBG_Settings::sanitize_input(). $new = CBBG_Settings::save( $input ); if ( ! $new['htaccess_sync'] || $new['cloudflare'] ) { CBBG_Store::write_htaccess( array() ); } CBBG_Store::rebuild(); $msg = __( 'Settings saved.', 'cb-bot-guard' ); if ( $new['htaccess_sync'] && ! $was['htaccess_sync'] && ! $new['cloudflare'] ) { $msg .= ' ' . __( '.htaccess blocking is on: open your site in another tab now to confirm it still loads.', 'cb-bot-guard' ); } self::done( $msg ); break; case 'reset_settings': delete_option( CBBG_Settings::OPTION ); CBBG_Settings::flush(); CBBG_Settings::save( CBBG_Settings::defaults() ); CBBG_Store::write_htaccess( array() ); CBBG_Store::rebuild(); self::done( __( 'Settings reset to defaults. Your IP lists were kept.', 'cb-bot-guard' ) ); break; case 'clear_log': global $wpdb; $wpdb->query( $wpdb->prepare( 'TRUNCATE TABLE %i', CBBG_Store::t_log() ) ); self::done( __( 'Activity log cleared.', 'cb-bot-guard' ) ); break; case 'clear_watch': global $wpdb; $n = (int) $wpdb->query( $wpdb->prepare( "DELETE FROM %i WHERE status IN ('watch','expired')", CBBG_Store::t_ips() ) ); /* translators: %d: number of rows. */ self::done( sprintf( _n( 'Removed %d watched or expired entry.', 'Removed %d watched or expired entries.', $n, 'cb-bot-guard' ), $n ) ); break; case 'import': $list = isset( $_POST['list'] ) ? sanitize_textarea_field( wp_unslash( $_POST['list'] ) ) : ''; $mode = 'allow' === self::field( 'import_mode' ) ? 'allow' : 'block'; $note = '' !== $reason ? $reason : 'Imported'; $ok = 0; $bad = 0; foreach ( preg_split( '/[\s,;]+/', $list ) as $line ) { $one = CBBG_IP::normalize( $line ); if ( ! $one ) { $bad += '' !== trim( $line ) ? 1 : 0; continue; } if ( 'allow' === $mode ) { CBBG_Store::allow( $one, $note ); } elseif ( ! self::would_lock_out( $one ) ) { CBBG_Store::block( $one, $note, 0, 'manual' ); } else { ++$bad; continue; } ++$ok; } /* translators: 1: imported count, 2: skipped count. */ self::done( sprintf( __( 'Imported %1$d entries, skipped %2$d.', 'cb-bot-guard' ), $ok, $bad ) ); break; case 'export': self::export_csv(); break; case 'mu_install': $ok = CBBG_Store::install_mu_loader(); self::done( $ok ? __( 'Early blocking is on. Blocked bots are now stopped before other plugins load.', 'cb-bot-guard' ) : __( 'Could not write to the mu-plugins folder. Check folder permissions.', 'cb-bot-guard' ), $ok ? 'success' : 'error' ); break; case 'mu_remove': CBBG_Store::remove_mu_loader(); self::done( __( 'Early blocking turned off. Blocking still works, just later in the page load.', 'cb-bot-guard' ) ); break; case 'sync_now': CBBG_Store::frequent(); CBBG_Store::rebuild(); self::done( __( 'Blocklist rebuilt and buffered counts updated.', 'cb-bot-guard' ) ); break; default: /** * Lets add-ons handle their own admin actions (nonce and capability already checked). * * @param string $action Action key. */ do_action( 'cbbg_admin_action_' . $action ); self::done( __( 'Unknown action.', 'cb-bot-guard' ), 'error' ); } } /** * Stream the IP list as CSV. */ private static function export_csv() { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( 'SELECT ip, status, reason, source, score, events, blocked_hits, block_count, first_seen, last_seen, expires_at, last_ua FROM %i ORDER BY status, last_seen DESC', CBBG_Store::t_ips() ), ARRAY_A ); nocache_headers(); header( 'Content-Type: text/csv; charset=utf-8' ); header( 'Content-Disposition: attachment; filename=cb-bot-guard-ips-' . gmdate( 'Y-m-d' ) . '.csv' ); $lines = array(); $lines[] = array( 'ip', 'status', 'reason', 'source', 'score', 'events', 'blocked_hits', 'block_count', 'first_seen', 'last_seen', 'expires', 'last_user_agent' ); foreach ( (array) $rows as $r ) { foreach ( array( 'first_seen', 'last_seen', 'expires_at' ) as $k ) { $r[ $k ] = $r[ $k ] ? gmdate( 'Y-m-d H:i:s', (int) $r[ $k ] ) . ' UTC' : ''; } $lines[] = $r; } foreach ( $lines as $line ) { $cells = array(); foreach ( $line as $v ) { $v = (string) $v; // Neutralise spreadsheet formulas in attacker-controlled text. if ( preg_match( '/^[=+\-@]/', $v ) ) { $v = "'" . $v; } $cells[] = '"' . str_replace( '"', '""', $v ) . '"'; } echo implode( ',', $cells ) . "\r\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSV download, quoted and formula-neutralised above. } exit; } // Page. /** * Tabs. * * @return array */ private static function tabs() { $tabs = array( 'dashboard' => __( 'Dashboard', 'cb-bot-guard' ), 'ips' => __( 'IP Manager', 'cb-bot-guard' ), 'log' => __( 'Activity Log', 'cb-bot-guard' ), 'settings' => __( 'Settings', 'cb-bot-guard' ), 'tools' => __( 'Tools', 'cb-bot-guard' ), ); if ( self::show_pro() ) { $tabs['pro'] = __( 'Pro', 'cb-bot-guard' ); } /** * Filters the dashboard tabs. Render custom tabs with the * "cbbg_render_tab_{$tab}" action. * * @param array $tabs Key => label. */ return apply_filters( 'cbbg_admin_tabs', $tabs ); } /** * Render the page. */ public static function render() { if ( ! current_user_can( self::CAP ) ) { return; } // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only view parameters. $tab = isset( $_GET['tab'] ) ? sanitize_key( $_GET['tab'] ) : 'dashboard'; $tabs = self::tabs(); if ( ! isset( $tabs[ $tab ] ) ) { $tab = 'dashboard'; } $on = CBBG_Settings::get( 'enabled' ) && ! ( defined( 'CBBG_DISABLE' ) && CBBG_DISABLE ); echo '
'; echo '

' . esc_html__( 'CB Bot Guard', 'cb-bot-guard' ) . '

'; printf( ' %3$s', esc_attr( $on ? 'is-on' : 'is-off' ), esc_attr( $on ? 'dashicons-yes-alt' : 'dashicons-warning' ), esc_html( $on ? __( 'Protection on', 'cb-bot-guard' ) : __( 'Protection off', 'cb-bot-guard' ) ) ); echo '' . esc_html__( 'Your IP:', 'cb-bot-guard' ) . ' ' . esc_html( self::my_ip() ) . '
'; // The

above anchors core notices; ours goes right after it. echo '
'; $msg = get_transient( 'cbbg_msg_' . get_current_user_id() ); if ( is_array( $msg ) && isset( $msg['msg'], $msg['type'] ) ) { delete_transient( 'cbbg_msg_' . get_current_user_id() ); printf( '

%2$s

', esc_attr( $msg['type'] ), esc_html( $msg['msg'] ) ); } if ( ! CBBG_Store::ensure_data_dir() ) { /* translators: %s: folder path. */ printf( '

%s

', esc_html( sprintf( __( 'CB Bot Guard cannot write to %s. Blocking needs this folder: make it writable by the web server.', 'cb-bot-guard' ), CBBG_Store::data_dir() ) ) ); } echo '
'; switch ( $tab ) { case 'ips': if ( ! empty( $_GET['ip'] ) ) { self::tab_ip_detail( CBBG_IP::normalize( sanitize_text_field( wp_unslash( $_GET['ip'] ) ) ) ); } else { self::tab_ips(); } break; case 'log': self::tab_log(); break; case 'settings': self::tab_settings(); break; case 'tools': self::tab_tools(); break; case 'pro': self::tab_pro(); break; case 'dashboard': self::tab_dashboard(); break; default: /** * Renders a custom tab registered with "cbbg_admin_tabs". */ do_action( 'cbbg_render_tab_' . $tab ); } echo '

'; // phpcs:enable WordPress.Security.NonceVerification.Recommended } // Dashboard. /** * Dashboard tab. */ private static function tab_dashboard() { global $wpdb; $ips = CBBG_Store::t_ips(); $log = CBBG_Store::t_log(); $now = time(); $since = $now - DAY_IN_SECONDS; $blocked = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'blocked' AND (expires_at = 0 OR expires_at > %d)", $ips, $now ) ); $watch = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'watch'", $ips ) ); $allowed = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'allowed'", $ips ) ); $verified = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'verified' AND (expires_at = 0 OR expires_at > %d)", $ips, $now ) ); $hits24 = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(cnt),0) FROM %i WHERE type = 'blocked' AND t >= %d", $log, $since ) ); $det24 = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COALESCE(SUM(cnt),0) FROM %i WHERE type NOT IN ('blocked','manual') AND t >= %d", $log, $since ) ); $new24 = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE status = 'blocked' AND blocked_at >= %d", $ips, $since ) ); $buffered = CBBG_Store::buffered_hits(); echo '
'; /* translators: %s: number. */ self::card( __( 'Blocked IPs now', 'cb-bot-guard' ), number_format_i18n( $blocked ), sprintf( __( '%s new in the last 24h', 'cb-bot-guard' ), number_format_i18n( $new24 ) ), 'blocked' ); /* translators: %s: number. */ self::card( __( 'Requests stopped (24h)', 'cb-bot-guard' ), number_format_i18n( $hits24 + $buffered ), $buffered ? sprintf( __( '%s waiting to be counted', 'cb-bot-guard' ), number_format_i18n( $buffered ) ) : __( 'Answered with a tiny 403/429', 'cb-bot-guard' ), 'neutral' ); self::card( __( 'Suspicious events (24h)', 'cb-bot-guard' ), number_format_i18n( $det24 ), __( 'Floods, probes, bad bots, failed logins', 'cb-bot-guard' ), 'neutral' ); self::card( __( 'Watching', 'cb-bot-guard' ), number_format_i18n( $watch ), __( 'Seen misbehaving, not yet blocked', 'cb-bot-guard' ), 'neutral' ); self::card( __( 'Allowed / verified bots', 'cb-bot-guard' ), number_format_i18n( $allowed ) . ' / ' . number_format_i18n( $verified ), __( 'Never blocked', 'cb-bot-guard' ), 'neutral' ); echo '
'; self::setup_checklist(); // Hourly series for the last 24 hours. $start = (int) ( floor( $now / HOUR_IN_SECONDS ) * HOUR_IN_SECONDS ) - 23 * HOUR_IN_SECONDS; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT FLOOR(t/3600)*3600 AS h, SUM(CASE WHEN type = 'blocked' THEN cnt ELSE 0 END) AS b, SUM(CASE WHEN type NOT IN ('blocked','manual') THEN cnt ELSE 0 END) AS d FROM %i WHERE t >= %d GROUP BY h", $log, $start ) ); $hits = array(); $dets = array(); for ( $i = 0; $i < 24; $i++ ) { $h = $start + $i * HOUR_IN_SECONDS; $hits[ $h ] = 0; $dets[ $h ] = 0; } foreach ( (array) $rows as $r ) { $h = (int) $r->h; if ( isset( $hits[ $h ] ) ) { $hits[ $h ] = (int) $r->b; $dets[ $h ] = (int) $r->d; } } echo '

' . esc_html__( 'Last 24 hours', 'cb-bot-guard' ) . '

'; self::bar_chart( __( 'Requests stopped per hour', 'cb-bot-guard' ), $hits, __( 'requests stopped', 'cb-bot-guard' ) ); self::bar_chart( __( 'Suspicious events per hour', 'cb-bot-guard' ), $dets, __( 'events', 'cb-bot-guard' ) ); echo '

' . esc_html__( 'Stopped requests are counted in batches every 5 minutes.', 'cb-bot-guard' ) . '

'; // Event types. $types = $wpdb->get_results( $wpdb->prepare( "SELECT type, SUM(cnt) AS n FROM %i WHERE t >= %d AND type NOT IN ('blocked','manual') GROUP BY type ORDER BY n DESC", $log, $since ) ); echo '

' . esc_html__( 'What triggered (24h)', 'cb-bot-guard' ) . '

'; if ( $types ) { $max = max( 1, (int) $types[0]->n ); $html = '' ); } else { echo '

' . esc_html__( 'Nothing suspicious in the last 24 hours.', 'cb-bot-guard' ) . '

'; } echo '
'; // Top offenders. $top = $wpdb->get_results( $wpdb->prepare( "SELECT ip, SUM(cnt) AS n, SUM(CASE WHEN type = 'blocked' THEN cnt ELSE 0 END) AS b, MAX(t) AS last FROM %i WHERE t >= %d AND type <> 'manual' GROUP BY ip ORDER BY n DESC LIMIT 10", $log, $since ) ); echo '

' . esc_html__( 'Top offending IPs (24h)', 'cb-bot-guard' ) . '

'; if ( $top ) { $html = ''; foreach ( $top as $t ) { $row = CBBG_Store::get( $t->ip ); $html .= ''; } self::out( $html . '
' . esc_html__( 'IP', 'cb-bot-guard' ) . '' . esc_html__( 'Status', 'cb-bot-guard' ) . '' . esc_html__( 'Requests logged', 'cb-bot-guard' ) . '' . esc_html__( 'Stopped', 'cb-bot-guard' ) . '' . esc_html__( 'Last seen', 'cb-bot-guard' ) . '' . esc_html__( 'Actions', 'cb-bot-guard' ) . '
' . self::ip_link( $t->ip ) . '' . self::ip_status( $t->ip, $row ) . '' . esc_html( number_format_i18n( (int) $t->n ) ) . '' . esc_html( number_format_i18n( (int) $t->b ) ) . '' . self::ago( $t->last ) . '' . self::row_actions( $row ? $row : $t->ip ) . '
' ); } else { echo '

' . esc_html__( 'No offenders yet.', 'cb-bot-guard' ) . '

'; } echo '
'; // Top user agents. $uas = $wpdb->get_results( $wpdb->prepare( "SELECT ua, SUM(cnt) AS n, COUNT(DISTINCT ip) AS ips FROM %i WHERE t >= %d AND type <> 'manual' GROUP BY ua ORDER BY n DESC LIMIT 8", $log, $since ) ); echo '

' . esc_html__( 'Top user agents (24h)', 'cb-bot-guard' ) . '

'; if ( $uas ) { $html = ''; foreach ( $uas as $u ) { /* translators: %s: number of IP addresses. */ $count = sprintf( _n( '%s IP', '%s IPs', (int) $u->ips, 'cb-bot-guard' ), number_format_i18n( (int) $u->ips ) ); $html .= ''; } self::out( $html . '
' . self::short_ua( $u->ua, 48 ) . '
' . esc_html( $count ) . '
' . esc_html( number_format_i18n( (int) $u->n ) ) . '
' ); } else { echo '

' . esc_html__( 'No data yet.', 'cb-bot-guard' ) . '

'; } echo '
'; } /** * Stat card. * * @param string $label Label. * @param string $value Formatted value. * @param string $sub Sub line. * @param string $tone Tone. */ private static function card( $label, $value, $sub, $tone ) { printf( '
%2$s
%3$s
%4$s
', esc_attr( $tone ), esc_html( $label ), esc_html( $value ), esc_html( $sub ) ); } /** * Single-series hourly bar chart (CSS only) with hover tooltips and a table view. * * @param string $title Title. * @param array $values Hour start => value. * @param string $unit Unit label. */ private static function bar_chart( $title, array $values, $unit ) { $peak = max( $values ); $max = max( 1, $peak ); $total = array_sum( $values ); /* translators: 1: total, 2: peak value. */ $meta = sprintf( __( '%1$s total · peak %2$s', 'cb-bot-guard' ), number_format_i18n( $total ), number_format_i18n( $peak ) ); $html = '
' . esc_html( $title ) . '' . esc_html( $meta ) . '
'; $html .= '
' . esc_html( number_format_i18n( $max ) ) . '0
'; $i = 0; foreach ( $values as $h => $v ) { $pct = $v ? max( 2, round( 100 * $v / $max, 2 ) ) : 0; $range = wp_date( 'H:00', $h ) . '–' . wp_date( 'H:00', $h + HOUR_IN_SECONDS ); $html .= '
' . esc_html( number_format_i18n( $v ) ) . ' ' . esc_html( $unit ) . '
' . esc_html( $range ) . '
'; $html .= '' . ( 0 === $i % 3 ? esc_html( wp_date( 'H:00', $h ) ) : '' ) . '
'; ++$i; } $html .= '
'; self::out( $html ); echo '
' . esc_html__( 'Table view', 'cb-bot-guard' ) . ''; foreach ( $values as $h => $v ) { echo ''; } echo '
' . esc_html( wp_date( 'H:00', $h ) ) . '' . esc_html( number_format_i18n( $v ) ) . '
'; } /** * Recommended next steps (only shown on this plugin's dashboard). */ private static function setup_checklist() { $items = array(); if ( ! CBBG_Store::mu_installed() ) { $items[] = '' . esc_html__( 'Turn on early blocking', 'cb-bot-guard' ) . ' ' . esc_html__( 'so blocked bots are rejected before other plugins and the theme load (the biggest RAM saving inside WordPress).', 'cb-bot-guard' ) . ' ' . esc_html__( 'Turn on', 'cb-bot-guard' ) . ''; } if ( ! CBBG_Settings::get( 'htaccess_sync' ) && ! CBBG_Settings::get( 'cloudflare' ) ) { $items[] = '' . esc_html__( 'Optional: block at the server level.', 'cb-bot-guard' ) . ' ' . esc_html__( 'On Apache, blocked IPs can be written into .htaccess so they never start PHP. Preview the rules first under', 'cb-bot-guard' ) . ' ' . esc_html__( 'Tools', 'cb-bot-guard' ) . '.'; } if ( CBBG_Settings::get( 'block_xmlrpc' ) && class_exists( 'Jetpack' ) ) { $items[] = '' . esc_html__( 'Jetpack detected.', 'cb-bot-guard' ) . ' ' . esc_html__( 'Jetpack needs XML-RPC. Turn off "Block XML-RPC" in Settings if Jetpack features stop working.', 'cb-bot-guard' ); } if ( ! $items ) { return; } $html = '

' . esc_html__( 'Recommended', 'cb-bot-guard' ) . '

' ); } // IP manager. /** * IP manager tab. */ private static function tab_ips() { global $wpdb; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only list filters. $status = isset( $_GET['status'] ) ? sanitize_key( $_GET['status'] ) : ''; $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $orderby_map = array( 'last' => 'last_seen', 'hits' => 'blocked_hits', 'score' => 'score', 'events' => 'events', ); $ob = isset( $_GET['orderby'] ) ? sanitize_key( $_GET['orderby'] ) : 'last'; $ob = isset( $orderby_map[ $ob ] ) ? $ob : 'last'; $paged = max( 1, isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $per = 50; $now = time(); $t = CBBG_Store::t_ips(); // WHERE clause built only from fixed fragments with placeholders. $where = array( '1=1' ); $args = array( $t ); if ( 'blocked' === $status ) { $where[] = "status = 'blocked' AND (expires_at = 0 OR expires_at > %d)"; $args[] = $now; } elseif ( 'expired' === $status ) { $where[] = "(status = 'expired' OR (status = 'blocked' AND expires_at > 0 AND expires_at <= %d))"; $args[] = $now; } elseif ( in_array( $status, array( 'allowed', 'verified', 'watch' ), true ) ) { $where[] = 'status = %s'; $args[] = $status; } if ( '' !== $search ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; $where[] = '(ip LIKE %s OR reason LIKE %s OR last_ua LIKE %s)'; array_push( $args, $like, $like, $like ); } $w = implode( ' AND ', $where ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $w contains only fixed SQL with placeholders; values are passed to prepare(). $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$w}", $args ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE {$w} ORDER BY %i DESC LIMIT %d OFFSET %d", array_merge( $args, array( $orderby_map[ $ob ], $per, ( $paged - 1 ) * $per ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter $counts = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( "SELECT CASE WHEN status = 'blocked' AND expires_at > 0 AND expires_at <= %d THEN 'expired' ELSE status END AS s, COUNT(*) AS n FROM %i GROUP BY s", $now, $t ) ) as $c ) { $counts[ $c->s ] = (int) $c->n; } // Add form. echo '

' . esc_html__( 'Add an IP or range', 'cb-bot-guard' ) . '

'; self::form_open( 'block', 'cbbg-inline-form' ); echo ''; echo ''; self::duration_select(); echo ''; submit_button( __( 'Add', 'cb-bot-guard' ), 'primary', 'submit', false ); echo '

' . esc_html__( 'A /24 range covers 256 addresses. Use ranges for hosting networks that rotate IPs.', 'cb-bot-guard' ) . '

'; // Filters. $filters = array( '' => __( 'All', 'cb-bot-guard' ) ) + self::status_labels(); $parts = array(); foreach ( $filters as $k => $label ) { $n = '' === $k ? array_sum( $counts ) : ( isset( $counts[ $k ] ) ? $counts[ $k ] : 0 ); $parts[] = '
  • ' . esc_html( $label ) . ' (' . esc_html( number_format_i18n( $n ) ) . ')
  • '; } echo '
    '; self::out( '' ); echo '
    '; // Table + bulk actions. self::form_open( 'bulk' ); echo '
    '; $html = ''; if ( ! $rows ) { $html .= ''; } foreach ( (array) $rows as $r ) { $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; } self::out( $html . '
    ' . esc_html__( 'IP / range', 'cb-bot-guard' ) . '' . esc_html__( 'Status', 'cb-bot-guard' ) . '' . esc_html__( 'Reason', 'cb-bot-guard' ) . '' . esc_html__( 'Score', 'cb-bot-guard' ) . '' . esc_html__( 'Events', 'cb-bot-guard' ) . '' . esc_html__( 'Stopped', 'cb-bot-guard' ) . '' . esc_html__( 'Last seen', 'cb-bot-guard' ) . '' . esc_html__( 'Expires', 'cb-bot-guard' ) . '' . esc_html__( 'Actions', 'cb-bot-guard' ) . '
    ' . esc_html__( 'Nothing here yet.', 'cb-bot-guard' ) . '
    ' . self::ip_link( $r->ip ) . ( 'manual' === $r->source ? ' ' . esc_html__( 'manual', 'cb-bot-guard' ) . '' : '' ) . '' . self::badge( $r->status, $r ) . '' . esc_html( $r->reason ) . ( $r->last_ua ? '
    ' . self::short_ua( $r->last_ua, 60 ) . '
    ' : '' ) . '
    ' . esc_html( (string) (int) $r->score ) . '' . esc_html( number_format_i18n( (int) $r->events ) ) . '' . esc_html( number_format_i18n( (int) $r->blocked_hits ) ) . '' . self::ago( $r->last_seen ) . '' . self::expires( $r ) . '' . self::row_actions( $r ) . '
    ' ); echo ''; self::pagination( $total, $per, $paged, array( 'tab' => 'ips', 'status' => $status, 's' => $search, 'orderby' => $ob, ) ); } /** * Single IP view. * * @param string $ip IP or range. */ private static function tab_ip_detail( $ip ) { global $wpdb; echo '

    ' . esc_html__( '← Back to IP Manager', 'cb-bot-guard' ) . '

    '; if ( ! $ip ) { echo '

    ' . esc_html__( 'Invalid IP address.', 'cb-bot-guard' ) . '

    '; return; } $row = CBBG_Store::get( $ip ); $is_range = false !== strpos( $ip, '/' ); self::out( '

    ' . esc_html( $ip ) . '

    ' . self::ip_status( $ip, $row ) . '
    ' ); echo '

    ' . esc_html__( 'Details', 'cb-bot-guard' ) . '

    '; if ( $row ) { $kv = array( __( 'Reason', 'cb-bot-guard' ) => esc_html( $row->reason ? $row->reason : '—' ), __( 'Score', 'cb-bot-guard' ) => esc_html( (int) $row->score . ' / ' . (int) CBBG_Settings::get( 'score_threshold' ) ), __( 'Events', 'cb-bot-guard' ) => esc_html( number_format_i18n( (int) $row->events ) ), __( 'Requests stopped', 'cb-bot-guard' ) => esc_html( number_format_i18n( (int) $row->blocked_hits ) ), __( 'Times blocked', 'cb-bot-guard' ) => esc_html( (string) (int) $row->block_count ), __( 'First seen', 'cb-bot-guard' ) => self::ago( $row->first_seen ), __( 'Last seen', 'cb-bot-guard' ) => self::ago( $row->last_seen ), __( 'Blocked at', 'cb-bot-guard' ) => self::ago( $row->blocked_at ), __( 'Expires', 'cb-bot-guard' ) => self::expires( $row ), __( 'Last URL', 'cb-bot-guard' ) => '' . esc_html( $row->last_uri ? $row->last_uri : '—' ) . '', __( 'User agent', 'cb-bot-guard' ) => esc_html( $row->last_ua ? $row->last_ua : '—' ), ); } else { $kv = array( __( 'Status', 'cb-bot-guard' ) => esc_html__( 'Not on the list yet.', 'cb-bot-guard' ) ); } $html = ''; foreach ( $kv as $k => $v ) { $html .= ''; } self::out( $html . '
    ' . esc_html( $k ) . '' . $v . '
    ' ); echo '
    '; echo '

    ' . esc_html__( 'Actions', 'cb-bot-guard' ) . '

    '; self::form_open( 'block', 'cbbg-stack-form' ); echo ''; echo ''; submit_button( CBBG_Store::is_active_block( $row ) ? __( 'Update block', 'cb-bot-guard' ) : __( 'Block this IP', 'cb-bot-guard' ), 'primary', 'submit', false ); echo ''; self::out( '

    ' . self::row_actions( $row ? $row : $ip ) . '

    ' ); if ( ! $is_range ) { $net = CBBG_IP::network_of( $ip ); /* translators: %s: IP range. */ $ask = sprintf( __( 'Block the whole range %s? Real visitors on that network will be blocked too.', 'cb-bot-guard' ), $net ); $url = self::action_url( 'block_range', array( 'ip' => $ip, 'duration' => 'perm', 'reason' => 'Range of ' . $ip, ) ); /* translators: %s: IP range. */ self::out( '

    ' . esc_html( sprintf( __( 'Block whole range %s', 'cb-bot-guard' ), $net ) ) . '

    ' ); self::out( '

    ' . esc_html__( 'Look up (opens an external site):', 'cb-bot-guard' ) . ' AbuseIPDB · ipinfo.io

    ' ); } echo '
    '; $events = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM %i WHERE ip = %s ORDER BY t DESC LIMIT 200', CBBG_Store::t_log(), $ip ) ); echo '

    ' . esc_html__( 'Recent activity', 'cb-bot-guard' ) . '

    '; self::log_table( $events, false ); echo '
    '; } // Activity log. /** * Activity log tab. */ private static function tab_log() { global $wpdb; // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only list filters. $type = isset( $_GET['type'] ) ? sanitize_key( $_GET['type'] ) : ''; $search = isset( $_GET['s'] ) ? sanitize_text_field( wp_unslash( $_GET['s'] ) ) : ''; $paged = max( 1, isset( $_GET['paged'] ) ? absint( $_GET['paged'] ) : 1 ); // phpcs:enable WordPress.Security.NonceVerification.Recommended $per = 100; $where = array( '1=1' ); $args = array( CBBG_Store::t_log() ); if ( $type ) { $where[] = 'type = %s'; $args[] = $type; } if ( '' !== $search ) { $like = '%' . $wpdb->esc_like( $search ) . '%'; $where[] = '(ip LIKE %s OR uri LIKE %s OR ua LIKE %s OR detail LIKE %s)'; array_push( $args, $like, $like, $like, $like ); } $w = implode( ' AND ', $where ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $w contains only fixed SQL with placeholders; values are passed to prepare(). $total = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE {$w}", $args ) ); $rows = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM %i WHERE {$w} ORDER BY t DESC, id DESC LIMIT %d OFFSET %d", array_merge( $args, array( $per, ( $paged - 1 ) * $per ) ) ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders, PluginCheck.Security.DirectDB.UnescapedDBParameter echo '
    '; /* translators: %d: number of days. */ echo '' . esc_html( sprintf( __( 'Normal visitors are never logged, only suspicious or blocked traffic. Entries older than %d days are removed.', 'cb-bot-guard' ), (int) CBBG_Settings::get( 'log_retention_days' ) ) ) . '
    '; self::log_table( $rows, true ); self::pagination( $total, $per, $paged, array( 'tab' => 'log', 'type' => $type, 's' => $search, ) ); } /** * Log table. * * @param array $rows Rows. * @param bool $with_ip Show IP and action columns. */ private static function log_table( $rows, $with_ip ) { $html = ''; $html .= $with_ip ? '' : ''; $html .= ''; $html .= $with_ip ? '' : ''; $html .= ''; if ( ! $rows ) { $html .= ''; } $cache = array(); foreach ( (array) $rows as $r ) { $html .= ''; if ( $with_ip ) { $html .= ''; } $html .= ''; $html .= ''; $html .= ''; $html .= ''; if ( $with_ip ) { if ( ! array_key_exists( $r->ip, $cache ) ) { $cache[ $r->ip ] = CBBG_Store::get( $r->ip ); } $row = $cache[ $r->ip ]; $is_block = ( $row && CBBG_Store::is_active_block( $row ) ) || '' !== CBBG_Gate::blocked_rule( $r->ip ); $html .= ''; } $html .= ''; } self::out( $html . '
    ' . esc_html__( 'When', 'cb-bot-guard' ) . '' . esc_html__( 'IP', 'cb-bot-guard' ) . '' . esc_html__( 'Event', 'cb-bot-guard' ) . '' . esc_html__( 'Count', 'cb-bot-guard' ) . '' . esc_html__( 'Request', 'cb-bot-guard' ) . '' . esc_html__( 'User agent', 'cb-bot-guard' ) . '
    ' . esc_html__( 'No activity recorded.', 'cb-bot-guard' ) . '
    ' . self::ago( $r->t ) . '' . self::ip_link( $r->ip ) . '' . self::type_badge( $r->type ) . ( $r->detail ? '
    ' . esc_html( $r->detail ) . '
    ' : '' ) . '
    ' . esc_html( number_format_i18n( (int) $r->cnt ) ) . '' . ( $r->method ? '' . esc_html( $r->method ) . ' ' : '' ) . '' . esc_html( $r->uri ) . '' . self::short_ua( $r->ua, 50 ) . '' . ( $is_block ? self::ip_status( $r->ip, $row ) : '' . esc_html__( 'Block', 'cb-bot-guard' ) . '' ) . '
    ' ); } // Settings. /** * Checkbox field. * * @param string $key Key. * @param string $label Label. * @param string $help Help text. */ private static function f_check( $key, $label, $help = '' ) { printf( '
    %4$s
    ', esc_attr( $key ), checked( (int) CBBG_Settings::get( $key ), 1, false ), esc_html( $label ), $help ? '

    ' . esc_html( $help ) . '

    ' : '' ); } /** * Number field. * * @param string $key Key. * @param string $label Label. * @param string $suffix Suffix. * @param string $help Help text. */ private static function f_num( $key, $label, $suffix = '', $help = '' ) { printf( '
    %4$s
    %5$s
    ', esc_attr( $key ), esc_html( $label ), esc_attr( (string) CBBG_Settings::get( $key ) ), esc_html( $suffix ), $help ? '

    ' . esc_html( $help ) . '

    ' : '' ); } /** * Textarea field. * * @param string $key Key. * @param string $label Label. * @param string $help Help text. * @param int $rows Rows. */ private static function f_text( $key, $label, $help = '', $rows = 8 ) { printf( '
    %5$s
    ', esc_attr( $key ), esc_html( $label ), (int) $rows, esc_textarea( (string) CBBG_Settings::get( $key ) ), $help ? '

    ' . esc_html( $help ) . '

    ' : '' ); } /** * Settings tab. */ private static function tab_settings() { self::form_open( 'save_settings', 'cbbg-settings' ); echo '

    ' . esc_html__( 'General', 'cb-bot-guard' ) . '

    '; self::f_check( 'enabled', __( 'Protection enabled', 'cb-bot-guard' ), __( 'Turn off to stop all blocking while keeping your lists and settings.', 'cb-bot-guard' ) ); self::f_check( 'cloudflare', __( 'Site is behind Cloudflare', 'cb-bot-guard' ), __( 'Reads the real visitor IP from the CF-Connecting-IP header, only when the request really comes from a Cloudflare server. .htaccess blocking is disabled in this mode because the web server only sees Cloudflare addresses.', 'cb-bot-guard' ) ); echo '
    '; echo '

    ' . esc_html__( 'Rate limiting', 'cb-bot-guard' ) . ' ' . esc_html__( 'Visitors only. Logged-in users are never rate limited.', 'cb-bot-guard' ) . '

    '; self::f_num( 'rate_general', __( 'All requests', 'cb-bot-guard' ), __( 'per IP per minute', 'cb-bot-guard' ), __( 'Real shoppers rarely pass 40–60. 0 turns this off. Minimum 20.', 'cb-bot-guard' ) ); self::f_num( 'rate_expensive', __( 'Heavy requests', 'cb-bot-guard' ), __( 'per IP per minute', 'cb-bot-guard' ), __( 'Searches, product filter and sort combinations, add-to-cart, checkout, login and comment posts. Bots crawling these are the usual cause of RAM spikes on shops. 0 turns this off. Minimum 5.', 'cb-bot-guard' ) ); echo '
    '; echo '

    ' . esc_html__( 'Scoring and block length', 'cb-bot-guard' ) . '

    '; self::f_num( 'score_threshold', __( 'Block when score reaches', 'cb-bot-guard' ), __( 'points', 'cb-bot-guard' ), __( 'Floods, bad bots, fake search engine bots, probes and honeypot hits are worth 10 (an instant block at the default). Failed logins and 404s are worth less.', 'cb-bot-guard' ) ); self::f_num( 'score_window', __( 'Score resets after', 'cb-bot-guard' ), __( 'minutes without events', 'cb-bot-guard' ) ); self::f_num( 'block_hours', __( 'Automatic block length', 'cb-bot-guard' ), __( 'hours', 'cb-bot-guard' ) ); self::f_check( 'escalate', __( 'Escalate repeat offenders', 'cb-bot-guard' ), __( 'The second automatic block lasts 7 days, the third is permanent.', 'cb-bot-guard' ) ); self::f_num( 'points_login_fail', __( 'Points per failed login', 'cb-bot-guard' ), '', __( 'Default 2: blocked after 5 failures. 0 disables.', 'cb-bot-guard' ) ); self::f_num( 'points_404', __( 'Points per page not found', 'cb-bot-guard' ), '', __( 'Default 1: blocked after 10 missing pages. Missing images, CSS and JS are ignored. 0 disables.', 'cb-bot-guard' ) ); echo '
    '; echo '

    ' . esc_html__( 'Bot detection', 'cb-bot-guard' ) . '

    '; self::f_check( 'verify_bots', __( 'Verify Googlebot, Bingbot, Applebot and Yandex', 'cb-bot-guard' ), __( 'Anything claiming to be these crawlers must pass a reverse DNS check (once per IP, using your server\'s own DNS resolver). Impostors are blocked; genuine crawlers are never rate limited.', 'cb-bot-guard' ) ); self::f_check( 'block_empty_ua', __( 'Block requests with no user agent', 'cb-bot-guard' ) ); self::f_check( 'block_bad_ua', __( 'Block scraping tools and attack scanners', 'cb-bot-guard' ) ); self::f_text( 'bad_ua_list', __( 'Scraper and attack user agents', 'cb-bot-guard' ), __( 'One per line, matched anywhere in the user agent, not case sensitive.', 'cb-bot-guard' ), 8 ); self::f_check( 'block_seo_bots', __( 'Block aggressive SEO crawlers', 'cb-bot-guard' ), __( 'Third-party SEO and data crawlers that do not send you visitors. Remove any your SEO team relies on.', 'cb-bot-guard' ) ); self::f_text( 'seo_ua_list', __( 'SEO crawler user agents', 'cb-bot-guard' ), '', 6 ); self::f_check( 'block_ai_bots', __( 'Block AI crawlers', 'cb-bot-guard' ), __( 'Crawlers that collect content for AI models and assistants. Off by default.', 'cb-bot-guard' ) ); self::f_text( 'ai_ua_list', __( 'AI crawler user agents', 'cb-bot-guard' ), '', 6 ); self::f_check( 'probe_block', __( 'Block vulnerability probes', 'cb-bot-guard' ) ); self::f_text( 'probe_patterns', __( 'Probe URL patterns', 'cb-bot-guard' ), __( 'Regular expressions matched against the URL path, one per line.', 'cb-bot-guard' ), 8 ); self::f_check( 'block_xmlrpc', __( 'Block XML-RPC', 'cb-bot-guard' ), __( 'Leave on unless the site uses Jetpack or the WordPress mobile app.', 'cb-bot-guard' ) ); self::f_check( 'honeypot', __( 'Honeypot on comment and registration forms', 'cb-bot-guard' ), __( 'Adds an invisible field that only bots fill in. Works alongside other anti-spam plugins.', 'cb-bot-guard' ) ); self::f_text( 'exempt_paths', __( 'Never check these URLs', 'cb-bot-guard' ), __( 'Requests whose URL contains any of these are skipped by the user agent, probe and rate checks (already-blocked IPs stay blocked). Keep payment gateway callback URLs here so orders are never lost. One per line.', 'cb-bot-guard' ), 5 ); echo '
    '; echo '

    ' . esc_html__( 'Server level', 'cb-bot-guard' ) . '

    '; self::f_check( 'htaccess_sync', __( 'Also write blocked IPs into .htaccess (Apache)', 'cb-bot-guard' ), __( 'Apache rejects these IPs before PHP starts, so they cost almost nothing. A backup of .htaccess is saved in the plugin\'s uploads folder before every change. Preview the rules under Tools first.', 'cb-bot-guard' ) ); self::f_num( 'log_retention_days', __( 'Keep activity log for', 'cb-bot-guard' ), __( 'days', 'cb-bot-guard' ) ); echo '
    '; /** * Lets add-ons print extra settings sections inside the form. */ do_action( 'cbbg_settings_sections' ); echo '

    '; submit_button( __( 'Save settings', 'cb-bot-guard' ), 'primary', 'submit', false ); echo ' ' . esc_html__( 'Reset to defaults', 'cb-bot-guard' ) . '

    '; } // Tools. /** * Tools tab. */ private static function tab_tools() { $mu = CBBG_Store::mu_installed(); echo '
    '; echo '

    ' . esc_html__( 'Early blocking', 'cb-bot-guard' ) . '

    '; self::out( self::onoff( $mu ) ); echo ' ' . esc_html__( 'Adds a small file to wp-content/mu-plugins so the blocklist check runs before other plugins and the theme load. A blocked bot then uses a few MB of memory instead of a full page load. It adds no measurable cost for real visitors.', 'cb-bot-guard' ) . '

    '; if ( $mu ) { echo '' . esc_html__( 'Turn off early blocking', 'cb-bot-guard' ) . ''; } else { echo '' . esc_html__( 'Turn on early blocking', 'cb-bot-guard' ) . ''; } echo '

    ' . esc_html__( 'The file does nothing while CB Bot Guard is inactive and is removed automatically when you deactivate the plugin.', 'cb-bot-guard' ) . '

    '; echo '

    ' . esc_html__( '.htaccess blocking', 'cb-bot-guard' ) . '

    '; if ( CBBG_Settings::get( 'cloudflare' ) ) { echo '

    ' . esc_html__( 'Not available behind Cloudflare: the web server sees Cloudflare\'s addresses, not your visitors\'. Use Cloudflare\'s own IP Access Rules for edge blocking.', 'cb-bot-guard' ) . '

    '; } else { $rules = CBBG_Store::htaccess_rules(); echo '

    '; self::out( self::onoff( (bool) CBBG_Settings::get( 'htaccess_sync' ) ) ); echo ' ' . esc_html__( 'Rules placed in .htaccess between the "BEGIN CB Bot Guard" and "END CB Bot Guard" markers (turn on in Settings):', 'cb-bot-guard' ) . '

    '; echo '
    ' . esc_html( $rules ? implode( "\n", $rules ) : __( '(No blocked IPs yet, nothing to write.)', 'cb-bot-guard' ) ) . '
    '; echo '

    ' . esc_html__( 'Uses the Apache 2.4 "Require not ip" directive. To undo by hand, delete the lines between the markers.', 'cb-bot-guard' ) . '

    '; } echo '
    '; echo '

    ' . esc_html__( 'Import IPs', 'cb-bot-guard' ) . '

    '; self::form_open( 'import', 'cbbg-stack-form' ); echo ''; echo ''; echo ''; submit_button( __( 'Import', 'cb-bot-guard' ), 'secondary', 'submit', false ); echo '
    '; echo '

    ' . esc_html__( 'Maintenance', 'cb-bot-guard' ) . '

    '; echo '' . esc_html__( 'Export IP list (CSV)', 'cb-bot-guard' ) . ' '; echo '' . esc_html__( 'Rebuild blocklist now', 'cb-bot-guard' ) . ' '; echo '' . esc_html__( 'Clear watched / expired', 'cb-bot-guard' ) . ' '; echo '' . esc_html__( 'Clear activity log', 'cb-bot-guard' ) . '

    '; $file = CBBG_Store::path( 'blocklist' ); $html = ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; self::out( $html . '
    ' . esc_html__( 'Data folder', 'cb-bot-guard' ) . '' . esc_html( CBBG_Store::data_dir() ) . ' ' . ( wp_is_writable( CBBG_Store::data_dir() ) ? esc_html__( '(writable)', 'cb-bot-guard' ) : '' . esc_html__( '(not writable)', 'cb-bot-guard' ) . '' ) . '
    ' . esc_html__( 'Blocklist built', 'cb-bot-guard' ) . '' . ( file_exists( $file ) ? self::ago( filemtime( $file ) ) : esc_html__( 'never', 'cb-bot-guard' ) ) . '
    ' . esc_html__( 'Rate counters', 'cb-bot-guard' ) . '' . ( CBBG_Gate::apcu() ? esc_html__( 'APCu (memory)', 'cb-bot-guard' ) : esc_html__( 'Small files', 'cb-bot-guard' ) ) . '
    ' . esc_html__( 'Next maintenance run', 'cb-bot-guard' ) . '' . self::ago( (int) wp_next_scheduled( 'cbbg_frequent' ) ) . '
    ' ); echo '
    '; echo '

    ' . esc_html__( 'If you are ever locked out', 'cb-bot-guard' ) . '

      '; echo '
    1. ' . esc_html__( 'Add this line to wp-config.php using SFTP or your host\'s file manager. All blocking stops immediately:', 'cb-bot-guard' ) . ' define( \'CBBG_DISABLE\', true );
    2. '; echo '
    3. ' . esc_html__( 'If .htaccess blocking was on, delete the lines between "BEGIN CB Bot Guard" and "END CB Bot Guard" in .htaccess.', 'cb-bot-guard' ) . '
    4. '; echo '
    5. ' . esc_html__( 'Log in, add your IP under IP Manager as "Always allow", then remove the line from wp-config.php.', 'cb-bot-guard' ) . '
    6. '; echo '
    '; } // Pro. /** * Pro tab: a single, non-intrusive overview page. */ private static function tab_pro() { $features = array( array( __( 'Country and network blocking', 'cb-bot-guard' ), __( 'Block or allow whole countries and hosting networks (ASNs) where your customers never come from.', 'cb-bot-guard' ) ), array( __( 'Attack alerts', 'cb-bot-guard' ), __( 'Email or Slack alerts when a flood starts, with the top offending IPs.', 'cb-bot-guard' ) ), array( __( 'Shared blocklist across sites', 'cb-bot-guard' ), __( 'An IP blocked on one of your sites is blocked on all of them.', 'cb-bot-guard' ) ), array( __( 'Cloudflare firewall sync', 'cb-bot-guard' ), __( 'Push blocked IPs to Cloudflare so they are stopped before reaching your server.', 'cb-bot-guard' ) ), array( __( 'IP reputation', 'cb-bot-guard' ), __( 'Check visitors against AbuseIPDB and block known bad IPs before they misbehave.', 'cb-bot-guard' ) ), array( __( 'Weekly reports', 'cb-bot-guard' ), __( 'A weekly email summary of what was blocked and how much load was saved.', 'cb-bot-guard' ) ), ); echo '

    ' . esc_html__( 'CB Bot Guard Pro', 'cb-bot-guard' ) . '

    '; echo '

    ' . esc_html__( 'Everything on the other tabs is free and stays free. Pro is a separate add-on for agencies and busy stores that want more automation.', 'cb-bot-guard' ) . '

    ' . esc_html__( 'See Pro plans', 'cb-bot-guard' ) . '

    '; } }