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 / Helper.php

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

1,483 lines 53.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace BetterLinks;
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 use BetterLinks\Admin\Cache;
6 use WP_Http;
7 use DeviceDetector\DeviceDetector;
8 use DeviceDetector\Parser\OperatingSystem;
9 use DeviceDetector\Parser\Device\AbstractDeviceParser;
10 use DeviceDetector\Parser\Client\Browser;
11
12 class Helper {
13
14 use Traits\Query;
15 use Traits\Clicks;
16
17 // phpcs:disable PluginCheck.Security.DirectDB, WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL
18
19 public static function btl_menu_notice() {
20 return BETTERLINKS_MENU_NOTICE !== get_option( 'betterlinks_menu_notice', 0 );
21 }
22
23 /**
24 * Whether the clicks table has a `bot_name` column.
25 *
26 * Part of the current schema and added by the migration, but an install that
27 * never ran the migration would fail the INSERT and silently lose the click,
28 * so the write is guarded by this. Lives on Helper rather than in a trait
29 * because both callers need it and they compose different traits: the insert
30 * path (Traits\Query, also used by LinkChecker) and the audience report
31 * (Traits\Clicks, also used by the REST controller).
32 *
33 * Cached like the user-agent check so the redirect path never hits
34 * information_schema.
35 *
36 * @return bool
37 */
38 public static function has_bot_name_column() {
39 global $wpdb;
40
41 $transient_key = 'betterlinks_bot_name_column_exists';
42 $column_exists = get_transient( $transient_key );
43
44 if ( $column_exists === false ) {
45 $column_exists = $wpdb->get_var(
46 $wpdb->prepare(
47 'SELECT `column_name` FROM information_schema.columns WHERE table_schema=%s AND table_name=%s AND column_name="bot_name"',
48 DB_NAME,
49 $wpdb->prefix . 'betterlinks_clicks'
50 )
51 );
52
53 // Normalise before the comparison below: the lookup returns the column
54 // NAME, so returning `$column_exists === 'yes'` directly would report
55 // false on the very first call after the cache expires and only start
56 // working once the cached value is read back.
57 $column_exists = $column_exists ? 'yes' : 'no';
58 set_transient( $transient_key, $column_exists, HOUR_IN_SECONDS );
59 }
60
61 return $column_exists === 'yes';
62 }
63 public static function get_links() {
64 if ( BETTERLINKS_EXISTS_LINKS_JSON ) {
65 $data = json_decode( file_get_contents( BETTERLINKS_UPLOAD_DIR_PATH . '/links.json' ), true );
66 if ( empty( $data ) ) {
67 $cron = new Cron();
68 $cron->write_json_links();
69 return json_decode( file_get_contents( BETTERLINKS_UPLOAD_DIR_PATH . '/links.json' ), true );
70 }
71 return $data;
72 }
73 $options = json_decode( get_option( BETTERLINKS_LINKS_OPTION_NAME ), true );
74 return is_array( $options )
75 ? array(
76 'wildcards_is_active' => isset( $options['wildcards'] ) ? $options['wildcards'] : false,
77 'disablebotclicks' => isset( $options['disablebotclicks'] ) ? $options['disablebotclicks'] : false,
78 'force_https' => isset( $options['force_https'] ) ? $options['force_https'] : false,
79 'autolink_disable_post_types' => isset( $options['autolink_disable_post_types'] ) ? $options['autolink_disable_post_types'] : array(),
80 'is_autolink_icon' => isset( $options['is_autolink_icon'] ) ? $options['is_autolink_icon'] : false,
81 'is_autolink_headings' => isset( $options['is_autolink_headings'] ) ? $options['is_autolink_headings'] : false,
82 'uncloaked_categories' => isset( $options['uncloaked_categories'] ) ? $options['uncloaked_categories'] : array(),
83 'is_disable_analytics_ip' => isset( $options['is_disable_analytics_ip'] ) ? $options['is_disable_analytics_ip'] : false,
84 'excluded_ips' => isset( $options['excluded_ips'] ) ? $options['excluded_ips'] : array(),
85 )
86 : array(
87 'wildcards_is_active' => false,
88 'disablebotclicks' => false,
89 'force_https' => false,
90 'excluded_ips' => array(),
91 );
92 }
93
94 public static function get_link_from_json_file( $short_url ) {
95 if ( empty( $short_url ) ) {
96 return;
97 }
98 global $betterlinks;
99 if ( ! ( isset( $betterlinks['is_case_sensitive'] ) && $betterlinks['is_case_sensitive'] ) ) {
100 $short_url = strtolower( $short_url );
101 }
102 if ( isset( $betterlinks['links'][ $short_url ] ) ) {
103 return $betterlinks['links'][ $short_url ];
104 }
105 if ( isset( $betterlinks['wildcards_is_active'] ) && $betterlinks['wildcards_is_active'] ) {
106 if ( isset( $betterlinks['wildcards'] ) && count( $betterlinks['wildcards'] ) > 0 ) {
107 foreach ( $betterlinks['wildcards'] as $key => $item ) {
108 $postion = strpos( $key, '/*' );
109 if ( false !== $postion ) {
110 if ( substr( $key, 0, $postion ) === substr( $short_url, 0, $postion ) ) {
111 $target_postion = strpos( $item['target_url'], '/*' );
112 if ( false !== $target_postion ) {
113 $target_url = str_replace( '/*', substr( $short_url, $postion ), $item['target_url'] );
114 $item['target_url'] = $target_url;
115 return $item;
116 }
117 return $item;
118 }
119 }
120 }
121 }
122 }
123 }
124
125 /**
126 * Whether the Promo Cards screen should be reachable.
127 *
128 * Free users always get it — the page is an upgrade teaser, so hiding it
129 * would defeat the point. Only Pro users can switch it off, via the
130 * "Feature Modules" settings card. The key is missing on installs that
131 * predate it, which counts as enabled.
132 *
133 * @return bool
134 */
135 public static function is_promo_cards_enabled() {
136 if ( ! apply_filters( 'betterlinks/pro_enabled', false ) ) {
137 return true;
138 }
139
140 $settings = Cache::get_json_settings();
141
142 return ! isset( $settings['enable_promo_cards'] ) || ! empty( $settings['enable_promo_cards'] );
143 }
144
145 /**
146 * Whether the "Bio Links" submenu is enabled.
147 *
148 * Pro-only opt-out, same contract as is_promo_cards_enabled(): in free the
149 * screen is the upgrade teaser, so it always stays reachable and only Pro
150 * can hide it from the "Feature Modules" settings card. The key is missing
151 * on installs that predate it, which counts as enabled.
152 *
153 * @return bool
154 */
155 public static function is_bio_links_enabled() {
156 if ( ! apply_filters( 'betterlinks/pro_enabled', false ) ) {
157 return true;
158 }
159
160 $settings = Cache::get_json_settings();
161
162 return ! isset( $settings['enable_bio_links'] ) || ! empty( $settings['enable_bio_links'] );
163 }
164
165 public static function get_menu_items() {
166 // $enable_custom_domain_menu = get_option(BETTERLINKS_CUSTOM_DOMAIN_MENU, 0);
167 $enable_custom_domain_menu = Cache::get_json_settings();
168 $enable_custom_domain_menu = !empty( $enable_custom_domain_menu['enable_custom_domain_menu'] ) ? $enable_custom_domain_menu['enable_custom_domain_menu'] : false;
169
170 // Built in display order — do NOT go back to splicing conditional entries
171 // into an existing array. That is what previously put Bio Links and Promo
172 // Cards above Tags & Categories: every insert used index 1, so each new
173 // one landed directly after "Manage Links" and they stacked in reverse.
174 $menu_items = array(
175 BETTERLINKS_PLUGIN_SLUG => array(
176 'title' => __( 'Manage Links', 'betterlinks' ),
177 'capability' => 'manage_options',
178 ),
179 BETTERLINKS_PLUGIN_SLUG . '-manage-tags-and-categories' => array(
180 'title' => __( 'Tags & Categories', 'betterlinks' ),
181 'capability' => 'manage_options',
182 ),
183 );
184
185 if ( ! empty( $enable_custom_domain_menu ) ) {
186 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-custom-domain' ] = array(
187 'title' => __( 'Custom Domain', 'betterlinks' ),
188 'capability' => 'manage_options',
189 );
190 }
191
192 // Free users reach the teaser through this same slug, so the submenu has
193 // to exist here too — otherwise WordPress rejects the page load before
194 // the React router ever sees it.
195 if ( self::is_promo_cards_enabled() ) {
196 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-promo-cards' ] = array(
197 'title' => __( 'Promo Cards', 'betterlinks' ),
198 'capability' => 'manage_options',
199 );
200 }
201
202 // Same reasoning as Promo Cards above — free users reach the Bio Links
203 // teaser through this slug, so it must be registered here as well.
204 if ( self::is_bio_links_enabled() ) {
205 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-bio-links' ] = array(
206 'title' => __( 'Bio Links', 'betterlinks' ),
207 'capability' => 'manage_options',
208 );
209 }
210
211 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-analytics' ] = array(
212 'title' => __( 'Analytics', 'betterlinks' ),
213 'capability' => 'manage_options',
214 );
215 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-link-scanner' ] = array(
216 'title' => __( 'Link Scanner', 'betterlinks' ),
217 'capability' => 'manage_options',
218 );
219 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-mcp' ] = array(
220 'title' => __( 'MCP', 'betterlinks' ),
221 'capability' => 'manage_options',
222 );
223 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-settings' ] = array(
224 'title' => __( 'Settings', 'betterlinks' ),
225 'capability' => 'manage_options',
226 );
227
228 if ( get_option( 'betterlinks_quick_setup_step' ) !== 'complete' ) {
229 $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-quick-setup' ] = array(
230 'title' => __( 'Quick Setup', 'betterlinks' ),
231 'capability' => 'manage_options',
232 );
233 }
234
235 $menu_items = apply_filters( 'betterlinks/helper/menu_items', $menu_items );
236
237 // Pro registers the same slug through the filter above, so the opt-out
238 // has to be re-applied afterwards to actually take effect.
239 if ( ! self::is_promo_cards_enabled() ) {
240 unset( $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-promo-cards' ] );
241 }
242 if ( ! self::is_bio_links_enabled() ) {
243 unset( $menu_items[ BETTERLINKS_PLUGIN_SLUG . '-bio-links' ] );
244 }
245
246 return $menu_items;
247 }
248
249 /**
250 * Check Supported Post type for admin page and plugin main settings page
251 *
252 * @return bool
253 */
254 public static function plugin_page_hook_suffix( $hook ) {
255 if ( 'toplevel_page_' . BETTERLINKS_PLUGIN_SLUG === $hook ) {
256 return true;
257 } else {
258 foreach ( self::get_menu_items() as $key => $value ) {
259 if ( BETTERLINKS_PLUGIN_SLUG . '_page_' . $key === $hook || strpos( $hook, BETTERLINKS_PLUGIN_SLUG . '_page_' . $key ) || strpos( '_' . $hook, 'betterlinks' ) ) {
260 return true;
261 }
262 }
263 }
264 return false;
265 }
266
267 public static function make_slug( $str ) {
268 if ( empty( $str ) ) {
269 return;
270 }
271 if ( $str !== mb_convert_encoding( mb_convert_encoding( $str, 'UTF-32', 'UTF-8' ), 'UTF-8', 'UTF-32' ) ) {
272 $str = mb_convert_encoding( $str, 'UTF-8', mb_detect_encoding( $str ) );
273 }
274 $str = htmlentities( $str, ENT_NOQUOTES, 'UTF-8' );
275 $str = preg_replace( '`&([a-z]{1,2})(acute|uml|circ|grave|ring|cedil|slash|tilde|caron|lig);`i', '\\1', $str );
276 $str = html_entity_decode( $str, ENT_NOQUOTES, 'UTF-8' );
277 $str = preg_replace( array( '`[^a-z0-9]`i', '`[-]+`' ), '-', $str );
278 $str = strtolower( trim( $str, '-' ) );
279 $str = substr( $str, 0, 100 );
280 return $str;
281 }
282
283 public static function link_exists( $title, $slug = '' ) {
284 global $wpdb;
285
286 $link_title = wp_unslash( sanitize_post_field( 'link_title', $title, 0, 'db' ) );
287 $short_url = wp_unslash( sanitize_post_field( 'short_url', $slug, 0, 'db' ) );
288 $betterlinks = $wpdb->prefix . 'betterlinks';
289 $query = "SELECT link_title, short_url FROM $betterlinks WHERE ";
290 $args = array();
291
292 if ( ! empty( $title ) ) {
293 $query .= ' link_title = %s';
294 $args[] = $link_title;
295 }
296
297 if ( ! empty( $slug ) ) {
298 $query .= ' AND short_url = %s';
299 $args[] = $short_url;
300 }
301
302 if ( ! empty( $args ) ) {
303 $results = $wpdb->get_var( $wpdb->prepare( $query, $args ) );
304 if ( ! empty( $results ) ) {
305 return true;
306 }
307 return;
308 }
309 }
310 public static function term_exists( $slug ) {
311 global $wpdb;
312
313 $term_slug = wp_unslash( sanitize_post_field( 'term_slug', $slug, 0, 'db' ) );
314 $betterlinks = $wpdb->prefix . 'betterlinks_terms';
315 $query = "SELECT term_slug FROM $betterlinks WHERE ";
316 $args = array();
317
318 if ( ! empty( $slug ) ) {
319 $query .= ' term_slug = %s';
320 $args[] = $term_slug;
321 }
322
323 if ( ! empty( $args ) ) {
324 $results = $wpdb->get_var( $wpdb->prepare( $query, $args ) );
325 if ( ! empty( $results ) ) {
326 return true;
327 }
328 return;
329 }
330 }
331 public static function click_exists( $ID ) {
332 global $wpdb;
333 $click_ID = wp_unslash( sanitize_post_field( 'ID', $ID, 0, 'db' ) );
334 $betterlinks = $wpdb->prefix . 'betterlinks_clicks';
335 $query = "SELECT ID FROM $betterlinks WHERE ";
336 $args = array();
337
338 if ( ! empty( $click_ID ) ) {
339 $query .= ' ID = %d';
340 $args[] = $click_ID;
341 }
342
343 if ( ! empty( $args ) ) {
344 $results = $wpdb->get_var( $wpdb->prepare( $query, $args ) );
345 if ( ! empty( $results ) ) {
346 return true;
347 }
348 return;
349 }
350 }
351
352 public static function create_cron_jobs_for_json_links() {
353 wp_clear_scheduled_hook( 'betterlinks/write_json_links' );
354 wp_schedule_single_event( time() + 5, 'betterlinks/write_json_links' );
355 }
356
357 public static function write_links_inside_json() {
358 $cron = new Cron();
359 $cron->write_json_links();
360 }
361
362 public static function create_cron_jobs_for_analytics() {
363 wp_clear_scheduled_hook( 'betterlinks/analytics' );
364 wp_schedule_single_event( time() + 5, 'betterlinks/analytics' );
365 }
366
367 public static function clear_query_cache() {
368 delete_transient( BETTERLINKS_CACHE_LINKS_NAME );
369 }
370
371 public static function create_links_cache() {
372 $results = self::get_prepare_all_links();
373 set_transient( BETTERLINKS_CACHE_LINKS_NAME, json_encode( $results ) );
374 }
375
376 public static function parse_link_response( $items, $analytic, $broken_links ) {
377 $results = array();
378 $broken_link_status_codes = array( 401, 403, 404 );
379
380 $tags_list = array();
381
382 foreach ( $items as $item ) {
383 if ( null !== $item->ID && 'tags' === $item->term_type ) {
384 array_push( $tags_list, $item );
385 }
386 }
387
388 foreach ( $items as $item ) {
389 if ( 'category' === $item->term_type ) {
390 if ( null === $item->ID ) {
391 continue;
392 }
393 // insert analytic data.
394 if ( isset( $analytic[ $item->ID ] ) ) {
395 $item->analytic = $analytic[ $item->ID ];
396 }
397 if ( ! empty( $item->param_struct ) ) {
398 $item->param_struct = unserialize( $item->param_struct, array( 'allowed_classes' => false ) );
399 }
400 if ( class_exists( '\BetterLinksPro' ) ) {
401 $custom_tracking_scripts = self::get_link_meta( $item->ID, 'btl_custom_tracking_scripts' );
402 if ( ! empty( $custom_tracking_scripts ) ) {
403 $custom_tracking_scripts = unserialize( $custom_tracking_scripts, array( 'allowed_classes' => false ) );
404 $item->enable_custom_scripts = isset( $custom_tracking_scripts['enable'] ) ? $custom_tracking_scripts['enable'] : false;
405 $item->custom_tracking_scripts = isset( $custom_tracking_scripts['script'] ) ? $custom_tracking_scripts['script'] : '';
406 }
407 }
408
409 if ( isset( $broken_links[ $item->ID ] ) && is_array( $broken_links[ $item->ID ] ) && isset( $broken_links[ $item->ID ]['status']['status_code'] ) && in_array( $broken_links[ $item->ID ]['status']['status_code'], $broken_link_status_codes ) && empty( $broken_links[ $item->ID ]['is_log_removed'] ) ) {
410 $item->link_status = 'broken';
411 } elseif ( 'broken' === $item->link_status && isset( $broken_links[ $item->ID ] ) && is_array( $broken_links[ $item->ID ] ) && isset( $broken_links[ $item->ID ]['old_link_status'] ) && 'broken' !== $broken_links[ $item->ID ]['old_link_status'] ) {
412 // if the link is fixed, but if db is not updated it to fixed link immediately then it will be marked as old status code.
413 $item->link_status = $broken_links[ $item->ID ]['old_link_status'];
414 }
415 $item->tags_data = array();
416 $item->tags_id = array(); // Initialize tags_id array for form submission
417
418 foreach ( $tags_list as $tag ) {
419 if ( $tag->ID === $item->ID ) {
420 array_push(
421 $item->tags_data,
422 array(
423 'term_id' => $tag->cat_id,
424 'term_name' => $tag->term_name,
425 'term_slug' => $tag->term_slug,
426 )
427 );
428 // Also add to tags_id array for form submission
429 array_push( $item->tags_id, $tag->cat_id );
430 }
431 }
432
433 // formatting response.
434 if ( ! isset( $results[ $item->cat_id ] ) ) {
435 $results[ $item->cat_id ] = array(
436 'term_name' => $item->term_name,
437 'term_slug' => $item->term_slug,
438 'term_type' => $item->term_type,
439 );
440 if ( null !== $item->ID ) {
441 $results[ $item->cat_id ]['lists'][] = $item;
442 } else {
443 $results[ $item->cat_id ]['lists'] = array();
444 }
445 } else {
446 $results[ $item->cat_id ]['lists'][] = $item;
447 }
448 }
449 }
450 return $results;
451 }
452 public static function json_link_formatter( $data ) {
453 $res = array(
454 'ID' => $data['ID'] ?? null,
455 'link_slug' => $data['link_slug'] ?? '',
456 'link_status' => ( isset( $data['link_status'] ) ? $data['link_status'] : 'publish' ),
457 'short_url' => $data['short_url'] ?? '',
458 'redirect_type' => ( isset( $data['redirect_type'] ) ? $data['redirect_type'] : '307' ),
459 'target_url' => $data['target_url'] ?? '',
460 'nofollow' => ( isset( $data['nofollow'] ) ? $data['nofollow'] : false ),
461 'sponsored' => ( isset( $data['sponsored'] ) ? $data['sponsored'] : false ),
462 'param_forwarding' => ( isset( $data['param_forwarding'] ) ? $data['param_forwarding'] : false ),
463 'track_me' => ( isset( $data['track_me'] ) ? $data['track_me'] : false ),
464 'wildcards' => ( isset( $data['wildcards'] ) ? $data['wildcards'] : false ),
465 'expire' => ( isset( $data['expire'] ) ? $data['expire'] : null ),
466 'dynamic_redirect' => ( isset( $data['dynamic_redirect'] ) ? $data['dynamic_redirect'] : null ),
467 'cat_id' => isset( $data['cat_id'] ) ? $data['cat_id'] : null,
468 );
469 if ( isset( $data['uncloaked'] ) && $data['uncloaked'] ) {
470 $res['uncloaked'] = $data['uncloaked'];
471 }
472 return $res;
473 }
474 public static function insert_json_into_file( $file, $data ) {
475 $existingData = file_get_contents( $file );
476 $existingData = json_decode( $existingData, true );
477 $case_sensitive_is_enabled = isset( $existingData['is_case_sensitive'] ) ? $existingData['is_case_sensitive'] : false;
478 $short_url = $case_sensitive_is_enabled ? $data['short_url'] : strtolower( $data['short_url'] );
479 if ( isset( $data['wildcards'] ) && $data['wildcards'] ) {
480 $tempArray = $existingData['wildcards'];
481 // Remove any existing entry with the same ID to prevent duplicates
482 if ( isset( $data['ID'] ) ) {
483 foreach ( $tempArray as $key => $entry ) {
484 if ( isset( $entry['ID'] ) && $entry['ID'] == $data['ID'] ) {
485 unset( $tempArray[ $key ] );
486 break;
487 }
488 }
489 }
490 $tempArray[ $short_url ] = self::json_link_formatter( $data );
491 $existingData['wildcards'] = $tempArray;
492 } else {
493 $tempArray = ( isset( $existingData['links'] ) ? $existingData['links'] : array() );
494 // Remove any existing entry with the same ID to prevent duplicates
495 if ( isset( $data['ID'] ) ) {
496 foreach ( $tempArray as $key => $entry ) {
497 if ( isset( $entry['ID'] ) && $entry['ID'] == $data['ID'] ) {
498 unset( $tempArray[ $key ] );
499 break;
500 }
501 }
502 }
503 $tempArray[ $short_url ] = self::json_link_formatter( $data );
504 $existingData['links'] = $tempArray;
505 }
506 return file_put_contents( $file, wp_json_encode( $existingData ) );
507 }
508 public static function update_json_into_file( $file, $data, $old_short_url = '' ) {
509 if ( ! isset( $data['short_url'] ) ) {
510 return false;
511 }
512 $existingData = file_get_contents( $file );
513 $existingData = json_decode( $existingData, true );
514 // make sure we always have an array to work with
515 if ( ! is_array( $existingData ) ) {
516 $existingData = array();
517 }
518 $case_sensitive_is_enabled = isset( $existingData['is_case_sensitive'] ) ? $existingData['is_case_sensitive'] : false;
519 $short_url = $case_sensitive_is_enabled ? $data['short_url'] : strtolower( $data['short_url'] );
520
521 if ( isset( $data['wildcards'] ) && ! empty( $data['wildcards'] ) ) {
522 $tempArray = isset( $existingData['wildcards'] ) && is_array( $existingData['wildcards'] ) ? $existingData['wildcards'] : array();
523 if ( is_array( $tempArray ) ) {
524 $found_old_entry = false;
525
526 // First, try to find and remove by old_short_url if provided
527 if ( ! empty( $old_short_url ) ) {
528 $old_short_url_lower = strtolower( $old_short_url );
529 if ( isset( $tempArray[ $old_short_url ] ) ) {
530 unset( $tempArray[ $old_short_url ] );
531 $found_old_entry = true;
532 } elseif ( isset( $tempArray[ $old_short_url_lower ] ) ) {
533 unset( $tempArray[ $old_short_url_lower ] );
534 $found_old_entry = true;
535 }
536 }
537
538 // If old entry not found by short_url, search by ID to remove all duplicates
539 if ( ! $found_old_entry && isset( $data['ID'] ) ) {
540 foreach ( $tempArray as $key => $entry ) {
541 if ( isset( $entry['ID'] ) && $entry['ID'] == $data['ID'] ) {
542 unset( $tempArray[ $key ] );
543 }
544 }
545 }
546
547 $tempArray[ $short_url ] = self::json_link_formatter( $data );
548 $existingData['wildcards'] = $tempArray;
549 return file_put_contents( $file, wp_json_encode( $existingData ) );
550 }
551 } else {
552 $tempArray = isset( $existingData['links'] ) && is_array( $existingData['links'] ) ? $existingData['links'] : array();
553 $previous_data = array();
554 if ( is_array( $tempArray ) ) {
555 $found_old_entry = false;
556
557 // First, try to find and remove by old_short_url if provided
558 if ( ! empty( $old_short_url ) ) {
559 $old_short_url_lower = strtolower( $old_short_url );
560 if ( isset( $tempArray[ $old_short_url ] ) ) {
561 $previous_data = $tempArray[ $old_short_url ];
562 unset( $tempArray[ $old_short_url ] );
563 $found_old_entry = true;
564 } elseif ( isset( $tempArray[ $old_short_url_lower ] ) ) {
565 $previous_data = $tempArray[ $old_short_url_lower ];
566 unset( $tempArray[ $old_short_url_lower ] );
567 $found_old_entry = true;
568 }
569 }
570
571 // If old entry not found by short_url, search by ID to remove all duplicates
572 if ( ! $found_old_entry && isset( $data['ID'] ) ) {
573 foreach ( $tempArray as $key => $entry ) {
574 if ( isset( $entry['ID'] ) && $entry['ID'] == $data['ID'] ) {
575 $previous_data = $entry;
576 unset( $tempArray[ $key ] );
577 }
578 }
579 }
580
581 $data = wp_parse_args( $data, $previous_data );
582 $tempArray[ $short_url ] = self::json_link_formatter( $data );
583 $existingData['links'] = $tempArray;
584 return file_put_contents( $file, wp_json_encode( $existingData ) );
585 }
586 }
587 }
588 public static function delete_json_into_file( $file, $short_url ) {
589 if ( ! is_string( $short_url ) || '' === $short_url ) {
590 return;
591 }
592 $existingData = file_get_contents( $file );
593 $existingData = json_decode( $existingData, true );
594 if ( ! is_array( $existingData ) ) {
595 $existingData = array();
596 }
597 if ( isset( $existingData['wildcards'][ $short_url ] ) || isset( $existingData['wildcards'][ strToLower( $short_url ) ] ) ) {
598 $tempArray = $existingData['wildcards'];
599 if ( is_array( $tempArray ) ) {
600 unset( $tempArray[ $short_url ] );
601 unset( $tempArray[ strToLower( $short_url ) ] );
602 $existingData['wildcards'] = $tempArray;
603 return file_put_contents( $file, wp_json_encode( $existingData ) );
604 }
605 } elseif ( isset( $existingData['links'][ $short_url ] ) || isset( $existingData['links'][ strtolower( $short_url ) ] ) ) {
606 $tempArray = $existingData['links'];
607 if ( is_array( $tempArray ) ) {
608 unset( $tempArray[ $short_url ] );
609 unset( $tempArray[ strtolower( $short_url ) ] );
610 $existingData['links'] = $tempArray;
611 return file_put_contents( $file, wp_json_encode( $existingData ) );
612 }
613 }
614 return;
615 }
616
617 public static function is_exists_short_url( $short_url ) {
618 $resutls = self::get_link_by_short_url( $short_url );
619 if ( count( $resutls ) > 0 ) {
620 return true;
621 }
622 return false;
623 }
624
625 /**
626 * Return a WP_Error when a proposed BetterLinks short_url would shadow a URL
627 * WordPress already serves. Returns null when the path is free.
628 *
629 * BetterLinks' redirect handler runs at `init` priority 0 — before WP
630 * resolves the request — so a short_url that matches a real URL silently
631 * hijacks it. This check surfaces the conflict at write time.
632 *
633 * Sites that need the historical "always accept" behaviour can opt out via
634 * the `betterlinks/skip_wp_url_collision_check` filter.
635 *
636 * @param string $short_url Proposed short_url (prefix included).
637 * @param int $allowed_post_id Post whose own permalink may be shadowed on
638 * purpose — see the exemption below. 0 for none.
639 * @return \WP_Error|null
640 */
641 public static function check_wp_url_collision( $short_url, $allowed_post_id = 0 ) {
642 $short = trim( (string) $short_url, "/ \t\n\r\0\x0B" );
643 if ( '' === $short ) {
644 return null;
645 }
646 /**
647 * Filter — return true to skip the WP URL collision check entirely.
648 * Provided as an escape hatch for existing installs whose data already
649 * contains intentional collisions.
650 *
651 * @param bool $skip Whether to skip the check. Default false.
652 * @param string $short Proposed short URL.
653 */
654 if ( apply_filters( 'betterlinks/skip_wp_url_collision_check', false, $short ) ) {
655 return null;
656 }
657 $allowed_post_id = absint( $allowed_post_id );
658 $conflict = null;
659 foreach ( self::short_url_match_candidates( $short ) as $candidate ) {
660 // Instant Redirect binds a post to its *own* permalink on purpose —
661 // "make this page redirect somewhere else" is the entire feature. The
662 // content being shadowed is the post the author is editing, so this is
663 // a deliberate override, not the silent hijack this check exists to
664 // catch. Only that one post's canonical path is exempted; a slug
665 // aimed at any other page or archive is still rejected.
666 if ( $allowed_post_id > 0 && self::is_canonical_post_path( $allowed_post_id, $candidate ) ) {
667 return null;
668 }
669 $conflict = self::resolve_wp_url_conflict( $candidate );
670 if ( null !== $conflict ) {
671 break;
672 }
673 }
674 if ( null === $conflict ) {
675 return null;
676 }
677 return new \WP_Error(
678 'betterlinks_wp_url_collision',
679 sprintf(
680 /* translators: 1: proposed short URL, 2: what WordPress already serves there, e.g. "page" or "category archive" */
681 __( 'Cannot save short URL "%1$s" because WordPress already serves a %2$s at that path, and the link would make it unreachable. Pick a different slug, or remove the conflicting content.', 'betterlinks' ),
682 $short,
683 $conflict['label']
684 ),
685 array(
686 'status' => 409,
687 'conflict_type' => $conflict['type'],
688 'conflict_label' => $conflict['label'],
689 'conflicting_post_id' => $conflict['post_id'],
690 // Short enough to sit under the slug field in the link form; the
691 // full message above is for toasts and API consumers.
692 'short_message' => sprintf(
693 /* translators: %s: what WordPress already serves there, e.g. "page" or "Category archive" */
694 __( 'A WordPress %s already lives at this path', 'betterlinks' ),
695 $conflict['label']
696 ),
697 )
698 );
699 }
700
701 /**
702 * Every path a stored short_url will actually answer on.
703 *
704 * Unless an install opts into case-sensitive matching, the redirect handler
705 * lowercases the incoming request before looking it up, so a link saved as
706 * "Pricing" captures "/pricing" as well — and checking only the literal
707 * spelling would let that straight past the collision check.
708 *
709 * @param string $short Proposed short_url, already trimmed of slashes.
710 * @return string[]
711 */
712 protected static function short_url_match_candidates( $short ) {
713 $candidates = array( $short );
714 $options = json_decode( (string) get_option( BETTERLINKS_LINKS_OPTION_NAME, '{}' ), true );
715 $sensitive = is_array( $options ) && ! empty( $options['is_case_sensitive'] );
716 if ( ! $sensitive ) {
717 $lowered = strtolower( $short );
718 if ( $lowered !== $short ) {
719 $candidates[] = $lowered;
720 }
721 }
722 return $candidates;
723 }
724
725 /**
726 * Describe whatever WordPress would serve at `$path`, or null when nothing
727 * is there.
728 *
729 * `url_to_postid()` only ever answers for singular content, so it misses the
730 * archives the original report explicitly called out — a category-based
731 * permalink whose base collides with the link prefix, a custom post type
732 * archive, an author or date archive. Those are resolved below by running
733 * the path through the same rewrite rules WordPress itself routes with.
734 *
735 * @param string $path Path relative to the site root, no leading slash.
736 * @return array{type:string,label:string,post_id:int}|null
737 */
738 protected static function resolve_wp_url_conflict( $path ) {
739 $post_id = url_to_postid( trailingslashit( home_url() ) . $path );
740 if ( $post_id > 0 && self::is_canonical_post_path( $post_id, $path ) ) {
741 $post = get_post( $post_id );
742 return array(
743 'type' => 'post',
744 'label' => $post instanceof \WP_Post ? $post->post_type : 'post',
745 'post_id' => $post_id,
746 );
747 }
748
749 global $wp_rewrite;
750 if ( ! $wp_rewrite instanceof \WP_Rewrite ) {
751 return null;
752 }
753 $rules = $wp_rewrite->wp_rewrite_rules();
754 if ( empty( $rules ) ) {
755 // Plain permalinks: every URL but the front page is a query string,
756 // so no path can be shadowed.
757 return null;
758 }
759
760 $path = ltrim( $path, '/' );
761 foreach ( $rules as $match => $query ) {
762 if ( ! preg_match( "#^$match#", $path, $matches ) ) {
763 continue;
764 }
765 $query = preg_replace( '!^.+\?!', '', $query );
766 $query = addslashes( \WP_MatchesMapRegex::apply( $query, $matches ) );
767 $vars = array();
768 parse_str( $query, $vars );
769 $conflict = self::describe_query_var_conflict( $vars );
770 if ( null !== $conflict ) {
771 return $conflict;
772 }
773 // The rule matched but resolves to nothing a visitor can reach (a
774 // verbose page rule for a page that is gone, an empty date archive).
775 // WordPress keeps walking the rule table in that case, so do the same.
776 }
777 return null;
778 }
779
780 /**
781 * Whether `$path` is a post's own permalink rather than a variant of it.
782 *
783 * With `%postname%` permalinks `url_to_postid()` answers for paginated forms
784 * too — `/go/2019/` resolves to the page `/go/` as "page 2019 of it", and
785 * WordPress serves it as a 301 back to `/go/`. Nothing becomes unreachable
786 * if a link takes that path over, so treating it as a collision would only
787 * block slugs that are in practice free (every `<page>/<digits>` under a
788 * link prefix that happens to also be a page).
789 *
790 * @param int $post_id
791 * @param string $path Path relative to the site root, no leading slash.
792 * @return bool
793 */
794 protected static function is_canonical_post_path( $post_id, $path ) {
795 $permalink = get_permalink( $post_id );
796 if ( ! $permalink ) {
797 return false;
798 }
799 $home_path = trim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
800 $post_path = trim( (string) wp_parse_url( $permalink, PHP_URL_PATH ), '/' );
801 if ( '' !== $home_path && 0 === strpos( $post_path, $home_path . '/' ) ) {
802 $post_path = substr( $post_path, strlen( $home_path ) + 1 );
803 }
804 return $post_path === trim( (string) $path, '/' );
805 }
806
807 /**
808 * Turn a set of resolved query vars into a conflict description, but only
809 * when the thing they point at genuinely exists. A rule that matches and
810 * then 404s is not a collision, and rejecting it would block slugs that are
811 * in fact free.
812 *
813 * Singular content (`name`, `pagename`, `p`) is deliberately ignored here:
814 * `resolve_wp_url_conflict()` has already asked `url_to_postid()` about it.
815 *
816 * @param array $vars Query vars produced by a matched rewrite rule.
817 * @return array{type:string,label:string,post_id:int}|null
818 */
819 protected static function describe_query_var_conflict( $vars ) {
820 $found = static function ( $type, $label ) {
821 return array(
822 'type' => $type,
823 'label' => $label,
824 'post_id' => 0,
825 );
826 };
827
828 // Taxonomy archives — category and tag first, then anything else public.
829 $taxonomy_vars = array(
830 'category_name' => 'category',
831 'tag' => 'post_tag',
832 );
833 foreach ( get_taxonomies( array( 'public' => true ), 'objects' ) as $taxonomy ) {
834 if ( ! empty( $taxonomy->query_var ) ) {
835 $taxonomy_vars[ $taxonomy->query_var ] = $taxonomy->name;
836 }
837 }
838 foreach ( $taxonomy_vars as $var => $taxonomy ) {
839 if ( empty( $vars[ $var ] ) || ! is_string( $vars[ $var ] ) ) {
840 continue;
841 }
842 // Hierarchical taxonomies arrive as "parent/child"; the term itself
843 // is the last segment.
844 $slug = (string) $vars[ $var ];
845 $slug = false === strpos( $slug, '/' ) ? $slug : substr( strrchr( $slug, '/' ), 1 );
846 if ( '' === $slug ) {
847 continue;
848 }
849 $term = get_term_by( 'slug', $slug, $taxonomy );
850 if ( $term instanceof \WP_Term ) {
851 $object = get_taxonomy( $taxonomy );
852 return $found(
853 'term_archive',
854 sprintf(
855 /* translators: %s: taxonomy singular name, e.g. "Category" */
856 __( '%s archive', 'betterlinks' ),
857 $object && isset( $object->labels->singular_name ) ? $object->labels->singular_name : $taxonomy
858 )
859 );
860 }
861 }
862
863 // Post type archives.
864 if ( ! empty( $vars['post_type'] ) && empty( $vars['name'] ) && empty( $vars['pagename'] ) && empty( $vars['p'] ) ) {
865 $post_type = is_array( $vars['post_type'] ) ? reset( $vars['post_type'] ) : $vars['post_type'];
866 $object = get_post_type_object( (string) $post_type );
867 if ( $object && ! empty( $object->has_archive ) ) {
868 return $found(
869 'post_type_archive',
870 sprintf(
871 /* translators: %s: post type singular name, e.g. "Product" */
872 __( '%s archive', 'betterlinks' ),
873 isset( $object->labels->singular_name ) ? $object->labels->singular_name : $post_type
874 )
875 );
876 }
877 }
878
879 // Author archives.
880 if ( ! empty( $vars['author_name'] ) && is_string( $vars['author_name'] ) ) {
881 if ( get_user_by( 'slug', $vars['author_name'] ) ) {
882 return $found( 'author_archive', __( 'author archive', 'betterlinks' ) );
883 }
884 }
885
886 // Date archives, but only when they actually hold a published post.
887 if ( ! empty( $vars['year'] ) ) {
888 $date = array( 'year' => (int) $vars['year'] );
889 if ( ! empty( $vars['monthnum'] ) ) {
890 $date['month'] = (int) $vars['monthnum'];
891 }
892 if ( ! empty( $vars['day'] ) ) {
893 $date['day'] = (int) $vars['day'];
894 }
895 $dated = new \WP_Query(
896 array(
897 'post_type' => 'post',
898 'post_status' => 'publish',
899 'posts_per_page' => 1,
900 'fields' => 'ids',
901 'no_found_rows' => true,
902 'ignore_sticky_posts' => true,
903 'update_post_meta_cache' => false,
904 'update_post_term_cache' => false,
905 'date_query' => array( $date ),
906 )
907 );
908 if ( ! empty( $dated->posts ) ) {
909 return $found( 'date_archive', __( 'date archive', 'betterlinks' ) );
910 }
911 }
912
913 // Reserved endpoints WordPress answers on every install.
914 if ( ! empty( $vars['feed'] ) ) {
915 return $found( 'feed', __( 'feed', 'betterlinks' ) );
916 }
917 if ( ! empty( $vars['robots'] ) ) {
918 return $found( 'robots', __( 'robots.txt', 'betterlinks' ) );
919 }
920 if ( ! empty( $vars['sitemap'] ) ) {
921 return $found( 'sitemap', __( 'sitemap', 'betterlinks' ) );
922 }
923 if ( isset( $vars['s'] ) ) {
924 return $found( 'search', __( 'search results page', 'betterlinks' ) );
925 }
926
927 return null;
928 }
929
930 public static function sanitize_text_or_array_field( $array_or_string, $key = '' ) {
931
932 $boolean = array( 'true', 'false', '1', '0' );
933 $skip = array( 'affiliate_disclosure_text', 'allow_contact_text', 'form_title', 'customFields', 'autolink_custom_icon' );
934 $url_keys = array( 'link', 'target_url' );
935 if ( is_string( $array_or_string ) ) {
936 if ( in_array( $key, $url_keys, true ) ) {
937 return esc_url_raw( $array_or_string );
938 }
939 $array_or_string = in_array( $array_or_string, $boolean ) || is_bool( $array_or_string ) ? rest_sanitize_boolean( $array_or_string ) : sanitize_text_field( $array_or_string );
940 } elseif ( is_array( $array_or_string ) ) {
941 foreach ( $array_or_string as $field_key => &$value ) {
942 if ( in_array( $field_key, $skip ) ) {
943 continue;
944 }
945 if ( is_array( $value ) ) {
946 $value = self::sanitize_text_or_array_field( $value, $field_key );
947 } elseif ( in_array( $field_key, $url_keys, true ) ) {
948 $value = esc_url_raw( $value );
949 } else {
950 $value = in_array( $value, $boolean ) || is_bool( $value ) ? rest_sanitize_boolean( $value ) : sanitize_text_field( $value );
951 }
952 }
953 }
954 return $array_or_string;
955 }
956 public static function fresh_ajax_request_data( $data ) {
957 $remove = array( 'action', 'security' );
958 return array_diff_key( $data, array_flip( $remove ) );
959 }
960
961 public static function force_relative_url( $url ) {
962 return preg_replace( '/^(http)?s?:?\/\/[^\/]*(\/?.*)$/i', '$2', '' . $url );
963 }
964
965 /**
966 * Normalizing Clicks Data
967 *
968 * This function is responsible for manualy filter the duplicates IPs and link_id's from the data.
969 *
970 * @internal this is used in update_links_analytics for clicks on cron hook called 'betterlinks/analytics'
971 *
972 * @since 1.3.1
973 *
974 * @param array $data This should be the clicks data for IP's and links.
975 * @return array
976 */
977 public static function normalize_ips_data( &$data ) {
978 $_results = array();
979 if ( ! empty( $data ) ) {
980 foreach ( $data as &$analytic ) {
981 $_link_id = $analytic['link_id'];
982 $_link_count = $analytic['lidc'];
983 $_ip = isset( $analytic['ip'] ) ? trim( $analytic['ip'] ) : '';
984 $_ip_count = $analytic['ipc'];
985
986 if ( ! isset( $_results[ $_link_id ] ) ) {
987 $_results[ $_link_id ] = array(
988 'link_count' => $_link_count,
989 'ip' => array(),
990 );
991 }
992
993 if ( $_ip && ! isset( $_results[ $_link_id ]['ip'][ $_ip ] ) ) {
994 $_results[ $_link_id ]['ip'][ $_ip ] = $_ip_count;
995 }
996 }
997 }
998
999 return $_results;
1000 }
1001
1002 /**
1003 * Merges the two result sets returned by get_clicks_count() into a single
1004 * map keyed by link_id.
1005 *
1006 * The totals and the uniques come from two independent GROUP BY queries, so
1007 * their row order is not guaranteed to line up. Pairing them positionally
1008 * attaches one link's unique count to a different link — which is how a link
1009 * with hundreds of clicks from distinct IPs ends up reporting "1 unique".
1010 * Both sides are looked up by link_id instead.
1011 *
1012 * @since 3.0.1
1013 *
1014 * @param array $clicks_count Return value of get_clicks_count().
1015 * @return array Map of link_id => array( 'link_count' => int, 'ip' => int ).
1016 */
1017 public static function merge_clicks_count( $clicks_count ) {
1018 $results = array();
1019 $total_clicks = isset( $clicks_count['total_clicks'] ) && is_array( $clicks_count['total_clicks'] ) ? $clicks_count['total_clicks'] : array();
1020 $unique_clicks = isset( $clicks_count['unique_clicks'] ) && is_array( $clicks_count['unique_clicks'] ) ? $clicks_count['unique_clicks'] : array();
1021
1022 $unique_by_link = array();
1023 foreach ( $unique_clicks as $unique ) {
1024 if ( isset( $unique['link_id'] ) ) {
1025 $unique_by_link[ $unique['link_id'] ] = isset( $unique['unique_clicks'] ) ? (int) $unique['unique_clicks'] : 0;
1026 }
1027 }
1028
1029 foreach ( $total_clicks as $total ) {
1030 if ( ! isset( $total['link_id'] ) ) {
1031 continue;
1032 }
1033 $link_id = $total['link_id'];
1034 $results[ $link_id ] = array(
1035 'link_count' => isset( $total['total_clicks'] ) ? (int) $total['total_clicks'] : 0,
1036 'ip' => isset( $unique_by_link[ $link_id ] ) ? $unique_by_link[ $link_id ] : 0,
1037 );
1038 }
1039
1040 return $results;
1041 }
1042
1043 public static function update_links_analytics() {
1044 $results = self::merge_clicks_count( self::get_clicks_count() );
1045
1046 return update_option( 'betterlinks_analytics_data', wp_json_encode( $results ), false );
1047 }
1048
1049 public static function maybe_json( $data, $sanitize_text = true ) {
1050 if ( is_array( $data ) || is_object( $data ) ) {
1051 return wp_json_encode( $data );
1052 }
1053
1054 if ( is_string( $data ) && $sanitize_text ) {
1055 return sanitize_text_field( $data );
1056 }
1057
1058 return $data;
1059 }
1060 public static function generate_short_url( $short_url ) {
1061 return site_url( '/' ) . trim( $short_url, '/' );
1062 }
1063
1064 public static function btl_get_option( $option_name ) {
1065 global $wpdb;
1066 $result = $wpdb->get_row(
1067 $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}options WHERE option_name=%s", $option_name ),
1068 ARRAY_A
1069 );
1070 $value = false;
1071 if ( ! empty( $result['option_id'] ) ) {
1072 $value = maybe_unserialize( $result['option_value'] );
1073 }
1074 return $value;
1075 }
1076 public static function btl_update_option( $option_name, $option_value, $careless_insert = false, $careless_update = false ) {
1077 global $wpdb;
1078 $option_value = maybe_serialize( $option_value );
1079 $result = false;
1080 if ( ! $careless_insert && ! $careless_update ) {
1081 $result = $wpdb->get_row(
1082 $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}options WHERE option_name=%s", $option_name ),
1083 ARRAY_A
1084 );
1085 }
1086 if ( $careless_insert || ( ! $careless_update && empty( $result['option_id'] ) ) ) {
1087 $result = $wpdb->query(
1088 $wpdb->prepare(
1089 "INSERT INTO {$wpdb->prefix}options ( option_name, option_value, autoload ) VALUES ( %s, %s, %s )",
1090 array(
1091 $option_name,
1092 $option_value,
1093 'no',
1094 )
1095 )
1096 );
1097 return $result;
1098 }
1099 if ( $careless_update || ! empty( $result['option_id'] ) ) {
1100 $result = $wpdb->update(
1101 "{$wpdb->prefix}options",
1102 array(
1103 'option_value' => $option_value,
1104 'autoload' => 'no',
1105 ),
1106 array( 'option_name' => $option_name )
1107 );
1108 return $result !== false;
1109 }
1110 }
1111 public static function btl_update_autoload_option( $option_name, $autoload = false ) {
1112 global $wpdb;
1113 $result = $wpdb->get_row(
1114 $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}options WHERE option_name=%s", $option_name ),
1115 ARRAY_A
1116 );
1117
1118 if ( ! empty( $result['option_id'] ) && ! empty( $result['option_value'] ) ) {
1119 if ( $autoload === false ) {
1120 $result = $wpdb->update(
1121 "{$wpdb->prefix}options",
1122 array(
1123 'option_value' => $result['option_value'],
1124 'autoload' => 'no',
1125 ),
1126 array( 'option_name' => $option_name )
1127 );
1128 } elseif ( $autoload === true ) {
1129 $result = $wpdb->update(
1130 "{$wpdb->prefix}options",
1131 array(
1132 'option_value' => $result['option_value'],
1133 'autoload' => 'yes',
1134 ),
1135 array( 'option_name' => $option_name )
1136 );
1137 }
1138 return $result !== false;
1139 }
1140 }
1141 public static function run_migration_for_ptrl_links_in_background( $installer, $links_count ) {
1142 global $wpdb;
1143 $per_page = 10000;
1144 $total_page = ceil( $links_count / $per_page );
1145 for ( $page = 1; $page <= $total_page; $page++ ) {
1146 $offset = ( $page - 1 ) * $per_page;
1147 $links = $wpdb->get_col(
1148 "SELECT concat('prli_links-', ID) AS ID FROM {$wpdb->prefix}prli_links LIMIT $per_page OFFSET {$offset}",
1149 0
1150 );
1151 $installer->data( $links )->save();
1152 }
1153 $installer->data( array( 'betterlinks_ptl_links_migrated' ) )->save();
1154 return $installer;
1155 }
1156 public static function run_migration_for_ptrl_clicks_in_background( $installer, $clicks_count ) {
1157 global $wpdb;
1158 $per_page = 10000;
1159 $total_page = ceil( $clicks_count / $per_page );
1160 for ( $page = 1; $page <= $total_page; $page++ ) {
1161 $offset = ( $page - 1 ) * $per_page;
1162 $clicks = $wpdb->get_col(
1163 "SELECT concat('prli_clicks-', ID) AS ID FROM {$wpdb->prefix}prli_clicks LIMIT $per_page OFFSET {$offset}",
1164 0
1165 );
1166 $installer->data( $clicks )->save();
1167 }
1168 $installer->data( array( 'betterlinks_ptl_clicks_migrated' ) )->save();
1169 return $installer;
1170 }
1171
1172 public function generate_random_slug( $length = 3 ) {
1173 $characters = '0123456789abcdefghijklmnopqrstuvwxyz';
1174 $random_string = '';
1175 $characters_length = strlen( $characters );
1176
1177 for ( $i = 0; $i < $length; $i++ ) {
1178 $random_string .= $characters[ wp_rand( 0, $characters_length - 1 ) ];
1179 }
1180 $random_num = wp_rand( 0, 10 ) . wp_rand( 0, 10 ) . wp_rand( 0, 10 );
1181 return $random_string . $random_num;
1182 }
1183 public function get_betterlinks_prefix() {
1184 if ( BETTERLINKS_EXISTS_SETTINGS_JSON ) {
1185 $data = Cache::get_json_settings();
1186
1187 if ( empty( $data ) ) {
1188 $data = Cache::write_json_settings();
1189 }
1190 $prefix = ! empty( $data['prefix'] ) ? $data['prefix'] . '/' : '';
1191 return $prefix;
1192 }
1193
1194 $betterlinks_links = get_option( 'betterlinks_links', array() );
1195 if ( is_string( $betterlinks_links ) ) {
1196 $betterlinks_links = json_decode( $betterlinks_links, true );
1197 }
1198 $prefix = ! empty( $betterlinks_links['prefix'] ) ? $betterlinks_links['prefix'] . '/' : '';
1199 return $prefix;
1200 }
1201
1202 /**
1203 * The current user's Quick Link Creation token, for rendering the bookmarklet.
1204 *
1205 * Issued lazily and only for users who are actually allowed to create links,
1206 * so a delegated role browsing the settings screen never receives one.
1207 *
1208 * @return string|null
1209 */
1210 public static function get_cle_token_for_display() {
1211 $user_id = get_current_user_id();
1212
1213 if ( ! $user_id || ! CLEToken::current_user_can_create() ) {
1214 return null;
1215 }
1216
1217 $record = CLEToken::get_or_issue_for_user( $user_id );
1218
1219 return ( is_array( $record ) && ! empty( $record['token'] ) ) ? $record['token'] : null;
1220 }
1221
1222 /**
1223 * Sanitize callback for the `custom_tracking_scripts` REST field.
1224 *
1225 * The field holds raw JavaScript that is echoed verbatim on the cloaked
1226 * redirect page, so storing it is an `unfiltered_html` action. The write path
1227 * (`BetterLinksPro\Helper::update_custom_script_data`) already refuses the
1228 * field for callers without that capability; this drops it one layer earlier
1229 * so a delegated `writelinks` / `editlinks` role can never get raw markup as
1230 * far as the storage layer.
1231 *
1232 * @param mixed $value Incoming value.
1233 * @return string
1234 */
1235 public static function sanitize_custom_tracking_scripts( $value ) {
1236 if ( ! is_string( $value ) ) {
1237 return '';
1238 }
1239
1240 if ( ! current_user_can( 'unfiltered_html' ) ) {
1241 return '';
1242 }
1243
1244 return $value;
1245 }
1246
1247 public function fetch_target_url( $target_url ) {
1248 if ( empty( $target_url ) ) {
1249 return false;
1250 }
1251
1252 // SSRF guard: this fetches a caller-supplied URL server-side, so it must
1253 // not be able to reach internal hosts. wp_safe_remote_get() runs
1254 // wp_http_validate_url() (rejects loopback, RFC1918, link-local
1255 // 169.254/16, CGNAT, reserved ranges and non-http(s) schemes) and keeps
1256 // TLS verification on. Replaces WP_Http::get() with sslverify => false.
1257 $result = wp_safe_remote_get(
1258 $target_url,
1259 array(
1260 'timeout' => 5,
1261 'redirection' => 3,
1262 // Preserve the original WP_Http::get() behavior (no cert enforcement)
1263 // so this title fetch still works for targets with invalid certs;
1264 // wp_safe_remote_get only adds the SSRF host/redirect validation.
1265 'sslverify' => false,
1266 'limit_response_size' => 512 * 1024,
1267 )
1268 );
1269 $title = '';
1270 $body = is_wp_error( $result ) ? '' : wp_remote_retrieve_body( $result );
1271 if ( ! empty( $body ) && preg_match( '/<title>(.*)<\/title>/siU', $body, $title_matches ) ) {
1272 $title = html_entity_decode( $title_matches[1] );
1273 }
1274 return $title;
1275 }
1276 public static function insert_new_category( $slug ) {
1277 if ( ! ! intval( $slug ) ) {
1278 return $slug;
1279 }
1280
1281 // Check if category exists and get its ID for AI
1282 $existing_term = self::get_term_by_slug( self::make_slug( $slug ), 'category' );
1283 if ( ! empty( $existing_term ) && is_array( $existing_term ) && count( $existing_term ) > 0 ) {
1284 // Category exists, return its ID
1285 return $existing_term[0]['ID'];
1286 }
1287
1288 // Category doesn't exist, create it
1289 $insert_id = self::insert_term(
1290 array(
1291 'term_name' => $slug,
1292 'term_slug' => self::make_slug( $slug ),
1293 'term_type' => 'category',
1294 )
1295 );
1296 if ( $insert_id ) {
1297 self::clear_query_cache();
1298 return $insert_id;
1299 }
1300
1301 return $slug;
1302 }
1303
1304 public static function isJson( $string ) {
1305 json_decode( $string );
1306 return json_last_error() === JSON_ERROR_NONE;
1307 }
1308
1309 public static function get_migratable_plugins() {
1310 return [
1311 'simple301redirects' => defined('SIMPLE301REDIRECTS_VERSION'),
1312 'thirstyaffiliates' => class_exists('ThirstyAffiliates'),
1313 'prettylinks' => defined('PRLI_VERSION'),
1314 ];
1315 }
1316
1317 public static function init_tracking( $data, $utils ) {
1318 global $betterlinks;
1319 $user_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : ''; // phpcs:ignore
1320 $dd = new DeviceDetector( $user_agent );
1321 $dd->parse();
1322
1323 $data['is_bot'] = $dd->isBot();
1324 if ( empty( $data['target_url'] ) || ! apply_filters( 'betterlinks/pre_before_redirect', $data ) ) {
1325 // password protection logics
1326 if( empty( $data['skip_password_protection'] ) ){
1327 do_action( 'betterlinkspro/admin/check_password_protection', $data['short_url'], $data );
1328 }
1329
1330 if ( empty( $data['target_url'] ) || ! apply_filters( 'betterlinks/pre_before_redirect', $data ) ) { // phpcs:ignore
1331 return false;
1332 }
1333 }
1334 $data = apply_filters( 'betterlinks/link/before_dispatch_redirect', $data ); // phpcs:ignore.
1335 if ( empty( $data ) ) {
1336 return false;
1337 }
1338 do_action( 'betterlinks/before_redirect', $data ); // phpcs:ignore.
1339
1340 $comparable_url = rtrim( preg_replace( '/https?\:\/\//', '', site_url( '/' ) ), '/' ) . '/' . $data['short_url'];
1341 $destination_url = rtrim( preg_replace( '/https?\:\/\//', '', $data['target_url'] ), '/' );
1342 $comparable_url = rtrim( preg_replace( '/^www\.?/', '', $comparable_url ), '/' );
1343 $destination_url = rtrim( preg_replace( '/^www\.?/', '', $destination_url ), '/' );
1344 if ( ! $data || $comparable_url === $destination_url ) {
1345 return;
1346 }
1347
1348 if ( filter_var( $data['track_me'], FILTER_VALIDATE_BOOLEAN ) ) {
1349 $data = apply_filters( 'betterlinks/extra_tracking_data', $data, $dd );
1350
1351 $data['os'] = OperatingSystem::getOsFamily( $dd->getOs( 'name' ) );
1352 $data['browser'] = Browser::getBrowserFamily( $dd->getClient( 'name' ) );
1353 $data['device'] = $dd->getDeviceName();
1354
1355 if ( isset( $betterlinks['disablebotclicks'] ) && $betterlinks['disablebotclicks'] ) {
1356 if ( ! $dd->isBot() ) {
1357 $utils->start_trakcing( $data );
1358 }
1359 } else {
1360 $utils->start_trakcing( $data );
1361 }
1362 }
1363 }
1364
1365 /**
1366 * Rebuild links JSON from database
1367 * Useful when JSON file is corrupted or completely out of sync
1368 *
1369 * @since 2.6.0
1370 * @return bool True if rebuild successful
1371 */
1372 public static function rebuild_links_json() {
1373 if ( ! BETTERLINKS_EXISTS_LINKS_JSON ) {
1374 return false;
1375 }
1376
1377 $formattedArray = self::get_links_for_json();
1378 $json_file = trailingslashit( BETTERLINKS_UPLOAD_DIR_PATH ) . 'links.json';
1379
1380 return (bool) file_put_contents( $json_file, wp_json_encode( $formattedArray ) );
1381 }
1382
1383 /**
1384 * Sync all missing links from database to JSON file
1385 * Checks if all database links exist in JSON and adds missing ones
1386 * Called when admin page loads to ensure complete synchronization
1387 *
1388 * @since 2.6.2
1389 * @return array Results: ['total' => count, 'synced' => count]
1390 */
1391 public static function sync_all_missing_links_to_json() {
1392 if ( ! BETTERLINKS_EXISTS_LINKS_JSON ) {
1393 return array( 'total' => 0, 'synced' => 0 );
1394 }
1395
1396 if ( ! function_exists( 'file_get_contents' ) || ! function_exists( 'file_put_contents' ) ) {
1397 return array( 'total' => 0, 'synced' => 0 );
1398 }
1399
1400 $json_file = trailingslashit( BETTERLINKS_UPLOAD_DIR_PATH ) . 'links.json';
1401
1402 // Read JSON file
1403 if ( ! file_exists( $json_file ) ) {
1404 // JSON doesn't exist, rebuild from scratch
1405 self::rebuild_links_json();
1406 return array( 'total' => 0, 'synced' => 0 );
1407 }
1408
1409 $json_content = file_get_contents( $json_file );
1410 $json_data = json_decode( $json_content, true );
1411
1412 // If JSON is corrupted, rebuild
1413 if ( ! is_array( $json_data ) ) {
1414 self::rebuild_links_json();
1415 return array( 'total' => 0, 'synced' => 0 );
1416 }
1417
1418 // Get all published links from database
1419 global $wpdb;
1420 $all_db_links = $wpdb->get_results(
1421 "SELECT ID, short_url, wildcards FROM {$wpdb->prefix}betterlinks WHERE link_status = 'publish'",
1422 ARRAY_A
1423 );
1424
1425 $synced_count = 0;
1426 $total_links = count( $all_db_links );
1427
1428 // Check each database link and add if missing from JSON
1429 foreach ( $all_db_links as $db_link ) {
1430 $short_url = $db_link['short_url'];
1431 $link_id = $db_link['ID'];
1432 $is_wildcard = isset( $db_link['wildcards'] ) && $db_link['wildcards'];
1433
1434 // Determine where to look
1435 $target_section = $is_wildcard ? 'wildcards' : 'links';
1436
1437 // Check if link exists in JSON
1438 $link_exists = false;
1439 if ( isset( $json_data[ $target_section ] ) && is_array( $json_data[ $target_section ] ) ) {
1440 if ( isset( $json_data[ $target_section ][ $short_url ] ) ) {
1441 $link_exists = true;
1442 }
1443 }
1444
1445 // If missing, add it
1446 if ( ! $link_exists ) {
1447 $full_link_data = $wpdb->get_row(
1448 $wpdb->prepare(
1449 "SELECT * FROM {$wpdb->prefix}betterlinks WHERE ID = %d",
1450 $link_id
1451 ),
1452 ARRAY_A
1453 );
1454
1455 if ( $full_link_data ) {
1456 $formatted_link = self::json_link_formatter( $full_link_data );
1457
1458 if ( $formatted_link ) {
1459 // Ensure target section exists
1460 if ( ! isset( $json_data[ $target_section ] ) ) {
1461 $json_data[ $target_section ] = array();
1462 }
1463
1464 // Add missing link to JSON
1465 $json_data[ $target_section ][ $short_url ] = $formatted_link;
1466 $synced_count++;
1467 }
1468 }
1469 }
1470 }
1471
1472 // Write back to file if any links were synced
1473 if ( $synced_count > 0 ) {
1474 file_put_contents( $json_file, wp_json_encode( $json_data ) );
1475 }
1476
1477 return array(
1478 'total' => $total_links,
1479 'synced' => $synced_count,
1480 );
1481 }
1482 }
1483