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 / Link / Utils.php

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

400 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace BetterLinks\Link;
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 use BetterLinks\Helper;
6 use DeviceDetector\Parser\Device\AbstractDeviceParser;
7 use BetterLinks\Traits\Links;
8 use BetterLinks\Traits\ArgumentSchema;
9 use DeviceDetector\DeviceDetector;
10 use DeviceDetector\Parser\OperatingSystem;
11 use DeviceDetector\Parser\Client\Browser;
12
13 class Utils {
14 use Links;
15 use ArgumentSchema;
16
17 public function __construct() {
18 AbstractDeviceParser::setVersionTruncation( AbstractDeviceParser::VERSION_TRUNCATION_NONE );
19 }
20 public function get_slug_raw( $slug ) {
21 if ( BETTERLINKS_EXISTS_LINKS_JSON ) {
22 return apply_filters( 'betterlinks/link/get_link_by_slug', Helper::get_link_from_json_file( $slug ) );
23 }
24 $link_options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME, '{}' ), true );
25 $is_case_sensitive = isset( $link_options['is_case_sensitive'] ) ? $link_options['is_case_sensitive'] : false;
26 $results = current( Helper::get_link_by_short_url( $slug, $is_case_sensitive ) );
27 if ( ! empty( $results ) ) {
28 return apply_filters( 'betterlinks/link/get_link_by_slug', json_decode( wp_json_encode( $results ), true ) );
29 }
30 // wildcards.
31 $links_option = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
32 if ( isset( $links_option['wildcards'] ) && $links_option['wildcards'] ) {
33 $results = Helper::get_link_by_wildcards( 1 );
34 if ( is_array( $results ) && count( $results ) > 0 ) {
35 foreach ( $results as $key => $item ) {
36 $postion = strpos( $item['short_url'], '/*' );
37 if ( false !== $postion ) {
38 $item_short_url_substr = substr( $item['short_url'], 0, $postion );
39 $slug_substr = substr( $slug, 0, $postion );
40 if ( ! $is_case_sensitive ) {
41 $item_short_url_substr = strtolower( $item_short_url_substr );
42 $slug_substr = strtolower( $slug_substr );
43 }
44 if ( $item_short_url_substr === $slug_substr ) {
45 $target_postion = strpos( $item['target_url'], '/*' );
46 if ( false !== $target_postion ) {
47 $target_url = str_replace( '/*', substr( $slug, $postion ), $item['target_url'] );
48 $item['target_url'] = $target_url;
49 return apply_filters( 'betterlinks/link/get_link_by_slug', json_decode( wp_json_encode( $item ), true ) );
50 }
51 return apply_filters( 'betterlinks/link/get_link_by_slug', json_decode( wp_json_encode( $item ), true ) );
52 }
53 }
54 }
55 }
56 }
57 }
58 public function dispatch_redirect( $data, $param ) {
59 global $betterlinks;
60
61 $comparable_url = rtrim( preg_replace( '/https?\:\/\//', '', site_url( '/' ) ), '/' ) . '/' . $data['short_url'];
62 $destination_url = rtrim( preg_replace( '/https?\:\/\//', '', $data['target_url'] ), '/' );
63 $comparable_url = rtrim( preg_replace( '/^www\.?/', '', $comparable_url ), '/' );
64 $destination_url = rtrim( preg_replace( '/^www\.?/', '', $destination_url ), '/' );
65 if ( ! $data || $comparable_url === $destination_url ) {
66 return;
67 }
68
69 $target_url = $this->addScheme( $data['target_url'] );
70 $_query_params = array();
71 wp_parse_str( $param, $_query_params );
72 $data['pf'] = build_query( $_query_params );
73 if ( filter_var( $data['param_forwarding'], FILTER_VALIDATE_BOOLEAN ) && ! empty( $param ) && $param !== $data['link_slug'] ) {
74 $_target_url = wp_parse_url( $target_url );
75 $target_url .= ( isset( $_target_url['query'] ) ? '&' : '?' ) . $data['pf'];
76 }
77
78 // A HEAD request is a metadata probe (uptime monitors, preview crawlers, CDN
79 // health checks), not a real visit — resolve the redirect but never record a
80 // click for it, otherwise those automated hits would inflate analytics.
81 $is_head = isset( $_SERVER['REQUEST_METHOD'] ) && 'HEAD' === strtoupper( $_SERVER['REQUEST_METHOD'] ); // phpcs:ignore
82 if ( ! $is_head && filter_var( $data['track_me'], FILTER_VALIDATE_BOOLEAN ) ) {
83 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; // phpcs:ignore
84 $dd = new DeviceDetector( $user_agent );
85 $dd->parse();
86
87 $data = apply_filters( 'betterlinks/extra_tracking_data', $data, $dd );
88
89 $data['os'] = OperatingSystem::getOsFamily( $dd->getOs( 'name' ) );
90 $data['browser'] = Browser::getBrowserFamily( $dd->getClient( 'name' ) );
91 $data['device'] = $dd->getDeviceName();
92
93 // Record which hits were bots so Analytics can split human vs bot
94 // traffic. Pro's extra-data tracking sets a richer bot_name via the
95 // filter above; this fills it in on free, where the detector already
96 // ran for the disablebotclicks check, so it costs nothing extra.
97 if ( empty( $data['bot_name'] ) && $dd->isBot() ) {
98 $bot = $dd->getBot();
99 $bot_name = isset( $bot['name'] ) ? $bot['name'] : 'Unknown';
100 $data['bot_name'] = substr( $bot_name, 0, 20 ); // column is VARCHAR(20)
101 }
102
103 if ( isset( $betterlinks['disablebotclicks'] ) && $betterlinks['disablebotclicks'] ) {
104 if ( ! $dd->isBot() ) {
105 $this->start_trakcing( $data );
106 }
107 } else {
108 $this->start_trakcing( $data );
109 }
110 }
111
112
113 $robots_tags = array();
114 if ( filter_var( $data['sponsored'], FILTER_VALIDATE_BOOLEAN ) ) {
115 $robots_tags[] = 'sponsored';
116 }
117 if ( filter_var( $data['nofollow'], FILTER_VALIDATE_BOOLEAN ) ) {
118 $robots_tags[] = 'noindex';
119 $robots_tags[] = 'nofollow';
120 }
121 if ( ! empty( $robots_tags ) ) {
122 header( 'X-Robots-Tag: ' . implode( ', ', $robots_tags ), true );
123 }
124
125 header( 'Cache-Control: no-store, no-cache, must-revalidate, max-age=0' );
126 header( 'Cache-Control: post-check=0, pre-check=0', false );
127 header( 'Expires: Mon, 26 Jul 1997 05:00:00 GMT' );
128 header( 'Cache-Control: no-cache' );
129 header( 'Pragma: no-cache' );
130 header( 'X-Redirect-Powered-By: https://www.betterlinks.io/' );
131
132 // phpcs:disable WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- BetterLinks redirects to the user-configured external target URL by design; wp_safe_redirect would block off-site URLs and break the plugin's core feature.
133 switch ( $data['redirect_type'] ) {
134 case '301':
135 wp_redirect( esc_url_raw( $target_url ), 301 );
136 exit;
137 case '302':
138 wp_redirect( esc_url_raw( $target_url ), 302 );
139 exit;
140 case '307':
141 wp_redirect( esc_url_raw( $target_url ), 307 );
142 exit;
143 case 'cloak':
144 do_action( 'betterlinks/make_cloaked_redirect', $target_url, $data );
145 exit;
146 default:
147 wp_redirect( esc_url_raw( $target_url ) );
148 exit;
149 }
150 // phpcs:enable WordPress.Security.SafeRedirect.wp_redirect_wp_redirect
151 }
152
153 public function start_trakcing( $data ) {
154 global $betterlinks;
155 $is_disable_analytics_ip = isset( $betterlinks['is_disable_analytics_ip'] ) ? $betterlinks['is_disable_analytics_ip'] : false;
156 do_action( 'betterlinks/link/before_start_tracking', $data );
157 $now = current_time( 'mysql' );
158 $now_gmt = current_time( 'mysql', 1 );
159 $visitor_cookie = 'betterlinks_visitor';
160 // A visitor seen before sends the cookie back; anyone else is new and gets
161 // one issued now. PHP does not add a cookie set during this request to
162 // $_COOKIE, so keep the generated id in $visitor_id — reading the cookie
163 // back here would store an empty visitor_id for every first-time visitor
164 // and make them invisible to the new-vs-returning report.
165 $is_new_visitor = ! isset( $_COOKIE[ $visitor_cookie ] );
166 if ( $is_new_visitor ) {
167 $visitor_cookie_expire_time = time() + 60 * 60 * 24 * 365; // 1 year
168 $visitor_id = uniqid( 'bl' );
169 setcookie( $visitor_cookie, $visitor_id, $visitor_cookie_expire_time, '/' );
170 } else {
171 $visitor_id = sanitize_text_field( wp_unslash( $_COOKIE[ $visitor_cookie ] ) );
172 }
173 // checking if split tes enabled.
174 $is_split_enabled = apply_filters( 'betterlinkspro/admin/split_test_tracking', false, $data );
175
176 $click_data = array(
177 'link_id' => $data['ID'],
178 'browser' => isset( $data['browser'] ) ? $data['browser'] : '',
179 'os' => isset( $data['os'] ) ? $data['os'] : '',
180 'device' => isset( $data['device'] ) ? $data['device'] : '',
181 'referer' => isset( $_SERVER['HTTP_REFERER'] ) ? $_SERVER['HTTP_REFERER'] : '', // phpcs:ignore
182 'uri' => $data['link_slug'],
183 'click_count' => 0,
184 'visitor_id' => $visitor_id,
185 // 1 = visitor's first click, 2 = a later one. Deliberately not 0:
186 // every click written before this existed has 0, and those rows carry
187 // a visitor_id too, so 0 has to keep meaning "unknown" or the report
188 // would count all of that history as returning visitors.
189 'click_order' => $is_new_visitor ? 1 : 2,
190 'created_at' => $now,
191 'created_at_gmt' => $now_gmt,
192 'rotation_target_url' => $data['target_url'],
193 'target_url' => $data['target_url'],
194 'is_split_enabled' => $is_split_enabled,
195 );
196 if ( ! $is_disable_analytics_ip ) {
197 $IP = $this->get_current_client_IP();
198 $click_data['ip'] = $IP;
199 $click_data['host'] = $IP;
200
201 // Only process country data if BetterLinks Pro v2.5.0 or newer is installed
202 $is_pro_version_valid = defined( 'BETTERLINKS_PRO_VERSION' ) && version_compare( BETTERLINKS_PRO_VERSION, '2.5.0', '>=' );
203
204 if ( $is_pro_version_valid ) {
205 // Check if country data was provided from frontend geolocation
206 $has_frontend_country = isset( $data['country_code'] ) && isset( $data['country_name'] );
207
208 if ( $has_frontend_country ) {
209 // Use country data from frontend geolocation
210 $click_data['country_code'] = $data['country_code'];
211 $click_data['country_name'] = $data['country_name'];
212 } else {
213 // Fallback to server-side detection if frontend didn't provide country data
214 if ( class_exists( '\BetterLinks\Services\CountryDetectionService' ) ) {
215 $country_data = \BetterLinks\Services\CountryDetectionService::get_country_by_ip( $IP );
216 if ( $country_data ) {
217 $click_data['country_code'] = $country_data['country_code'];
218 $click_data['country_name'] = $country_data['country_name'];
219 }
220 }
221 }
222 }
223 }
224
225 if ( apply_filters( 'betterlinks/is_extra_data_tracking_compatible', false ) ) {
226 $query_params = apply_filters( 'betterlinkspro/admin/parameter_tracking_values', array(), $data );
227
228 $click_data['brand_name'] = isset( $data['brand_name'] ) ? $data['brand_name'] : '';
229 $click_data['model'] = isset( $data['model'] ) ? $data['model'] : '';
230 $click_data['bot_name'] = isset( $data['bot_name'] ) ? $data['bot_name'] : '';
231 $click_data['browser_type'] = isset( $data['browser_type'] ) ? $data['browser_type'] : '';
232 $click_data['browser_version'] = isset( $data['browser_version'] ) ? $data['browser_version'] : '';
233 $click_data['os_version'] = isset( $data['os_version'] ) ? $data['os_version'] : '';
234 $click_data['language'] = isset( $data['language'] ) ? $data['language'] : '';
235 $click_data['query_params'] = wp_json_encode( $query_params );
236 }
237
238 // Add user agent if tracking is enabled
239 $settings = get_option( BETTERLINKS_LINKS_OPTION_NAME, '[]' );
240 if ( is_string( $settings ) ) {
241 $settings = json_decode( $settings, true );
242 }
243 if ( ! empty( $settings['enable_user_agent_tracking'] ) && isset( $_SERVER['HTTP_USER_AGENT'] ) ) {
244 $click_data['user_agent'] = sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) );
245 }
246 $arg = apply_filters( 'betterlinks/link/insert_click_arg', $click_data );
247
248 if ( BETTERLINKS_EXISTS_CLICKS_JSON ) {
249 $this->insert_json_into_file( BETTERLINKS_UPLOAD_DIR_PATH . '/clicks.json', $arg );
250 } else {
251 try {
252 $click_id = Helper::insert_click( $arg );
253 if ( ! empty( $click_id ) && $is_split_enabled ) {
254 do_action( 'betterlinks/link/after_insert_click', $arg['link_id'], $click_id, $arg['target_url'] );
255 }
256 } catch ( \Throwable $th ) {
257 echo esc_html( $th->getMessage() );
258 }
259 }
260 }
261
262 /**
263 * Resolve the visitor IP recorded against a click.
264 *
265 * This used to walk HTTP_CLIENT_IP / X-Forwarded / X-Forwarded-For /
266 * Forwarded ahead of REMOTE_ADDR and take whichever was set. Every one of
267 * those is a request header, so on a site that is not actually behind a
268 * reverse proxy any visitor could name their own IP on the public redirect
269 * path — the busiest unauthenticated entry point the plugin has. That let a
270 * caller inflate COUNT(DISTINCT ip) unique-click figures at will, walk
271 * straight past the `excluded_ips` analytics filter, and hand a fresh value
272 * to the per-IP country lookup on every single hit.
273 *
274 * The same walk was already replaced in
275 * CountryDetectionService::get_current_client_ip() and in
276 * BetterLinksPro\Helper::get_current_client_ip(); this path was missed.
277 * Delegate to the same resolver so all three agree: REMOTE_ADDR by default,
278 * a forwarding header only when the peer is inside an operator-configured
279 * trusted-proxy range.
280 *
281 * The service returns null for private/reserved space because it will not
282 * geolocate it. Click tracking still wants that value — a LAN visitor is a
283 * real visitor — so fall back to REMOTE_ADDR rather than storing nothing.
284 *
285 * @return string Client IP, or '' when none can be established.
286 */
287 public function get_current_client_IP() {
288 if ( class_exists( '\\BetterLinks\\Services\\CountryDetectionService' ) ) {
289 $resolved = \BetterLinks\Services\CountryDetectionService::get_current_client_ip();
290
291 if ( ! empty( $resolved ) ) {
292 return $resolved;
293 }
294 }
295
296 $address = isset( $_SERVER['REMOTE_ADDR'] )
297 ? trim( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) )
298 : '';
299
300 // Never store something that is not an address: the column is read back
301 // as an identity for unique-visitor counting and IP exclusion.
302 return filter_var( $address, FILTER_VALIDATE_IP ) ? $address : '';
303 }
304 public function addScheme( $url, $scheme = 'http://' ) {
305 if ( strpos( $url, '/' ) === 0 ) {
306 return $url = site_url( '/' ) . $url;
307 }
308 return apply_filters( 'betterlinks/link/target_url', wp_parse_url( $url, PHP_URL_SCHEME ) === null ? $scheme . $url : $url );
309 }
310
311 protected function insert_json_into_file( $file, $data ) {
312 $existing_data = file_get_contents( $file );
313 $temp_array = (array) json_decode( $existing_data, true );
314 array_push( $temp_array, $data );
315 return file_put_contents( $file, wp_json_encode( $temp_array ) );
316 }
317
318
319 /**
320 * Create a Quick Link.
321 *
322 * @param string $title Link title.
323 * @param string $target_url Destination.
324 * @param array $settings BetterLinks settings.
325 * @param bool $render When true (default) render the confirmation page
326 * and exit, preserving the legacy front-end
327 * behaviour. When false, return the created row so
328 * the REST endpoint can answer with JSON.
329 * @return array|false|void
330 */
331 public function create_new_link( $title, $target_url, $settings, $render = true ) {
332 $date = wp_date( 'Y-m-d H:i:s' );
333 $helper = new Helper();
334 $slug = $helper->generate_random_slug();
335 $prefix = ! empty( $settings['prefix'] ) ? $settings['prefix'] . '/' : '';
336 $nofollow = ! empty( $settings['nofollow'] ) ? $settings['nofollow'] : null;
337 $sponsored = ! empty( $settings['sponsored'] ) ? $settings['sponsored'] : null;
338 $track_me = ! empty( $settings['track_me'] ) ? $settings['track_me'] : null;
339 $param_forwarding = ! empty( $settings['param_forwarding'] ) ? $settings['param_forwarding'] : null;
340 $powered_by = ! empty( $settings['cle']['powered_by'] ) ? sanitize_text_field( $settings['cle']['powered_by'] ) : '';
341 $short_url = $prefix . $slug;
342
343 $initial_values = array(
344 'link_title' => $title,
345 'link_slug' => $slug,
346 'target_url' => $target_url,
347 'short_url' => $short_url,
348 'redirect_type' => '307',
349 'nofollow' => $nofollow,
350 'sponsored' => $sponsored,
351 'track_me' => $track_me,
352 'param_forwarding' => $param_forwarding,
353 'link_date' => $date,
354 'link_date_gmt' => $date,
355 'link_modified' => $date,
356 'link_modified_gmt' => $date,
357 'cat_id' => 1,
358 'powered_by' => $powered_by,
359 );
360 $initial_values = apply_filters( 'betterlinks_before_cle', $initial_values, $settings );
361
362 $helper->clear_query_cache();
363 $args = $this->sanitize_links_data( $initial_values );
364 $results = $this->insert_link( $args );
365
366 if ( ! $render ) {
367 if ( empty( $results ) ) {
368 return false;
369 }
370
371 $created_short_url = ! empty( $results['short_url'] ) ? $results['short_url'] : $short_url;
372
373 return array(
374 'id' => isset( $results['ID'] ) ? (int) $results['ID'] : 0,
375 'short_url' => $created_short_url,
376 'permalink' => site_url( $created_short_url ),
377 'results' => $results,
378 );
379 }
380
381 if ( ! empty( $results ) ) {
382 require_once BETTERLINKS_ROOT_DIR_PATH . '/includes/Views/create-link-externally.php';
383 exit;
384 }
385 wp_safe_redirect( home_url() );
386 exit;
387 }
388
389 public static function prevent_unwanted_cle() {
390 $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
391
392 $params = strpos($request_uri, 'action%3Dbtl_cle%26api_key');
393 if ( !empty( $params ) ) { // to prevent short link creation of the 'Here is your BetterLinks' page
394 $prevent_unwanted_click = true; // phpcs:ignore
395 require_once BETTERLINKS_ROOT_DIR_PATH . '/includes/Views/create-link-externally.php';
396 exit;
397 }
398 }
399 }
400