Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 |

---

Expand Down
9 changes: 9 additions & 0 deletions admin/views/visitors.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ class="regular-text">
<input type="text" name="ip" placeholder="<?php esc_attr_e( 'Lookup IP…', 'ipquery' ); ?>" class="regular-text" pattern="^(\d{1,3}\.){3}\d{1,3}$|^[0-9a-fA-F:]+$">
<?php submit_button( __( 'Lookup', 'ipquery' ), 'secondary', 'lookup_btn', false ); ?>
</form>

<!-- Export CSV (carries active filters into the POST body) -->
<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="ipquery-export-form">
<?php wp_nonce_field( 'ipquery_export_csv' ); ?>
<input type="hidden" name="action" value="ipquery_export_csv">
<input type="hidden" name="s" value="<?php echo esc_attr( sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended ?>">
<input type="hidden" name="risk_filter" value="<?php echo esc_attr( $ipquery_risk_filter ); ?>">
<?php submit_button( __( 'Export CSV', 'ipquery' ), 'secondary', 'export_csv_btn', false ); ?>
</form>
</div>

<?php
Expand Down
37 changes: 37 additions & 0 deletions docs/visitors.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,43 @@ Filters are applied server-side and are reflected in the record count shown abov

---

## Export CSV

Click **Export CSV** in the toolbar to download all currently visible visitor records as a UTF-8 encoded CSV file. The export honours any active search term or risk-type filter β€” only the rows that match the current filter are included.

**Columns exported:**

| Column | Description |
|---|---|
| IP | Raw IP address |
| Country | Full country name |
| Country Code | ISO 3166-1 alpha-2 code |
| City | City name |
| State | State or region |
| Zipcode | Postal / ZIP code |
| Latitude | Decimal latitude |
| Longitude | Decimal longitude |
| Timezone | IANA timezone identifier |
| ASN | Autonomous System Number |
| Org | Organisation name |
| ISP | Internet Service Provider |
| Is Mobile | Yes / No |
| Is VPN | Yes / No |
| Is Tor | Yes / No |
| Is Proxy | Yes / No |
| Is Datacenter | Yes / No |
| Risk Score | 0 – 100 |
| First Seen | UTC datetime of first visit |
| Last Seen | UTC datetime of most recent visit |
| Visit Count | Total visits attributed to this IP |

The file is named `ipquery-visitors-YYYY-MM-DD.csv` (today's date). A UTF-8 BOM is prepended so that Excel opens the file correctly without a manual import step.

{: .note }
The export is not paginated β€” it always includes every matching record, regardless of how many pages the table spans.

---

## Manual IP lookup

Enter any valid IP address in the **Lookup IP…** field and click **Lookup**. The plugin immediately calls `IpQueryClient::getIpData()` from the [ipquery-php](https://github.com/guibranco/ipquery-php) library and stores the result. The page redirects back with a success or error notice.
Expand Down
93 changes: 93 additions & 0 deletions includes/class-ipquery-admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
add_action( 'admin_post_ipquery_delete_by_country', array( self::class, 'handle_delete_by_country' ) );
add_action( 'admin_post_ipquery_purge', array( self::class, 'handle_purge' ) );
add_action( 'admin_post_ipquery_lookup', array( self::class, 'handle_manual_lookup' ) );
add_action( 'admin_post_ipquery_export_csv', array( self::class, 'handle_export_csv' ) );
add_action( 'wp_ajax_ipquery_chart_data', array( self::class, 'ajax_chart_data' ) );
add_action( 'wp_ajax_ipquery_heatmap_data', array( self::class, 'ajax_heatmap_data' ) );

Expand Down Expand Up @@ -330,6 +331,98 @@
exit;
}

/**
* Streams all matching visitor records as a CSV download.
*
* @return void
*/
public static function handle_export_csv(): void {

Check warning on line 339 in includes/class-ipquery-admin.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "handle_export_csv" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=guibranco_ipquery-wordpress&issues=AZ4g2XtqfsSAqMF9qnbi&open=AZ4g2XtqfsSAqMF9qnbi&pullRequest=56
check_admin_referer( 'ipquery_export_csv' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'Not allowed.', 'ipquery' ) );
}

$search = sanitize_text_field( wp_unslash( $_POST['s'] ?? '' ) );
$risk_filter = sanitize_text_field( wp_unslash( $_POST['risk_filter'] ?? '' ) );

$rows = IpQuery_DB::get_all_for_export(
array(
'search' => $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,
)
Comment thread
guibranco marked this conversation as resolved.
);
}

fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
exit;
}

// -------------------------------------------------------------------------
// AJAX endpoints.
// -------------------------------------------------------------------------
Expand Down
55 changes: 55 additions & 0 deletions includes/class-ipquery-db.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,4 +346,59 @@
$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<string,mixed> $args Query arguments (orderby, order, search, country_code, risk_filter).
* @return array<int,array<string,mixed>>
*/
public static function get_all_for_export( array $args = array() ): array {

Check warning on line 356 in includes/class-ipquery-db.php

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Rename function "get_all_for_export" to match the regular expression ^[a-z][a-zA-Z0-9]*$.

See more on https://sonarcloud.io/project/issues?id=guibranco_ipquery-wordpress&issues=AZ4g2XrefsSAqMF9qnbh&open=AZ4g2XrefsSAqMF9qnbh&pullRequest=56
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.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

Check warning on line 399 in includes/class-ipquery-db.php

View workflow job for this annotation

GitHub Actions / WP Plugin Check

PluginCheck.Security.DirectDB.UnescapedDBParameter

Unescaped parameter $sql used in $wpdb->get_results()\n$sql assigned unsafely at line 394.
}

return is_array( $rows ) ? $rows : array();
}
Comment thread
guibranco marked this conversation as resolved.
}
4 changes: 2 additions & 2 deletions ipquery.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__ ) );
Expand Down
11 changes: 10 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 ==

Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand Down