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 ); } }