# betterlinks/trunk/includes/API/Geolocation.php

BetterLinks – Link Shortener, Link Cloaking, Redirects, Affiliate Link Manager &amp; MCP, version trunk. 292 lines.

- Page: https://pluginprobe.com/plugins/betterlinks/trunk/code/includes/API/Geolocation.php
- Raw: https://pluginprobe.com/plugins/betterlinks/trunk/raw/includes/API/Geolocation.php
- Modified: 2026-08-21T07:10:22+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/betterlinks/trunk/code/includes/API/Geolocation.php#L10-L20`.

```php
<?php

namespace BetterLinks\API;
if ( ! defined( 'ABSPATH' ) ) { exit; }

use BetterLinks\Services\CountryDetectionService;

/**
 * Geolocation REST API
 *
 * Provides backend fallback for frontend geolocation detection
 */
class Geolocation {

	private $namespace = BETTERLINKS_PLUGIN_SLUG . '/v1';

	/**
	 * Initialize hooks
	 */
	public function __construct() {
		add_action( 'rest_api_init', [ $this, 'register_routes' ] );
	}

	/**
	 * Register the routes for geolocation detection
	 */
	public function register_routes() {
		// Public on purpose: this resolves the CURRENT visitor's own IP as a fallback for
		// the frontend tracker, so it runs for logged-out visitors and takes no IP input.
		register_rest_route(
			$this->namespace,
			'/geolocation/detect',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'detect_country' ),
					'permission_callback' => '__return_true',
				),
			)
		);

		// Endpoint to fetch country for a specific IP (for backward compatibility).
		// Authenticated only: it takes an ARBITRARY ip, so leaving it open turned every
		// site into a free IP->country lookup proxy that burned the upstream API quota
		// and wrote one transient per probed IP. Its only callers are the admin
		// analytics country backfill paths, which already send a REST nonce.
		register_rest_route(
			$this->namespace,
			'/geolocation/fetch-by-ip',
			array(
				array(
					'methods'             => \WP_REST_Server::READABLE,
					'callback'            => array( $this, 'fetch_country_by_ip' ),
					'permission_callback' => array( $this, 'fetch_by_ip_permissions_check' ),
					'args'                => array(
						'ip' => array(
							'required'          => true,
							'type'              => 'string',
							'sanitize_callback' => 'sanitize_text_field',
						),
					),
				),
			)
		);
	}

	/**
	 * Only users who can see analytics may resolve an arbitrary IP.
	 *
	 * Mirrors the permission filter used by the clicks/analytics endpoints so Pro's
	 * role matrix keeps working for non-admin roles that were granted analytics access.
	 *
	 * @return bool
	 */
	public function fetch_by_ip_permissions_check() {
		return (bool) apply_filters( 'betterlinks/api/analytics_items_permissions_check', current_user_can( 'manage_options' ) );
	}

	/**
	 * Per-peer throttle for the public detect endpoint.
	 *
	 * Keyed on REMOTE_ADDR only — never on a forwarding header, which the caller
	 * controls and could vary to get a fresh bucket per request.
	 *
	 * @return bool True when the request is within budget.
	 */
	private function within_rate_limit() {
		$limit = (int) apply_filters( 'betterlinks/geolocation/detect_rate_limit', 30 );

		if ( $limit <= 0 ) {
			return true;
		}

		$peer = isset( $_SERVER['REMOTE_ADDR'] )
			? trim( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) )
			: 'unknown';

		return CountryDetectionService::consume_bucket(
			'btl_geo_rl_' . md5( $peer ),
			$limit,
			MINUTE_IN_SECONDS
		);
	}

	/**
	 * Detect country for current user's IP
	 *
	 * This is a fallback endpoint when frontend geolocation fails
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 * @return \WP_REST_Response
	 */
	public function detect_country( $request ) {
		// Unauthenticated endpoint: throttle per calling peer before doing any
		// work. The client IP is now taken from REMOTE_ADDR (see
		// CountryDetectionService::get_current_client_ip), so a single caller can
		// no longer spoof a fresh IP per request to force cache misses — this
		// bucket bounds what one peer can still cost us, and the service-level
		// hourly budget bounds outbound lookups site-wide.
		if ( ! $this->within_rate_limit() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => 'Too many requests',
					'code'    => 'rate_limited',
					'data'    => null,
				),
				429
			);
		}

		// Check if BetterLinks Pro v2.5.0 or newer is installed
		if ( ! defined( 'BETTERLINKS_PRO_VERSION' ) || version_compare( BETTERLINKS_PRO_VERSION, '2.5.0', '<' ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => 'Country detection requires BetterLinks Pro v2.5.0 or newer',
					'code'    => 'pro_version_required',
					'data'    => null,
				),
				403
			);
		}

		// Additional check: Verify Pro plugin has the country tracking function (prevents bypass with old Pro files)
		if ( ! class_exists( 'BetterLinksPro\\Helper' ) || 
			 ! method_exists( 'BetterLinksPro\\Helper', 'is_country_tracking_enabled' ) ||
			 ! \BetterLinksPro\Helper::is_country_tracking_enabled() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => 'Please update BetterLinks Pro to v2.5.0 or newer to use this feature',
					'code'    => 'pro_update_required',
					'data'    => null,
				),
				403
			);
		}

		$ip = CountryDetectionService::get_current_client_ip();

		if ( ! $ip ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => 'Could not determine client IP',
					'data'    => null,
				),
				400
			);
		}

		$country_data = CountryDetectionService::get_country_by_ip( $ip );

		if ( $country_data ) {
			// Get or create country record and include country_id in response
			$country_id = CountryDetectionService::get_or_create_country_id(
				$country_data['country_code'],
				$country_data['country_name']
			);

			$response_data = $country_data;
			if ( $country_id ) {
				$response_data['country_id'] = $country_id;
			}

			return new \WP_REST_Response(
				array(
					'success' => true,
					'message' => 'Country detected successfully',
					'data'    => $response_data,
				),
				200
			);
		}

		return new \WP_REST_Response(
			array(
				'success' => false,
				'message' => 'Could not detect country for this IP',
				'data'    => null,
			),
			404
		);
	}

	/**
	 * Fetch country data for a specific IP address
	 *
	 * Used for backward compatibility to fetch country for existing clicks
	 *
	 * @param \WP_REST_Request $request Full data about the request.
	 * @return \WP_REST_Response
	 */
	public function fetch_country_by_ip( $request ) {
		// Check if BetterLinks Pro v2.5.0 or newer is installed
		if ( ! defined( 'BETTERLINKS_PRO_VERSION' ) || version_compare( BETTERLINKS_PRO_VERSION, '2.5.0', '<' ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => 'Country detection requires BetterLinks Pro v2.5.0 or newer',
					'code'    => 'pro_version_required',
					'data'    => null,
				),
				403
			);
		}

		// Additional check: Verify Pro plugin has the country tracking function (prevents bypass with old Pro files)
		if ( ! class_exists( 'BetterLinksPro\\Helper' ) || 
			 ! method_exists( 'BetterLinksPro\\Helper', 'is_country_tracking_enabled' ) ||
			 ! \BetterLinksPro\Helper::is_country_tracking_enabled() ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => 'Please update BetterLinks Pro to v2.5.0 or newer to use this feature',
					'code'    => 'pro_update_required',
					'data'    => null,
				),
				403
			);
		}

		$ip = $request->get_param( 'ip' );

		if ( ! $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
			return new \WP_REST_Response(
				array(
					'success' => false,
					'message' => 'Invalid IP address',
					'data'    => null,
				),
				400
			);
		}

		$country_data = CountryDetectionService::get_country_by_ip( $ip );

		if ( $country_data ) {
			// Get or create country record and include country_id in response
			$country_id = CountryDetectionService::get_or_create_country_id(
				$country_data['country_code'],
				$country_data['country_name']
			);

			$response_data = $country_data;
			if ( $country_id ) {
				$response_data['country_id'] = $country_id;
			}

			return new \WP_REST_Response(
				array(
					'success' => true,
					'message' => 'Country detected successfully',
					'data'    => $response_data,
				),
				200
			);
		}

		return new \WP_REST_Response(
			array(
				'success' => false,
				'message' => 'Could not detect country for this IP',
				'data'    => null,
			),
			404
		);
	}
}


```
