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 / API / Geolocation.php

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

292 lines 8.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace BetterLinks\API;
4 if ( ! defined( 'ABSPATH' ) ) { exit; }
5
6 use BetterLinks\Services\CountryDetectionService;
7
8 /**
9 * Geolocation REST API
10 *
11 * Provides backend fallback for frontend geolocation detection
12 */
13 class Geolocation {
14
15 private $namespace = BETTERLINKS_PLUGIN_SLUG . '/v1';
16
17 /**
18 * Initialize hooks
19 */
20 public function __construct() {
21 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
22 }
23
24 /**
25 * Register the routes for geolocation detection
26 */
27 public function register_routes() {
28 // Public on purpose: this resolves the CURRENT visitor's own IP as a fallback for
29 // the frontend tracker, so it runs for logged-out visitors and takes no IP input.
30 register_rest_route(
31 $this->namespace,
32 '/geolocation/detect',
33 array(
34 array(
35 'methods' => \WP_REST_Server::READABLE,
36 'callback' => array( $this, 'detect_country' ),
37 'permission_callback' => '__return_true',
38 ),
39 )
40 );
41
42 // Endpoint to fetch country for a specific IP (for backward compatibility).
43 // Authenticated only: it takes an ARBITRARY ip, so leaving it open turned every
44 // site into a free IP->country lookup proxy that burned the upstream API quota
45 // and wrote one transient per probed IP. Its only callers are the admin
46 // analytics country backfill paths, which already send a REST nonce.
47 register_rest_route(
48 $this->namespace,
49 '/geolocation/fetch-by-ip',
50 array(
51 array(
52 'methods' => \WP_REST_Server::READABLE,
53 'callback' => array( $this, 'fetch_country_by_ip' ),
54 'permission_callback' => array( $this, 'fetch_by_ip_permissions_check' ),
55 'args' => array(
56 'ip' => array(
57 'required' => true,
58 'type' => 'string',
59 'sanitize_callback' => 'sanitize_text_field',
60 ),
61 ),
62 ),
63 )
64 );
65 }
66
67 /**
68 * Only users who can see analytics may resolve an arbitrary IP.
69 *
70 * Mirrors the permission filter used by the clicks/analytics endpoints so Pro's
71 * role matrix keeps working for non-admin roles that were granted analytics access.
72 *
73 * @return bool
74 */
75 public function fetch_by_ip_permissions_check() {
76 return (bool) apply_filters( 'betterlinks/api/analytics_items_permissions_check', current_user_can( 'manage_options' ) );
77 }
78
79 /**
80 * Per-peer throttle for the public detect endpoint.
81 *
82 * Keyed on REMOTE_ADDR only — never on a forwarding header, which the caller
83 * controls and could vary to get a fresh bucket per request.
84 *
85 * @return bool True when the request is within budget.
86 */
87 private function within_rate_limit() {
88 $limit = (int) apply_filters( 'betterlinks/geolocation/detect_rate_limit', 30 );
89
90 if ( $limit <= 0 ) {
91 return true;
92 }
93
94 $peer = isset( $_SERVER['REMOTE_ADDR'] )
95 ? trim( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) )
96 : 'unknown';
97
98 return CountryDetectionService::consume_bucket(
99 'btl_geo_rl_' . md5( $peer ),
100 $limit,
101 MINUTE_IN_SECONDS
102 );
103 }
104
105 /**
106 * Detect country for current user's IP
107 *
108 * This is a fallback endpoint when frontend geolocation fails
109 *
110 * @param \WP_REST_Request $request Full data about the request.
111 * @return \WP_REST_Response
112 */
113 public function detect_country( $request ) {
114 // Unauthenticated endpoint: throttle per calling peer before doing any
115 // work. The client IP is now taken from REMOTE_ADDR (see
116 // CountryDetectionService::get_current_client_ip), so a single caller can
117 // no longer spoof a fresh IP per request to force cache misses — this
118 // bucket bounds what one peer can still cost us, and the service-level
119 // hourly budget bounds outbound lookups site-wide.
120 if ( ! $this->within_rate_limit() ) {
121 return new \WP_REST_Response(
122 array(
123 'success' => false,
124 'message' => 'Too many requests',
125 'code' => 'rate_limited',
126 'data' => null,
127 ),
128 429
129 );
130 }
131
132 // Check if BetterLinks Pro v2.5.0 or newer is installed
133 if ( ! defined( 'BETTERLINKS_PRO_VERSION' ) || version_compare( BETTERLINKS_PRO_VERSION, '2.5.0', '<' ) ) {
134 return new \WP_REST_Response(
135 array(
136 'success' => false,
137 'message' => 'Country detection requires BetterLinks Pro v2.5.0 or newer',
138 'code' => 'pro_version_required',
139 'data' => null,
140 ),
141 403
142 );
143 }
144
145 // Additional check: Verify Pro plugin has the country tracking function (prevents bypass with old Pro files)
146 if ( ! class_exists( 'BetterLinksPro\\Helper' ) ||
147 ! method_exists( 'BetterLinksPro\\Helper', 'is_country_tracking_enabled' ) ||
148 ! \BetterLinksPro\Helper::is_country_tracking_enabled() ) {
149 return new \WP_REST_Response(
150 array(
151 'success' => false,
152 'message' => 'Please update BetterLinks Pro to v2.5.0 or newer to use this feature',
153 'code' => 'pro_update_required',
154 'data' => null,
155 ),
156 403
157 );
158 }
159
160 $ip = CountryDetectionService::get_current_client_ip();
161
162 if ( ! $ip ) {
163 return new \WP_REST_Response(
164 array(
165 'success' => false,
166 'message' => 'Could not determine client IP',
167 'data' => null,
168 ),
169 400
170 );
171 }
172
173 $country_data = CountryDetectionService::get_country_by_ip( $ip );
174
175 if ( $country_data ) {
176 // Get or create country record and include country_id in response
177 $country_id = CountryDetectionService::get_or_create_country_id(
178 $country_data['country_code'],
179 $country_data['country_name']
180 );
181
182 $response_data = $country_data;
183 if ( $country_id ) {
184 $response_data['country_id'] = $country_id;
185 }
186
187 return new \WP_REST_Response(
188 array(
189 'success' => true,
190 'message' => 'Country detected successfully',
191 'data' => $response_data,
192 ),
193 200
194 );
195 }
196
197 return new \WP_REST_Response(
198 array(
199 'success' => false,
200 'message' => 'Could not detect country for this IP',
201 'data' => null,
202 ),
203 404
204 );
205 }
206
207 /**
208 * Fetch country data for a specific IP address
209 *
210 * Used for backward compatibility to fetch country for existing clicks
211 *
212 * @param \WP_REST_Request $request Full data about the request.
213 * @return \WP_REST_Response
214 */
215 public function fetch_country_by_ip( $request ) {
216 // Check if BetterLinks Pro v2.5.0 or newer is installed
217 if ( ! defined( 'BETTERLINKS_PRO_VERSION' ) || version_compare( BETTERLINKS_PRO_VERSION, '2.5.0', '<' ) ) {
218 return new \WP_REST_Response(
219 array(
220 'success' => false,
221 'message' => 'Country detection requires BetterLinks Pro v2.5.0 or newer',
222 'code' => 'pro_version_required',
223 'data' => null,
224 ),
225 403
226 );
227 }
228
229 // Additional check: Verify Pro plugin has the country tracking function (prevents bypass with old Pro files)
230 if ( ! class_exists( 'BetterLinksPro\\Helper' ) ||
231 ! method_exists( 'BetterLinksPro\\Helper', 'is_country_tracking_enabled' ) ||
232 ! \BetterLinksPro\Helper::is_country_tracking_enabled() ) {
233 return new \WP_REST_Response(
234 array(
235 'success' => false,
236 'message' => 'Please update BetterLinks Pro to v2.5.0 or newer to use this feature',
237 'code' => 'pro_update_required',
238 'data' => null,
239 ),
240 403
241 );
242 }
243
244 $ip = $request->get_param( 'ip' );
245
246 if ( ! $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
247 return new \WP_REST_Response(
248 array(
249 'success' => false,
250 'message' => 'Invalid IP address',
251 'data' => null,
252 ),
253 400
254 );
255 }
256
257 $country_data = CountryDetectionService::get_country_by_ip( $ip );
258
259 if ( $country_data ) {
260 // Get or create country record and include country_id in response
261 $country_id = CountryDetectionService::get_or_create_country_id(
262 $country_data['country_code'],
263 $country_data['country_name']
264 );
265
266 $response_data = $country_data;
267 if ( $country_id ) {
268 $response_data['country_id'] = $country_id;
269 }
270
271 return new \WP_REST_Response(
272 array(
273 'success' => true,
274 'message' => 'Country detected successfully',
275 'data' => $response_data,
276 ),
277 200
278 );
279 }
280
281 return new \WP_REST_Response(
282 array(
283 'success' => false,
284 'message' => 'Could not detect country for this IP',
285 'data' => null,
286 ),
287 404
288 );
289 }
290 }
291
292