PluginProbe
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP / trunk
BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP vtrunk
3.1.3 3.1.2 3.1.1 3.1.0 3.0.1 3.0.0 2.4.13 2.4.12 2.4.11 2.4.10 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 All 110 releases
betterlinks / includes / Services / CountryDetectionService.php

CountryDetectionService.php in BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager & MCP trunk, at includes/Services/CountryDetectionService.php

794 lines 26.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace BetterLinks\Services;
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 use BetterLinks\Helper;
6
7 // phpcs:disable PluginCheck.Security.DirectDB, WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL
8
9 /**
10 * Country Detection Service
11 *
12 * Handles IP-to-country detection with caching and efficient database storage
13 */
14 class CountryDetectionService {
15
16 /**
17 * Cache duration for IP-to-country mapping (24 hours)
18 */
19 const CACHE_DURATION = DAY_IN_SECONDS;
20
21 /**
22 * Ceiling on outbound provider lookups per hour, across the whole site.
23 *
24 * Cache hits do not count, and lookups are keyed on the real peer, so on a
25 * normal site this tracks new unique visitors per hour. Deliberately set well
26 * above real-world traffic: it is a runaway circuit breaker, not a functional
27 * quota, and must not throttle geolocation on a busy site. Override with the
28 * `betterlinks/geolocation/hourly_lookup_limit` filter (0 or less disables it).
29 */
30 const MAX_LOOKUPS_PER_HOUR = 5000;
31
32 /**
33 * Multiple API endpoints for fallback support
34 * Tries APIs in order until one succeeds
35 */
36 const API_ENDPOINTS = array(
37 array(
38 'url' => 'http://ip-api.com/json/{IP}',
39 'limit' => '45 requests per minute',
40 'country_field' => 'country',
41 'country_code_field' => 'countryCode'
42 ),
43 array(
44 'url' => 'https://api.db-ip.com/v2/free/{IP}',
45 'limit' => '500 requests per day',
46 'country_field' => 'countryName',
47 'country_code_field' => 'countryCode'
48 ),
49 array(
50 'url' => 'https://free.freeipapi.com/api/json/{IP}',
51 'limit' => '60 requests per minute',
52 'country_field' => 'countryName',
53 'country_code_field' => 'countryCode'
54 ),
55 array(
56 'url' => 'https://api.ipinfo.io/lite/{IP}?token=42ae8aabca02ac',
57 'limit' => 'depends on token plan',
58 'country_field' => 'country',
59 'country_code_field' => 'country_code'
60 )
61 );
62
63 /**
64 * Get country information for an IP address
65 *
66 * Note: Country data is primarily detected on the frontend via JavaScript.
67 * This method is used as a fallback when frontend detection fails.
68 *
69 * @param string $ip The IP address to lookup
70 * @return array|null Array with country_code and country_name, or null if not found
71 */
72 public static function get_country_by_ip( $ip ) {
73 if ( empty( $ip ) || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
74 return null;
75 }
76
77 // Check cache first
78 $cached_country = self::get_cached_country( $ip );
79 if ( $cached_country !== null ) {
80 return $cached_country;
81 }
82
83 // Try to fetch from APIs if not cached
84 $country_data = self::fetch_country_from_api( $ip );
85 if ( $country_data ) {
86 // Cache the result
87 self::cache_country( $ip, $country_data );
88 return $country_data;
89 }
90
91 return null;
92 }
93
94 /**
95 * Get cached country data for an IP
96 *
97 * @param string $ip The IP address
98 * @return array|null Cached country data or null
99 */
100 private static function get_cached_country( $ip ) {
101 $cache_key = 'btl_country_' . md5( $ip );
102 $cached = get_transient( $cache_key );
103
104 if ( $cached && is_array( $cached ) ) {
105 return $cached;
106 }
107
108 return null;
109 }
110
111 /**
112 * Cache country data for an IP
113 *
114 * @param string $ip The IP address
115 * @param array $country_data Country information
116 */
117 public static function cache_country( $ip, $country_data ) {
118 $cache_key = 'btl_country_' . md5( $ip );
119 set_transient( $cache_key, $country_data, self::CACHE_DURATION );
120 }
121
122 /**
123 * Fetch country data from multiple APIs with fallback support
124 *
125 * @param string $ip The IP address
126 * @return array|null Country data from API or null
127 */
128 private static function fetch_country_from_api( $ip ) {
129 // Global circuit breaker on OUTBOUND lookups (cache misses only). Bounds
130 // upstream provider quota burn and the number of per-IP transients this
131 // plugin can create in an hour, no matter which caller triggers it.
132 if ( ! self::consume_lookup_budget() ) {
133 return null;
134 }
135
136 foreach ( self::API_ENDPOINTS as $api_config ) {
137 $country_data = self::try_single_api( $ip, $api_config );
138 if ( $country_data ) {
139 return $country_data;
140 }
141 }
142 return null;
143 }
144
145 /**
146 * Fixed-window budget for outbound geolocation lookups.
147 *
148 * @return bool True when this lookup is allowed to proceed.
149 */
150 private static function consume_lookup_budget() {
151 $limit = (int) apply_filters( 'betterlinks/geolocation/hourly_lookup_limit', self::MAX_LOOKUPS_PER_HOUR );
152 $window = HOUR_IN_SECONDS;
153
154 if ( $limit <= 0 ) {
155 return true; // Explicitly disabled by the site owner.
156 }
157
158 return self::consume_bucket( 'btl_geo_lookup_budget', $limit, $window );
159 }
160
161 /**
162 * Fixed-window counter shared by the lookup budget and the REST rate limiter.
163 *
164 * @param string $key Transient key.
165 * @param int $limit Allowed hits per window.
166 * @param int $window Window length in seconds.
167 * @return bool True when the hit is within budget.
168 */
169 public static function consume_bucket( $key, $limit, $window ) {
170 $bucket = get_transient( $key );
171 $now = time();
172
173 if ( ! is_array( $bucket ) || ! isset( $bucket['start'], $bucket['count'] ) || ( $now - (int) $bucket['start'] ) >= $window ) {
174 $bucket = array(
175 'start' => $now,
176 'count' => 0,
177 );
178 }
179
180 ++$bucket['count'];
181
182 // Keep the transient alive only for the remainder of the current window
183 // so the counter cannot be held open indefinitely by continued traffic.
184 $ttl = max( 1, $window - ( $now - (int) $bucket['start'] ) );
185 set_transient( $key, $bucket, $ttl );
186
187 return ( $bucket['count'] <= $limit );
188 }
189
190 /**
191 * Try a single API endpoint
192 *
193 * @param string $ip The IP address
194 * @param array $api_config API configuration
195 * @return array|null Country data or null if failed
196 */
197 private static function try_single_api( $ip, $api_config ) {
198 $rate_limit_key = 'btl_api_rate_limit_' . md5( $api_config['url'] );
199 if ( get_transient( $rate_limit_key ) ) {
200 return null;
201 }
202
203 $api_url = str_replace( '{IP}', $ip, $api_config['url'] );
204
205 $response = wp_remote_get( $api_url, array(
206 'timeout' => 10,
207 'headers' => array(
208 'User-Agent' => 'BetterLinks/' . BETTERLINKS_VERSION
209 )
210 ) );
211
212 if ( is_wp_error( $response ) ) {
213 return null;
214 }
215
216 $response_code = wp_remote_retrieve_response_code( $response );
217
218 if ( $response_code === 429 ) {
219 set_transient( $rate_limit_key, true, 5 * MINUTE_IN_SECONDS );
220 return null;
221 }
222
223 if ( $response_code !== 200 ) {
224 return null;
225 }
226
227 $body = wp_remote_retrieve_body( $response );
228 $data = json_decode( $body, true );
229
230 if ( ! $data ) {
231 return null;
232 }
233
234 if ( isset( $data['status'] ) && $data['status'] === 'fail' ) {
235 return null;
236 }
237
238 $country_field = $api_config['country_field'];
239 $country_code_field = $api_config['country_code_field'];
240
241 if ( ! isset( $data[$country_field] ) || ! isset( $data[$country_code_field] ) ) {
242 return null;
243 }
244
245 return array(
246 'country_code' => sanitize_text_field( $data[$country_code_field] ),
247 'country_name' => sanitize_text_field( $data[$country_field] ),
248 );
249 }
250
251 /**
252 * Get or create country record and return country_id
253 *
254 * @param string $country_code The country code
255 * @param string $country_name The country name
256 * @return int|null Country ID or null if failed
257 */
258 public static function get_or_create_country_id( $country_code, $country_name ) {
259 global $wpdb;
260
261 if ( empty( $country_code ) || empty( $country_name ) ) {
262 return null;
263 }
264
265 $table_name = $wpdb->prefix . 'betterlinks_countries';
266
267 // Try to get existing country
268 $country = $wpdb->get_row( $wpdb->prepare(
269 "SELECT id FROM {$table_name} WHERE country_code = %s",
270 $country_code
271 ), ARRAY_A );
272
273 if ( $country ) {
274 return (int) $country['id'];
275 }
276
277 // Create new country record
278 $inserted = $wpdb->insert(
279 $table_name,
280 array(
281 'country_code' => $country_code,
282 'country_name' => $country_name,
283 ),
284 array( '%s', '%s' )
285 );
286
287 if ( $inserted ) {
288 return (int) $wpdb->insert_id;
289 }
290
291 return null;
292 }
293
294 /**
295 * Get country data from lookup table by country code
296 *
297 * @param string $country_code The country code
298 * @return array|null Country data or null
299 */
300 public static function get_country_from_lookup_table( $country_code ) {
301 global $wpdb;
302
303 $table_name = $wpdb->prefix . 'betterlinks_countries';
304
305 $country = $wpdb->get_row( $wpdb->prepare(
306 "SELECT * FROM {$table_name} WHERE country_code = %s",
307 $country_code
308 ), ARRAY_A );
309
310 return $country ? $country : null;
311 }
312
313 /**
314 * Get country data by country_id
315 *
316 * @param int $country_id The country ID
317 * @return array|null Country data or null
318 */
319 public static function get_country_by_id( $country_id ) {
320 global $wpdb;
321
322 $table_name = $wpdb->prefix . 'betterlinks_countries';
323
324 $country = $wpdb->get_row( $wpdb->prepare(
325 "SELECT * FROM {$table_name} WHERE id = %d",
326 $country_id
327 ), ARRAY_A );
328
329 return $country ? $country : null;
330 }
331
332 /**
333 * Get all countries from lookup table
334 *
335 * @return array Array of all countries
336 */
337 public static function get_all_countries() {
338 global $wpdb;
339
340 $table_name = $wpdb->prefix . 'betterlinks_countries';
341
342 $countries = $wpdb->get_results(
343 "SELECT * FROM {$table_name} ORDER BY country_name ASC",
344 ARRAY_A
345 );
346
347 return $countries ? $countries : array();
348 }
349
350 /**
351 * Clear country cache for an IP
352 *
353 * @param string $ip The IP address
354 */
355 public static function clear_country_cache( $ip ) {
356 $cache_key = 'btl_country_' . md5( $ip );
357 delete_transient( $cache_key );
358 }
359
360
361
362 /**
363 * Get country statistics for analytics
364 *
365 * @param string $from Start date
366 * @param string $to End date
367 * @param int|null $link_id Optional link ID to filter by
368 * @param int|null $limit Optional row cap. Null returns every country, which
369 * the geography map needs so it can shade the whole
370 * world; the list view passes a small number.
371 * @return array Country statistics
372 */
373 public static function get_country_statistics( $from, $to, $link_id = null, $limit = null ) {
374 global $wpdb;
375
376 $cache_key = 'btl_country_stats_' . md5( $from . $to . $link_id . '_' . $limit );
377 $cached = get_transient( $cache_key );
378
379 if ( $cached && is_array( $cached ) ) {
380 return $cached;
381 }
382
383 $clicks_table = $wpdb->prefix . 'betterlinks_clicks';
384 $countries_table = $wpdb->prefix . 'betterlinks_countries';
385
386 $where_clause = "WHERE c.created_at BETWEEN %s AND %s AND c.country_id IS NOT NULL";
387 $params = array( $from . ' 00:00:00', $to . ' 23:59:59' );
388
389 if ( $link_id ) {
390 $where_clause .= " AND c.link_id = %d";
391 $params[] = $link_id;
392 }
393
394 $limit_clause = '';
395 if ( null !== $limit ) {
396 $limit_clause = ' LIMIT %d';
397 $params[] = (int) $limit;
398 }
399
400 // Placeholders supplied via $params; $clicks_table/$countries_table/$where_clause built from controlled internal values.
401 // phpcs:disable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
402 $query = $wpdb->prepare(
403 "SELECT co.country_code, co.country_name, COUNT(*) as clicks, COUNT(DISTINCT c.ip) as unique_clicks
404 FROM {$clicks_table} c
405 LEFT JOIN {$countries_table} co ON c.country_id = co.id
406 {$where_clause}
407 GROUP BY c.country_id, co.country_code, co.country_name
408 ORDER BY clicks DESC{$limit_clause}",
409 $params
410 );
411 // phpcs:enable WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
412
413 $results = $wpdb->get_results( $query, ARRAY_A );
414
415 set_transient( $cache_key, $results, self::CACHE_DURATION );
416 return $results ? $results : array();
417 }
418
419 /**
420 * Get current client IP address
421 *
422 * Only REMOTE_ADDR is trusted by default. Forwarding headers
423 * (X-Forwarded-For and friends) are attacker-controlled on any request that
424 * does not physically come through a reverse proxy: previously an anonymous
425 * caller could send an arbitrary public IP per request, which defeated the
426 * per-IP transient cache and forced one fresh outbound geolocation lookup
427 * (up to four providers, 10s timeout each) for every request — burning the
428 * site owner's upstream quota and tying up PHP workers.
429 *
430 * A forwarding header is honored only when the immediate peer (REMOTE_ADDR)
431 * is inside an operator-configured trusted-proxy range, and then only for a
432 * single named header whose chain is parsed from the trusted (right) end.
433 *
434 * Configure with either:
435 * - option `betterlinks_trusted_proxies` (array or newline/comma separated
436 * list of IPs / CIDRs), or
437 * - filter `betterlinks/geolocation/trusted_proxies`.
438 * The header can be swapped with `betterlinks/geolocation/forwarded_header`
439 * (e.g. `HTTP_CF_CONNECTING_IP` behind Cloudflare).
440 *
441 * @return string|null The client IP address or null
442 */
443 public static function get_current_client_ip() {
444 $remote_addr = isset( $_SERVER['REMOTE_ADDR'] )
445 ? trim( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) )
446 : '';
447
448 $client_ip = self::get_forwarded_client_ip( $remote_addr );
449
450 if ( null === $client_ip ) {
451 $client_ip = $remote_addr;
452 }
453
454 // Compatibility fallback. If REMOTE_ADDR is private/reserved, the request
455 // definitively arrived through a local load balancer or reverse proxy and
456 // REMOTE_ADDR carries no visitor information at all — returning null here
457 // would silently switch country detection off for every site on that kind
458 // of hosting. Those setups cannot be attacked by varying a header either:
459 // the peer is the operator's own proxy. Fall back to the legacy header
460 // walk, which is no worse than the previous behaviour for these sites.
461 if ( ! filter_var( $client_ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE )
462 && filter_var( $remote_addr, FILTER_VALIDATE_IP ) ) {
463 $legacy = self::get_legacy_forwarded_ip();
464
465 if ( null !== $legacy ) {
466 $client_ip = $legacy;
467 }
468 }
469
470 // Reject private/reserved space: those are never resolvable to a country
471 // and must not be handed to an outbound provider lookup.
472 if ( filter_var( $client_ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
473 return $client_ip;
474 }
475
476 return null;
477 }
478
479 /**
480 * Resolve the client IP from a forwarding header, if and only if the request
481 * actually arrived through a trusted proxy.
482 *
483 * @param string $remote_addr The immediate peer address.
484 * @return string|null Forwarded client IP, or null to fall back to REMOTE_ADDR.
485 */
486 private static function get_forwarded_client_ip( $remote_addr ) {
487 $trusted = self::get_trusted_proxies();
488
489 if ( empty( $trusted ) || ! self::ip_matches_any( $remote_addr, $trusted ) ) {
490 return null;
491 }
492
493 // Behind Cloudflare the canonical header is CF-Connecting-IP, and it is a
494 // single address rather than a chain. Only reachable when REMOTE_ADDR is a
495 // Cloudflare edge, which the trusted-proxy check above has established.
496 $default_header = ( self::ip_matches_any( $remote_addr, self::cloudflare_ranges() ) && ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) )
497 ? 'HTTP_CF_CONNECTING_IP'
498 : 'HTTP_X_FORWARDED_FOR';
499
500 $header = apply_filters( 'betterlinks/geolocation/forwarded_header', $default_header );
501 $header = is_string( $header ) ? strtoupper( str_replace( '-', '_', $header ) ) : '';
502
503 if ( '' === $header || empty( $_SERVER[ $header ] ) ) {
504 return null;
505 }
506
507 $raw = sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) );
508
509 // Single-value headers (CF-Connecting-IP, True-Client-IP) carry one address.
510 if ( strpos( $raw, ',' ) === false ) {
511 $candidate = trim( $raw );
512 return filter_var( $candidate, FILTER_VALIDATE_IP ) ? $candidate : null;
513 }
514
515 // X-Forwarded-For style chain: the rightmost entries were appended by our
516 // own proxies, so walk from the trusted end inward and take the first hop
517 // that is not itself a trusted proxy. Anything an external client
518 // prepended stays to the left of that and is never reached.
519 $chain = array_map( 'trim', explode( ',', $raw ) );
520
521 for ( $i = count( $chain ) - 1; $i >= 0; $i-- ) {
522 $candidate = $chain[ $i ];
523
524 if ( ! filter_var( $candidate, FILTER_VALIDATE_IP ) ) {
525 // Ambiguous / malformed chain — refuse to guess.
526 return null;
527 }
528
529 if ( ! self::ip_matches_any( $candidate, $trusted ) ) {
530 return $candidate;
531 }
532 }
533
534 return null;
535 }
536
537 /**
538 * Operator-configured trusted proxy IPs / CIDRs.
539 *
540 * @return array
541 */
542 private static function get_trusted_proxies() {
543 $configured = get_option( 'betterlinks_trusted_proxies', array() );
544
545 if ( is_string( $configured ) ) {
546 $configured = preg_split( '/[\s,]+/', $configured, -1, PREG_SPLIT_NO_EMPTY );
547 }
548
549 // Cloudflare is trusted out of the box: it is by far the most common proxy
550 // in front of WordPress sites, and without it every Cloudflare-fronted site
551 // would suddenly resolve all visitors to a Cloudflare edge IP. Override the
552 // whole list — including this default — with the filter below.
553 $configured = array_merge( self::cloudflare_ranges(), (array) $configured );
554 $configured = apply_filters( 'betterlinks/geolocation/trusted_proxies', $configured );
555
556 return array_values( array_filter( array_map( 'trim', array_map( 'strval', $configured ) ) ) );
557 }
558
559 /**
560 * Cloudflare's published edge ranges.
561 *
562 * Source: https://www.cloudflare.com/ips/ — refresh with the
563 * `betterlinks/geolocation/cloudflare_ranges` filter if Cloudflare adds a
564 * block before the next plugin release.
565 *
566 * @return array
567 */
568 private static function cloudflare_ranges() {
569 return (array) apply_filters(
570 'betterlinks/geolocation/cloudflare_ranges',
571 array(
572 '173.245.48.0/20',
573 '103.21.244.0/22',
574 '103.22.200.0/22',
575 '103.31.4.0/22',
576 '141.101.64.0/18',
577 '108.162.192.0/18',
578 '190.93.240.0/20',
579 '188.114.96.0/20',
580 '197.234.240.0/22',
581 '198.41.128.0/17',
582 '162.158.0.0/15',
583 '104.16.0.0/13',
584 '104.24.0.0/14',
585 '172.64.0.0/13',
586 '131.0.72.0/22',
587 '2400:cb00::/32',
588 '2606:4700::/32',
589 '2803:f800::/32',
590 '2405:b500::/32',
591 '2405:8100::/32',
592 '2a06:98c0::/29',
593 '2c0f:f248::/32',
594 )
595 );
596 }
597
598 /**
599 * Legacy forwarding-header walk.
600 *
601 * Only used as a fallback when REMOTE_ADDR is private/reserved, i.e. the site
602 * sits behind a proxy we could not identify and REMOTE_ADDR is useless. Not
603 * reachable on directly-connected sites, where header spoofing is the actual
604 * attack.
605 *
606 * @return string|null
607 */
608 private static function get_legacy_forwarded_ip() {
609 $ip_keys = array(
610 'HTTP_CF_CONNECTING_IP',
611 'HTTP_TRUE_CLIENT_IP',
612 'HTTP_X_FORWARDED_FOR',
613 'HTTP_X_REAL_IP',
614 'HTTP_CLIENT_IP',
615 );
616
617 foreach ( $ip_keys as $key ) {
618 if ( empty( $_SERVER[ $key ] ) ) {
619 continue;
620 }
621
622 $ip = sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) );
623
624 if ( strpos( $ip, ',' ) !== false ) {
625 $ip = explode( ',', $ip )[0];
626 }
627
628 $ip = trim( $ip );
629
630 if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
631 return $ip;
632 }
633 }
634
635 return null;
636 }
637
638 /**
639 * Does $ip fall inside any of the given IPs / CIDR ranges?
640 *
641 * @param string $ip Address to test.
642 * @param array $ranges IPs or CIDR blocks.
643 * @return bool
644 */
645 private static function ip_matches_any( $ip, $ranges ) {
646 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
647 return false;
648 }
649
650 foreach ( $ranges as $range ) {
651 if ( self::ip_in_range( $ip, $range ) ) {
652 return true;
653 }
654 }
655
656 return false;
657 }
658
659 /**
660 * CIDR / exact-address match for both IPv4 and IPv6.
661 *
662 * @param string $ip Address to test.
663 * @param string $range IP or CIDR block.
664 * @return bool
665 */
666 private static function ip_in_range( $ip, $range ) {
667 if ( strpos( $range, '/' ) === false ) {
668 $packed_ip = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
669 $packed_range = @inet_pton( $range ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
670
671 return ( false !== $packed_ip && false !== $packed_range && $packed_ip === $packed_range );
672 }
673
674 list( $subnet, $bits ) = explode( '/', $range, 2 );
675
676 if ( ! is_numeric( $bits ) ) {
677 return false;
678 }
679
680 $bits = (int) $bits;
681 $packed_ip = @inet_pton( $ip ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
682 $packed_range = @inet_pton( $subnet ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
683
684 if ( false === $packed_ip || false === $packed_range || strlen( $packed_ip ) !== strlen( $packed_range ) ) {
685 return false;
686 }
687
688 $max_bits = strlen( $packed_ip ) * 8;
689
690 if ( $bits < 0 || $bits > $max_bits ) {
691 return false;
692 }
693
694 $whole_bytes = intdiv( $bits, 8 );
695 $remaining_bits = $bits % 8;
696
697 if ( $whole_bytes > 0 && strncmp( $packed_ip, $packed_range, $whole_bytes ) !== 0 ) {
698 return false;
699 }
700
701 if ( 0 === $remaining_bits ) {
702 return true;
703 }
704
705 $mask = chr( ( 0xff << ( 8 - $remaining_bits ) ) & 0xff );
706
707 return ( ( $packed_ip[ $whole_bytes ] & $mask ) === ( $packed_range[ $whole_bytes ] & $mask ) );
708 }
709
710 /**
711 * Backfill country data for existing clicks without country information
712 *
713 * @param int $limit Number of records to process per batch
714 * @return array Processing results
715 */
716 public static function backfill_country_data( $limit = 100 ) {
717 global $wpdb;
718
719 // Get clicks without country_id
720 $clicks = $wpdb->get_results( $wpdb->prepare(
721 "SELECT ID, ip FROM {$wpdb->prefix}betterlinks_clicks
722 WHERE ip IS NOT NULL AND ip != ''
723 AND country_id IS NULL
724 LIMIT %d",
725 $limit
726 ), ARRAY_A );
727
728 $processed = 0;
729 $updated = 0;
730 $errors = 0;
731
732 foreach ( $clicks as $click ) {
733 $processed++;
734
735 $country_data = self::get_country_by_ip( $click['ip'] );
736
737 if ( $country_data ) {
738 // Get or create country record and get its ID
739 $country_id = self::get_or_create_country_id(
740 $country_data['country_code'],
741 $country_data['country_name']
742 );
743
744 if ( $country_id ) {
745 $result = $wpdb->update(
746 $wpdb->prefix . 'betterlinks_clicks',
747 array( 'country_id' => $country_id ),
748 array( 'ID' => $click['ID'] ),
749 array( '%d' ),
750 array( '%d' )
751 );
752
753 if ( $result !== false ) {
754 $updated++;
755 } else {
756 $errors++;
757 }
758 } else {
759 $errors++;
760 }
761 } else {
762 $errors++;
763 }
764
765 // Add a small delay to avoid overwhelming the API
766 usleep( 100000 ); // 0.1 second delay
767 }
768
769 return array(
770 'processed' => $processed,
771 'updated' => $updated,
772 'errors' => $errors,
773 'remaining' => self::get_clicks_without_country_count()
774 );
775 }
776
777 /**
778 * Get count of clicks without country data
779 *
780 * @return int Number of clicks without country data
781 */
782 public static function get_clicks_without_country_count() {
783 global $wpdb;
784
785 return (int) $wpdb->get_var(
786 "SELECT COUNT(*) FROM {$wpdb->prefix}betterlinks_clicks
787 WHERE ip IS NOT NULL AND ip != ''
788 AND country_id IS NULL"
789 );
790 }
791
792
793 }
794