From 0de12981b34de897e18d067742803a0251df560f Mon Sep 17 00:00:00 2001 From: Guilherme Branco Stracini Date: Wed, 13 May 2026 11:18:45 +0100 Subject: [PATCH 1/2] feat: add CSV export for visitor records +semver: minor Introduce a filtered CSV download from the Visitors admin screen. The export honours active search and risk-type filters, streams all matching rows unpaginated, and prepends a UTF-8 BOM for Excel compatibility. Bump version to 1.2.0. --- README.md | 3 +- admin/views/visitors.php | 9 ++++ docs/visitors.md | 37 ++++++++++++++++ includes/class-ipquery-admin.php | 76 ++++++++++++++++++++++++++++++++ includes/class-ipquery-db.php | 55 +++++++++++++++++++++++ ipquery.php | 4 +- readme.txt | 11 ++++- 7 files changed, 191 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 53d88ef..e707937 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A WordPress plugin that enriches visitor IP addresses with real-time geolocation, ISP data, and risk intelligence โ€” powered by the [IpQuery API](https://ipquery.io) via [guibranco/ipquery-php](https://github.com/guibranco/ipquery-php). [![Build](https://img.shields.io/github/actions/workflow/status/guibranco/ipquery-wordpress/pages.yml?label=docs&style=flat-square)](https://github.com/guibranco/ipquery-wordpress/actions/workflows/pages.yml) -[![Version](https://img.shields.io/badge/version-1.0.0-blue?style=flat-square)](https://github.com/guibranco/ipquery-wordpress/releases) +[![Version](https://img.shields.io/badge/version-1.2.0-blue?style=flat-square)](https://github.com/guibranco/ipquery-wordpress/releases) [![WordPress](https://img.shields.io/badge/WordPress-%E2%89%A56.0-21759B?style=flat-square&logo=wordpress)](https://wordpress.org) [![PHP](https://img.shields.io/badge/PHP-%E2%89%A58.2-777BB4?style=flat-square&logo=php)](https://www.php.net) [![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE) @@ -32,6 +32,7 @@ A WordPress plugin that enriches visitor IP addresses with real-time geolocation | ๐Ÿ—„๏ธ | **Smart caching** | WordPress transients cache each IP for 1 hour; only one API call per IP per hour | | ๐Ÿงน | **Auto-retention** | Configurable data retention with daily WP-Cron cleanup | | ๐Ÿ”’ | **Privacy controls** | Exclude IPs, skip logged-in users or admins, disable tracking at any time | +| ๐Ÿ“ฅ | **CSV export** | Download all visitor data (or the current filtered view) as a UTF-8 CSV file | --- diff --git a/admin/views/visitors.php b/admin/views/visitors.php index 8105e5b..003ef22 100644 --- a/admin/views/visitors.php +++ b/admin/views/visitors.php @@ -91,6 +91,15 @@ class="regular-text"> + + +
+ + + + + +
$search, + 'risk_filter' => $risk_filter, + ) + ); + $filename = 'ipquery-visitors-' . gmdate( 'Y-m-d' ) . '.csv'; + + header( 'Content-Type: text/csv; charset=utf-8' ); + header( 'Content-Disposition: attachment; filename="' . $filename . '"' ); + header( 'Pragma: no-cache' ); + header( 'Expires: 0' ); + + $output = fopen( 'php://output', 'w' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen + + // UTF-8 BOM for Excel compatibility. + fwrite( $output, "\xEF\xBB\xBF" ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite + + fputcsv( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv + $output, + array( + 'IP', 'Country', 'Country Code', 'City', 'State', 'Zipcode', + 'Latitude', 'Longitude', 'Timezone', 'ASN', 'Org', 'ISP', + 'Is Mobile', 'Is VPN', 'Is Tor', 'Is Proxy', 'Is Datacenter', + 'Risk Score', 'First Seen', 'Last Seen', 'Visit Count', + ) + ); + + foreach ( $rows as $row ) { + fputcsv( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv + $output, + array( + $row['ip'], + $row['country'] ?? '', + $row['country_code'] ?? '', + $row['city'] ?? '', + $row['state'] ?? '', + $row['zipcode'] ?? '', + $row['latitude'] ?? '', + $row['longitude'] ?? '', + $row['timezone'] ?? '', + $row['asn'] ?? '', + $row['org'] ?? '', + $row['isp'] ?? '', + $row['is_mobile'] ? 'Yes' : 'No', + $row['is_vpn'] ? 'Yes' : 'No', + $row['is_tor'] ? 'Yes' : 'No', + $row['is_proxy'] ? 'Yes' : 'No', + $row['is_datacenter'] ? 'Yes' : 'No', + $row['risk_score'] ?? 0, + $row['first_seen'] ?? '', + $row['last_seen'] ?? '', + $row['visit_count'] ?? 0, + ) + ); + } + + fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose + exit; + } + // ------------------------------------------------------------------------- // AJAX endpoints. // ------------------------------------------------------------------------- diff --git a/includes/class-ipquery-db.php b/includes/class-ipquery-db.php index 7ad63bc..1fa833b 100644 --- a/includes/class-ipquery-db.php +++ b/includes/class-ipquery-db.php @@ -346,4 +346,59 @@ public static function get_distinct_countries(): array { $result = $wpdb->get_results( "SELECT DISTINCT country, country_code FROM {$table} WHERE country_code IS NOT NULL AND country_code != '' ORDER BY country ASC", ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared return is_array( $result ) ? $result : array(); } + + /** + * Returns all visitor rows matching the given filters, with no pagination limit, for CSV export. + * + * @param array $args Query arguments (orderby, order, search, country_code, risk_filter). + * @return array> + */ + public static function get_all_for_export( array $args = array() ): array { + global $wpdb; + $table = $wpdb->prefix . IPQUERY_TABLE; + + $defaults = array( + 'orderby' => 'last_seen', + 'order' => 'DESC', + 'search' => '', + 'country_code' => '', + 'risk_filter' => '', + ); + $args = wp_parse_args( $args, $defaults ); + + $where = array(); + $values = array(); + + if ( ! empty( $args['search'] ) ) { + $like = '%' . $wpdb->esc_like( $args['search'] ) . '%'; + $where[] = '(ip LIKE %s OR city LIKE %s OR country LIKE %s OR isp LIKE %s)'; + $values = array_merge( $values, array( $like, $like, $like, $like ) ); + } + if ( ! empty( $args['country_code'] ) ) { + $where[] = 'country_code = %s'; + $values[] = $args['country_code']; + } + if ( ! empty( $args['risk_filter'] ) ) { + $allowed_flags = array( 'is_vpn', 'is_proxy', 'is_tor', 'is_datacenter', 'is_mobile' ); + if ( in_array( $args['risk_filter'], $allowed_flags, true ) ) { + $where[] = $args['risk_filter'] . ' = 1'; + } + } + + $where_sql = $where ? 'WHERE ' . implode( ' AND ', $where ) : ''; + + $allowed_orderby = array( 'last_seen', 'first_seen', 'visit_count', 'country', 'risk_score', 'ip' ); + $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'last_seen'; + $order = 'ASC' === strtoupper( $args['order'] ) ? 'ASC' : 'DESC'; + + $sql = "SELECT * FROM {$table} {$where_sql} ORDER BY {$orderby} {$order}"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared + + if ( $values ) { + $rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$values ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter + } else { + $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + } + + return is_array( $rows ) ? $rows : array(); + } } diff --git a/ipquery.php b/ipquery.php index 53d2ff0..326849f 100644 --- a/ipquery.php +++ b/ipquery.php @@ -3,7 +3,7 @@ * Plugin Name: IpQuery * Plugin URI: https://guilherme.stracini.com.br/ipquery-wordpress/ * Description: Track and analyse visitor IP data using the IpQuery API (via guibranco/ipquery-php). Displays location maps, traffic heatmaps, and VPN/proxy statistics. - * Version: 1.1.4 + * Version: 1.2.0 * Requires at least: 6.0 * Requires PHP: 8.2 * Author: Guilherme Branco Stracini @@ -20,7 +20,7 @@ defined( 'ABSPATH' ) || exit; -define( 'IPQUERY_VERSION', '1.1.4' ); +define( 'IPQUERY_VERSION', '1.2.0' ); define( 'IPQUERY_FILE', __FILE__ ); define( 'IPQUERY_DIR', plugin_dir_path( __FILE__ ) ); define( 'IPQUERY_URL', plugin_dir_url( __FILE__ ) ); diff --git a/readme.txt b/readme.txt index 7bffa87..beed482 100644 --- a/readme.txt +++ b/readme.txt @@ -4,7 +4,7 @@ Tags: ip, geolocation, analytics, security, heatmap Requires at least: 6.0 Tested up to: 6.9 Requires PHP: 8.2 -Stable tag: 1.1.4 +Stable tag: 1.2.0 License: MIT License URI: https://opensource.org/licenses/MIT @@ -27,6 +27,7 @@ IpQuery enriches every visitor's IP address with real-time geolocation, ISP data * **Auto-retention** โ€” Configurable data retention with daily WP-Cron cleanup * **Privacy controls** โ€” Exclude IPs, skip logged-in users or admins, disable tracking at any time * **GDPR erasure tools** โ€” Delete individual visitor records or bulk-erase all data for a specific country +* **CSV export** โ€” Download all visitor data (or the current filtered view) as a UTF-8 CSV file == Installation == @@ -70,6 +71,11 @@ All data is stored in your own WordPress database in the `wp_ipquery_visitors` t == Changelog == += 1.2.0 = +* Added CSV export โ€” download all visitor records (or the current filtered view) directly from the Visitors screen +* Export honours active search and risk-type filters so you can export exactly the subset you need +* Exported CSV is UTF-8 with BOM for seamless Excel compatibility; filename includes today's date + = 1.1.1 = * Maintenance and bug fixes @@ -85,6 +91,9 @@ All data is stored in your own WordPress database in the `wp_ipquery_visitors` t == Upgrade Notice == += 1.2.0 = +Adds CSV export of visitor data from the Visitors screen. No database changes โ€” safe to upgrade. + = 1.1.1 = Maintenance release. Safe to upgrade. From f6eb19ca16c1d939968c84cf489f94c615ceda67 Mon Sep 17 00:00:00 2001 From: Guilherme Branco Stracini Date: Wed, 13 May 2026 11:28:06 +0100 Subject: [PATCH 2/2] style: expand CSV headers to one per line Reformats the fputcsv header array in the CSV export to use one element per line for improved readability and easier diffs. Also adds missing phpcs ignore flag `WordPress.DB.PreparedSQL.NotPrepared` to the direct DB query suppression comment. --- includes/class-ipquery-admin.php | 25 +++++++++++++++++++++---- includes/class-ipquery-db.php | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/includes/class-ipquery-admin.php b/includes/class-ipquery-admin.php index a3eba76..91b3237 100644 --- a/includes/class-ipquery-admin.php +++ b/includes/class-ipquery-admin.php @@ -366,10 +366,27 @@ public static function handle_export_csv(): void { fputcsv( // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fputcsv $output, array( - 'IP', 'Country', 'Country Code', 'City', 'State', 'Zipcode', - 'Latitude', 'Longitude', 'Timezone', 'ASN', 'Org', 'ISP', - 'Is Mobile', 'Is VPN', 'Is Tor', 'Is Proxy', 'Is Datacenter', - 'Risk Score', 'First Seen', 'Last Seen', 'Visit Count', + 'IP', + 'Country', + 'Country Code', + 'City', + 'State', + 'Zipcode', + 'Latitude', + 'Longitude', + 'Timezone', + 'ASN', + 'Org', + 'ISP', + 'Is Mobile', + 'Is VPN', + 'Is Tor', + 'Is Proxy', + 'Is Datacenter', + 'Risk Score', + 'First Seen', + 'Last Seen', + 'Visit Count', ) ); diff --git a/includes/class-ipquery-db.php b/includes/class-ipquery-db.php index 1fa833b..706a35d 100644 --- a/includes/class-ipquery-db.php +++ b/includes/class-ipquery-db.php @@ -396,7 +396,7 @@ public static function get_all_for_export( array $args = array() ): array { if ( $values ) { $rows = $wpdb->get_results( $wpdb->prepare( $sql, ...$values ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter } else { - $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $rows = $wpdb->get_results( $sql, ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared } return is_array( $rows ) ? $rows : array();