prefix . 'aiblackout_blocked_calls'; } /** * Creates or updates the table. * * @return void */ public static function install(): void { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table_name(); $collate = $wpdb->get_charset_collate(); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name cannot be a placeholder, and it is built from $wpdb->prefix. $sql = "CREATE TABLE {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, occurred_on DATE NOT NULL, last_seen DATETIME NOT NULL, host VARCHAR(190) NOT NULL, source VARCHAR(190) NOT NULL, provider VARCHAR(64) NOT NULL DEFAULT '', outcome TINYINT UNSIGNED NOT NULL DEFAULT 0, hits INT UNSIGNED NOT NULL DEFAULT 1, PRIMARY KEY (id), UNIQUE KEY dedupe (occurred_on,host,source,outcome), KEY pruning (occurred_on) ) {$collate};"; // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared dbDelta( $sql ); update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION, false ); } /** * Creates or upgrades the table when the stored schema version does not * match. * * @return void */ public static function maybe_install(): void { if ( (int) get_option( self::SCHEMA_OPTION, 0 ) === self::SCHEMA_VERSION ) { return; } self::install(); } /** * Drops the table. * * @return void */ public static function uninstall(): void { global $wpdb; $table = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching -- Dropping our own table on uninstall; the name is built from $wpdb->prefix. $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); delete_option( self::SCHEMA_OPTION ); } /** * Whether the table exists. * * @return bool True when the table is present. */ public function is_installed(): bool { global $wpdb; $table = self::table_name(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Schema introspection; there is nothing to cache and it runs only on the audit screen. $found = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ); return $found === $table; } /** * Records one call. * * @param string $host The host that was contacted. * @param string $source Slug of the plugin, theme or component responsible. * @param string $provider Provider identifier from the bundled list. * @param int $outcome One of OBSERVED or BLOCKED. * @return void */ public function record( string $host, string $source, string $provider, int $outcome ): void { global $wpdb; if ( ! $this->is_installed() ) { return; } $table = self::table_name(); $now = current_time( 'mysql' ); $day = substr( $now, 0, 10 ); $outcome = self::BLOCKED === $outcome ? self::BLOCKED : self::OBSERVED; // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name cannot be a placeholder; every value is prepared. $wpdb->query( $wpdb->prepare( "INSERT INTO {$table} (occurred_on, last_seen, host, source, provider, outcome, hits) VALUES (%s, %s, %s, %s, %s, %d, 1) ON DUPLICATE KEY UPDATE hits = hits + 1, last_seen = VALUES(last_seen)", $day, $now, substr( $host, 0, 190 ), substr( $source, 0, 190 ), substr( $provider, 0, 64 ), $outcome ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching } /** * Returns recent activity, most hits first. * * @param int $days How many days back to include. * @param int $limit Maximum rows. * @return array List of rows. */ public function get_recent( int $days = 30, int $limit = 100 ): array { global $wpdb; if ( ! $this->is_installed() ) { return array(); } $table = self::table_name(); $since = gmdate( 'Y-m-d', strtotime( current_time( 'mysql' ) ) - ( max( 1, $days ) * DAY_IN_SECONDS ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name cannot be a placeholder; every value is prepared. Admin-screen read, nothing to cache. $rows = $wpdb->get_results( $wpdb->prepare( "SELECT occurred_on, last_seen, host, source, provider, outcome, hits FROM {$table} WHERE occurred_on >= %s ORDER BY hits DESC, last_seen DESC LIMIT %d", $since, max( 1, $limit ) ), ARRAY_A ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return is_array( $rows ) ? $rows : array(); } /** * Deletes rows older than the retention window. * * @return int Rows removed. */ public function prune(): int { global $wpdb; if ( ! $this->is_installed() ) { return 0; } $table = self::table_name(); $cutoff = gmdate( 'Y-m-d', strtotime( current_time( 'mysql' ) ) - ( self::RETENTION_DAYS * DAY_IN_SECONDS ) ); // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name cannot be a placeholder; the value is prepared. $removed = $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE occurred_on < %s", $cutoff ) ); // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching return is_numeric( $removed ) ? (int) $removed : 0; } /** * Empties the log. * * @return void */ public function clear(): void { global $wpdb; if ( ! $this->is_installed() ) { return; } $table = self::table_name(); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Truncating our own table on an explicit user action. $wpdb->query( "TRUNCATE TABLE {$table}" ); } }