activation( [$this, 'bootstrapping'] ); $this->activation( [$this, 'send_activation_email'] ); $this->action( 'admin_post_admin_safety_guard_deactivate', [$this, 'handle_deactivate'] ); // Safety net: apply schema changes that ship via a plugin update, where the // activation hook does not re-fire. Idempotent and version-gated. This is // also what provisions the tables on each site of a multisite network, // since options (including tpsa_version) are per-site. $this->action( 'plugins_loaded', [$this, 'maybe_upgrade_database'] ); // Multisite: give a brand-new site its tables at creation time rather // than on whichever request happens to hit it first. $this->action( 'wp_initialize_site', [$this, 'install_for_new_site'], 100, 1 ); } /** * Provision plugin tables for a newly created site on a network. * * @param \WP_Site|int $site New site object (or ID on older callers). * @return void */ public function install_for_new_site( $site ) { if ( !is_multisite() ) { return; } // Only relevant when the plugin is active for the whole network; a // per-site activation provisions itself on activation. // wp_initialize_site can fire outside wp-admin (WP-CLI, REST), where // is_plugin_active_for_network() is not loaded yet. if ( !function_exists( 'is_plugin_active_for_network' ) ) { include_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( !is_plugin_active_for_network( TPSA_PLUGIN_BASENAME ) ) { return; } $site_id = is_object( $site ) && isset( $site->blog_id ) ? (int) $site->blog_id : (int) $site; if ( $site_id <= 0 ) { return; } switch_to_blog( $site_id ); $this->install_tables(); $this->update_db_version(); restore_current_blog(); } /** * Ensure the database schema is current on plugin updates. * * Runs the (idempotent) table install whenever the stored DB version does not * match the plugin version, so updating the plugin without a manual reactivation * still creates/updates the custom tables. * * @return void */ public function maybe_upgrade_database() { if ( $this->is_database_up_to_date() ) { return; } $this->install_tables(); $this->update_db_version(); } /** * Run installation tasks on plugin activation. */ public function bootstrapping() { // Enable Limit Login Attempts by default on fresh install. add_option( 'tpsa_limit-login-attempts_settings', [ 'enable' => 1, 'max-attempts' => 3, 'block-for' => 15, 'max-lockout' => 3, 'block-message' => 'You have been locked out due to too many login attempts.', ] ); if ( !$this->is_database_up_to_date() ) { set_transient( 'tpsm_do_activation_redirect', true, 30 ); $this->install_tables(); $this->update_db_version(); } } /** * Create or update all custom plugin tables. * * Uses dbDelta(), which is safe to run repeatedly: it creates tables when * missing and applies additive column changes on subsequent runs. * * @return void */ private function install_tables() { $this->create_table( 's_logins', " id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(100) NOT NULL, user_agent TEXT NOT NULL, ip_address VARCHAR(45) NOT NULL, login_time DATETIME NOT NULL, login_count INT UNSIGNED NOT NULL DEFAULT 1, is_new_ip TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY username (username), KEY login_time (login_time) " ); // Every lockout check filters on ip_address, and the cleanup job filters // on the datetime columns. Without these keys each lookup is a full // table scan, which gets slow fast on a site under sustained attack. $this->create_table( 'failed_logins', " id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(100) NOT NULL, user_agent TEXT NOT NULL, ip_address VARCHAR(45) NOT NULL, first_login_time DATETIME NOT NULL, last_login_time DATETIME NOT NULL, login_attempts INT UNSIGNED NOT NULL DEFAULT 1, lockouts INT UNSIGNED NOT NULL DEFAULT 0, lockout_time DATETIME DEFAULT NULL, PRIMARY KEY (id), KEY ip_address (ip_address), KEY last_login_time (last_login_time), KEY lockout_time (lockout_time) " ); // Every block the plugin performs, for the dashboard threat counter. $this->create_table( 'threats', " id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, threat_type VARCHAR(40) NOT NULL, ip_address VARCHAR(45) NOT NULL, detail VARCHAR(191) NOT NULL DEFAULT '', user_agent TEXT NOT NULL, request_uri VARCHAR(191) NOT NULL DEFAULT '', blocked_at DATETIME NOT NULL, PRIMARY KEY (id), KEY threat_type (threat_type), KEY ip_address (ip_address), KEY blocked_at (blocked_at) " ); $this->create_table( 'block_users', " id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, user_agent TEXT NOT NULL, ip_address VARCHAR(45) NOT NULL, login_time DATETIME NOT NULL, PRIMARY KEY (id), KEY ip_address (ip_address), KEY login_time (login_time) " ); } /** * Email the site admin on activation, including a one-click "safe * deactivate" link in case the plugin causes trouble. * * @return void */ public function send_activation_email() { if ( !$this->is_database_up_to_date() ) { return; } $admin_email = get_option( 'admin_email' ); if ( !is_email( $admin_email ) ) { return; } $site_name = get_bloginfo( 'name' ); $site_url = home_url(); $plugin_name = 'Admin Safety Guard'; // Generate a secure token for one-click deactivate link $token = wp_generate_password( 32, false ); update_option( 'tp_admin_safety_guard_deactivate_token', $token ); $deactivate_url = add_query_arg( array( 'action' => 'admin_safety_guard_deactivate', 'token' => $token, ), admin_url( 'admin-post.php' ) ); // Add nonce $deactivate_url = wp_nonce_url( $deactivate_url, 'tpsa_safe_deactivate', 'tpsa_nonce' ); $subject = sprintf( '[%s] %s has been activated', $site_name, $plugin_name ); // Build HTML email body $message = ' ' . esc_html( $plugin_name ) . ' Activated
'; $headers = array(); $from_name = $plugin_name; $from_email = 'no-reply@' . wp_parse_url( home_url(), PHP_URL_HOST ); $headers[] = 'From: ' . $from_name . ' <' . $from_email . '>'; $headers[] = 'Content-Type: text/html; charset=UTF-8'; wp_mail( $admin_email, $subject, $message, $headers ); } public function handle_deactivate() { auth_redirect(); // Only allow admins with plugin activation capability if ( !current_user_can( 'activate_plugins' ) ) { wp_die( esc_html__( 'You do not have permission to deactivate plugins.', 'admin-safety-guard' ) ); } // ✅ Nonce verification (fixes the warning) $nonce = isset( $_GET['tpsa_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['tpsa_nonce'] ) ) : ''; if ( empty( $nonce ) || !wp_verify_nonce( $nonce, 'tpsa_safe_deactivate' ) ) { wp_die( esc_html__( 'Security check failed.', 'admin-safety-guard' ) ); } $stored_token = get_option( 'tp_admin_safety_guard_deactivate_token' ); $token = isset( $_GET['token'] ) ? sanitize_text_field( wp_unslash( $_GET['token'] ) ) : ''; // Check token if ( empty( $stored_token ) || empty( $token ) || !hash_equals( $stored_token, $token ) ) { wp_die( esc_html__( 'Invalid or expired deactivation link.', 'admin-safety-guard' ) ); } // Token is valid, so delete it to avoid unlimited re-use (optional but recommended) delete_option( 'tp_admin_safety_guard_deactivate_token' ); // Deactivate this plugin deactivate_plugins( TPSA_PLUGIN_BASENAME ); // Redirect back to plugins page with a notice $redirect_url = add_query_arg( array( 'tp_secure_admin_deactivated' => 1, ), admin_url( 'plugins.php' ) ); wp_safe_redirect( $redirect_url ); exit; } /** * Create a custom database table. * * @param string $table_name * @param string $table_columns */ private function create_table( $table_name, $table_columns ) { require_once ABSPATH . 'wp-admin/includes/upgrade.php'; global $wpdb; $full_table_name = get_tpsa_db_table_name( $table_name ); $charset_collate = $wpdb->get_charset_collate(); $sql = "CREATE TABLE {$full_table_name} ($table_columns) {$charset_collate};"; dbDelta( $sql ); } /** * Check if the database is already up to date. * * @return bool */ private function is_database_up_to_date() { $installed_ver = get_option( 'tpsa_version' ); return version_compare( $installed_ver, TPSA_PLUGIN_VERSION, '=' ); } /** * Update the database version stored in options. */ private function update_db_version() { update_option( 'tpsa_version', TPSA_PLUGIN_VERSION ); } }