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 / Traits / Links.php

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

540 lines 24.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace BetterLinks\Traits;
3 if ( ! defined( 'ABSPATH' ) ) { exit; }
4
5 // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL, PluginCheck.Security.DirectDB
6
7 trait Links
8 {
9 public function sanitize_links_data($POST)
10 {
11 $data = [];
12 foreach ($this->get_links_schema() as $key => $schema) {
13 if (isset($POST[$key])) {
14 if (isset($schema['sanitize_callback'])) {
15 if( 'link_title' === $key ){
16 $data[$key] = $POST[$key]; // it could contain html element tags
17 continue;
18 }
19 $data[$key] = $schema['sanitize_callback']($POST[$key]);
20 } elseif (isset($schema['format']) && $schema['format'] == 'date-time') {
21 $data[$key] = sanitize_text_field($POST[$key]);
22 } elseif (isset($schema['type']) && $schema['type'] === 'object') {
23 $tempData = (is_array($POST[$key]) ? $POST[$key] : json_decode(html_entity_decode(stripslashes($POST[$key])), true));
24 $tempSanitizeData = [];
25 if (isset($schema['properties']) && is_array($tempData) && count($tempData) > 0) {
26 foreach ($schema['properties'] as $innerKey => $innerSchema) {
27 if ($innerSchema['type'] === 'integer' || $innerSchema['type'] === 'string') {
28 if (isset($tempData[$innerKey])) {
29 if (isset($innerSchema['sanitize_callback'])) {
30 $tempSanitizeData[$innerKey] = $innerSchema['sanitize_callback']($tempData[$innerKey]);
31 } elseif (isset($innerSchema['format']) && $innerSchema['format'] == 'date-time') {
32 $tempSanitizeData[$innerKey] = sanitize_text_field($tempData[$innerKey]);
33 }
34 }
35 } elseif ($innerSchema['type'] === 'array') {
36 $tempTwoSanitizeData = [];
37 if (isset($tempData['value']) && is_array($tempData['value'])) {
38 foreach ($tempData['value'] as $valueItem) {
39 $value = [];
40 if (is_array($valueItem)) {
41 foreach ($valueItem as $childValueKey => $childValueItem) {
42 $value[$childValueKey] = \BetterLinks\Helper::sanitize_text_or_array_field($childValueItem, $childValueKey);
43 }
44 }
45 $tempTwoSanitizeData[] = $value;
46 }
47 }
48 $tempSanitizeData[$innerKey] = $tempTwoSanitizeData;
49 } elseif ($innerSchema['type'] === 'object') {
50 $tempThreeSanitizeData = [];
51 if (isset($tempData['extra']) && is_array($tempData['extra'])) {
52 foreach ($tempData['extra'] as $extraKey => $extraItem) {
53 $tempThreeSanitizeData[$extraKey] = sanitize_text_field($extraItem);
54 }
55 }
56 $tempSanitizeData[$innerKey] = $tempThreeSanitizeData;
57 }
58 }
59 }
60 if( 'param_struct' === $key){
61 $data[$key] = serialize($POST[$key]);
62 continue;
63 }
64 $data[$key] = $tempSanitizeData;
65 } elseif ( in_array( $key, ['tags_id', 'favorite', 'analytic'] ) ) {
66 $result = (is_array($POST[$key]) ? $POST[$key] : json_decode(html_entity_decode(stripslashes($POST[$key])), true));
67 $data[$key] = \BetterLinks\Helper::sanitize_text_or_array_field($result);
68 }elseif( in_array( $key, ['enable_password', 'password', 'enable_custom_scripts'] ) ) { // password protected parameters
69 $data[$key] = \BetterLinks\Helper::sanitize_text_or_array_field($POST[$key]);
70 }elseif( 'custom_tracking_scripts' === $key){
71 $data[$key] = $POST[$key]; // it contains javascript code
72 }
73 }
74 }
75 return $data;
76 }
77 /**
78 * Gate a create/update payload before it reaches the database.
79 *
80 * Both the REST controller and the admin-ajax fallback that the React app
81 * falls back to when REST is unavailable go through here, so a short_url is
82 * validated the same way whichever transport carried it.
83 *
84 * Returns a WP_Error describing the rejection, or null when the payload is
85 * safe to write.
86 *
87 * @param array $args Sanitized link payload.
88 * @param bool $is_update Whether this is an update of an existing row.
89 * @param int $allowed_post_id Post whose own permalink this write is allowed
90 * to shadow (Instant Redirect). 0 for none.
91 * @return \WP_Error|null
92 */
93 public function validate_link_payload($args, $is_update = false, $allowed_post_id = 0)
94 {
95 if (!isset($args['short_url']) || '' === (string) $args['short_url']) {
96 return null;
97 }
98 $short_url = (string) $args['short_url'];
99 $id = isset($args['ID']) ? absint($args['ID']) : 0;
100
101 if ($is_update && $id > 0) {
102 $current_row = \BetterLinks\Helper::get_link_by_ID($id);
103 $current = is_array($current_row) && !empty($current_row) ? current($current_row) : null;
104 $current_url = is_array($current) && isset($current['short_url']) ? (string) $current['short_url'] : '';
105 // A no-op edit (title, target, category…) resubmits the stored
106 // short_url untouched. Nothing is changing, so nothing to validate —
107 // and validating anyway would reject links that predate this check.
108 if ($short_url === $current_url) {
109 return null;
110 }
111 // insert_link() refuses a duplicate short_url on create, but the
112 // update branch never did, so two rows could end up owning the same
113 // path and one would silently win the links.json entry.
114 $owner = \BetterLinks\Helper::get_link_by_short_url($short_url);
115 foreach ((array) $owner as $row) {
116 if (isset($row['ID']) && absint($row['ID']) !== $id) {
117 return new \WP_Error(
118 'betterlinks_duplicate_short_url',
119 sprintf(
120 /* translators: %s: the short URL that is already taken */
121 __('Another link already uses the short URL "%s". Short URLs have to be unique.', 'betterlinks'),
122 $short_url
123 ),
124 ['status' => 409]
125 );
126 }
127 }
128 }
129
130 $collision = \BetterLinks\Helper::check_wp_url_collision($short_url, $allowed_post_id);
131 if (is_wp_error($collision)) {
132 return $collision;
133 }
134 return null;
135 }
136
137 /**
138 * The post an Instant Redirect write claims to belong to, or 0.
139 *
140 * The block editor sends `instant_redirect_post_id` alongside the link
141 * payload when the Instant Redirect sidebar saves, because that panel
142 * deliberately registers the post's own permalink as a short URL and would
143 * otherwise be refused by the WP URL collision check.
144 *
145 * It is caller-supplied, and all it does is relax that check for one
146 * specific path, so it only counts when the current user may actually edit
147 * the post in question — otherwise it is a way to shadow someone else's
148 * page.
149 *
150 * @param array $source Raw (unsanitized) request payload.
151 * @return int
152 */
153 public function resolve_instant_redirect_post_id($source)
154 {
155 if (!is_array($source) || !isset($source['instant_redirect_post_id'])) {
156 return 0;
157 }
158 $post_id = absint($source['instant_redirect_post_id']);
159 if ($post_id < 1 || !get_post($post_id)) {
160 return 0;
161 }
162 return current_user_can('edit_post', $post_id) ? $post_id : 0;
163 }
164
165 public function insert_link($arg)
166 {
167 if (isset($arg['short_url']) && ! \BetterLinks\Helper::is_exists_short_url($arg['short_url'])) {
168 // Start Transaction
169 global $wpdb;
170 $wpdb->query("START TRANSACTION");
171 $lookFor = array_combine(array_keys($this->links_schema()), array_keys($this->links_schema()));
172 $params = array_intersect_key($arg, $lookFor);
173 // insert link
174 $id = \BetterLinks\Helper::insert_link(apply_filters('betterlinks/api/params', $params));
175 $term_data = \BetterLinks\Helper::insert_terms_and_terms_relationship($id, $arg);
176 $wpdb->query("COMMIT");
177
178 // Initialize category data with default fallback
179 $arg['cat_id'] = isset($arg['cat_id']) ? $arg['cat_id'] : 1; // Default to Uncategorized
180 $arg['tags_data'] = isset($arg['tags_data']) ? $arg['tags_data'] : [];
181
182 // for instant create category system
183 foreach ($term_data as $key => $value) {
184 if(empty($value["term_type"])){
185 continue;
186 }
187 if($value["term_type"] === "tags"){
188 $arg['tags_data'][] = $value;
189 }
190 if($value["term_type"] === "category"){
191 $arg['cat_id'] = $value["term_id"];
192 $arg['cat_data'] = $value;
193 }
194 }
195 if (BETTERLINKS_EXISTS_LINKS_JSON) {
196 $params['ID'] = $id;
197 $params['cat_id'] = $arg['cat_id'];
198
199 // Auto-apply UTM template if enabled for this category
200 $updated_target_url = $this->auto_apply_utm_template_to_new_link($id, $arg);
201
202 // Update params with the UTM-enhanced URL if it was modified
203 if ($updated_target_url && $updated_target_url !== $arg['target_url']) {
204 $params['target_url'] = $updated_target_url;
205 }
206
207 \BetterLinks\Helper::insert_json_into_file(trailingslashit(BETTERLINKS_UPLOAD_DIR_PATH) . 'links.json', $params);
208
209 // Sync missing links when new link is created (including when duplicating)
210 \BetterLinks\Helper::sync_all_missing_links_to_json();
211 } else {
212 // Auto-apply UTM template if enabled for this category (when JSON is not used)
213 $updated_target_url = $this->auto_apply_utm_template_to_new_link($id, $arg);
214 }
215
216 do_action( 'betterlinkspro/admin/update_link', $id, $arg );
217
218 $response = array_merge($arg, [
219 'ID' => strval($id),
220 ]);
221
222 // Update response with the UTM-enhanced URL if it was modified
223 if (isset($updated_target_url) && $updated_target_url && $updated_target_url !== $arg['target_url']) {
224 $response['target_url'] = $updated_target_url;
225 }
226
227 if( !empty( $response['param_struct'] ) ){
228 $response['param_struct'] = unserialize($response['param_struct'], array('allowed_classes' => false));
229 }
230 // Invalidate the dashboard cache *after* the row exists. Callers also
231 // clear it before writing, but that alone leaves a window: the
232 // transient is stored without a TTL, so any read landing between the
233 // pre-write clear and this insert would repopulate it from a table
234 // that does not have the new link yet and keep serving that snapshot
235 // forever — a link that saved fine but never appears in Manage Links.
236 delete_transient(BETTERLINKS_CACHE_LINKS_NAME);
237 return $response;
238 }
239 return false;
240 }
241 public function update_link($arg)
242 {
243
244 // Start Transaction
245 global $wpdb;
246 $wpdb->query("START TRANSACTION");
247 $lookFor = array_combine(array_keys($this->links_schema()), array_keys($this->links_schema()));
248 $params = array_intersect_key($arg, $lookFor);
249
250 $old_short_url = isset($arg['old_short_url']) ? $arg['old_short_url'] : '';
251 // update link
252 $id = \BetterLinks\Helper::insert_link(apply_filters('betterlinks/api/params', $params), true);
253
254 // Only rewrite term relationships when the caller actually sent term data.
255 // insert_terms_and_terms_relationship() falls back to the default category
256 // (Uncategorized) whenever cat_id is empty, so running it for a payload that
257 // never mentioned terms — e.g. the bulk status change, which posts only
258 // {ID, link_status} — silently moved the link out of its category.
259 $has_term_payload = isset($arg['cat_id']) || isset($arg['tags_id']);
260 $term_data = $has_term_payload
261 ? \BetterLinks\Helper::insert_terms_and_terms_relationship($id, $arg)
262 : array();
263
264 $wpdb->query("COMMIT");
265
266 if (!$has_term_payload) {
267 // Nothing was rewritten; carry the link's existing category forward so the
268 // JSON cache below is not rebuilt with the wrong (default) category.
269 $existing_cat = \BetterLinks\Helper::get_terms_by_link_ID_and_term_type($id, 'category');
270 if (!empty($existing_cat) && isset($existing_cat[0]['term_id'])) {
271 $arg['cat_id'] = $existing_cat[0]['term_id'];
272 $arg['cat_data'] = $existing_cat[0];
273 }
274 }
275
276 // Initialize category data with default fallback
277 $arg['cat_id'] = isset($arg['cat_id']) ? $arg['cat_id'] : 1; // Default to Uncategorized
278 $arg['tags_data'] = isset($arg['tags_data']) ? $arg['tags_data'] : [];
279
280 foreach ($term_data as $key => $value) {
281 if(empty($value["term_type"])){
282 continue;
283 }
284 if($value["term_type"] === "tags"){
285 $arg['tags_data'][] = $value;
286 }
287 if($value["term_type"] === "category"){
288 $arg['old_cat_id'] = isset($arg['cat_id']) ? $arg['cat_id'] : 1;
289 $arg['cat_id'] = $value["term_id"];
290 $arg['cat_data'] = $value;
291 }
292 }
293 if (BETTERLINKS_EXISTS_LINKS_JSON) {
294 $params['cat_id'] = $arg['cat_id'];
295 \BetterLinks\Helper::update_json_into_file(trailingslashit(BETTERLINKS_UPLOAD_DIR_PATH) . 'links.json', $params, $old_short_url);
296
297 // Sync missing links when link is updated
298 \BetterLinks\Helper::sync_all_missing_links_to_json();
299 }
300
301 do_action( 'betterlinkspro/admin/update_link', $id, $arg );
302
303 if( !empty( $arg['param_struct'] ) ){
304 $arg['param_struct'] = unserialize($arg['param_struct'], array('allowed_classes' => false));
305 }
306 // See insert_link(): clear once more now the write is committed, so a
307 // concurrent read cannot leave a permanent pre-write snapshot behind.
308 delete_transient(BETTERLINKS_CACHE_LINKS_NAME);
309 return $arg;
310 }
311 public function update_link_favorite($args)
312 {
313 if (isset($args["ID"], $args["data"])) {
314 $id = absint($args["ID"]);
315 $data = wp_json_encode($args["data"]);
316 global $wpdb;
317 $table = $wpdb->prefix . 'betterlinks';
318 return $wpdb->query(
319 $wpdb->prepare(
320 "UPDATE $table
321 SET favorite = %s
322 WHERE ID = %d LIMIT 1",
323 $data,
324 $id
325 )
326 );
327 }
328 }
329 public function delete_link($args)
330 {
331 if ( ! isset( $args['ID'] ) ) {
332 return false;
333 }
334 delete_transient( BETTERLINKS_CACHE_LINKS_NAME );
335 \BetterLinks\Helper::delete_link($args['ID']);
336 if (BETTERLINKS_EXISTS_LINKS_JSON && isset($args['short_url'])) {
337 \BetterLinks\Helper::delete_json_into_file(trailingslashit(BETTERLINKS_UPLOAD_DIR_PATH) . 'links.json', $args['short_url']);
338 }
339 // See insert_link(): clear again now the row is gone, so a read racing
340 // the delete cannot pin a snapshot that still contains it.
341 delete_transient( BETTERLINKS_CACHE_LINKS_NAME );
342 return true;
343 }
344
345 /**
346 * Auto-apply UTM template to newly created link if enabled for the category
347 * Returns the updated target URL if modified, or null if no changes
348 */
349 public function auto_apply_utm_template_to_new_link($link_id, $link_args)
350 {
351 // Get the category ID from the link
352 $category_id = isset($link_args['cat_id']) ? intval($link_args['cat_id']) : 1; // Default to Uncategorized
353
354 // Get current settings
355 $settings = get_option(BETTERLINKS_LINKS_OPTION_NAME, []);
356 if (is_string($settings)) {
357 $settings = json_decode($settings, true);
358 }
359
360 // Get UTM templates
361 $utm_templates = isset($settings['global_utm_templates']) ? $settings['global_utm_templates'] : [];
362 if (!is_array($utm_templates)) {
363 return null;
364 }
365
366 // Get last applied templates tracking
367 $last_applied_templates = isset($settings['utm_last_applied_templates']) ? $settings['utm_last_applied_templates'] : [];
368
369 // Find the most recently applied template for this category
370 $matching_template = null;
371
372 // First, check if there's a last applied template for this category
373 // Normalize category ID for consistent comparison
374 $normalized_category_id = strval($category_id);
375
376 if (isset($last_applied_templates[$normalized_category_id])) {
377 $last_applied_template_index = $last_applied_templates[$normalized_category_id]['template_index'];
378
379 // Find the template with this index
380 foreach ($utm_templates as $template) {
381 if (isset($template['template_index']) &&
382 $template['template_index'] == $last_applied_template_index) {
383
384 // Verify this template still applies to the current category
385 if (isset($template['categories']) && is_array($template['categories'])) {
386 foreach ($template['categories'] as $template_cat_id) {
387 // Normalize both IDs for comparison
388 $normalized_template_cat_id = strval($template_cat_id);
389 if ($normalized_template_cat_id === $normalized_category_id) {
390 // If the active template has auto-apply enabled, use it
391 if (!empty($template['utm_auto_apply_new_link'])) {
392 $matching_template = $template;
393 }
394 // If active template exists but auto-apply is disabled, and don't use any template (respect user's choice) and Set a flag to prevent fallback search
395 $active_template_found = true;
396 break 2;
397 }
398 }
399 }
400 }
401 }
402 }
403
404 // Only fall back to finding any template if there's no active template for this category
405 if (!$matching_template && !isset($active_template_found)) {
406 foreach ($utm_templates as $template) {
407 // Check if auto-apply is enabled for this template
408 if (empty($template['utm_auto_apply_new_link'])) {
409 continue;
410 }
411
412 // Check if this template applies to the current category
413 if (isset($template['categories']) && is_array($template['categories'])) {
414 foreach ($template['categories'] as $template_cat_id) {
415 // Normalize both IDs for comparison
416 $normalized_template_cat_id = strval($template_cat_id);
417 if ($normalized_template_cat_id === $normalized_category_id) {
418 $matching_template = $template;
419 break 2; // Break out of both loops
420 }
421 }
422 }
423 }
424 }
425
426 // If no matching template found, return
427 if (!$matching_template) {
428 return null;
429 }
430
431 // Extract UTM parameters from template
432 $utm_params = [
433 'utm_source' => isset($matching_template['utm_source']) ? sanitize_text_field($matching_template['utm_source']) : '',
434 'utm_medium' => isset($matching_template['utm_medium']) ? sanitize_text_field($matching_template['utm_medium']) : '',
435 'utm_campaign' => isset($matching_template['utm_campaign']) ? sanitize_text_field($matching_template['utm_campaign']) : '',
436 'utm_term' => isset($matching_template['utm_term']) ? sanitize_text_field($matching_template['utm_term']) : '',
437 'utm_content' => isset($matching_template['utm_content']) ? sanitize_text_field($matching_template['utm_content']) : '',
438 ];
439
440 // Remove empty UTM parameters
441 $utm_params = array_filter($utm_params, function($value) {
442 return !empty($value);
443 });
444
445 // If no UTM parameters to apply, return
446 if (empty($utm_params)) {
447 return null;
448 }
449
450 // Get the current target URL from the arguments (it should be the original URL)
451 $target_url = isset($link_args['target_url']) ? $link_args['target_url'] : '';
452 if (empty($target_url)) {
453 return null;
454 }
455
456 // Parse current target URL
457 $url_parts = wp_parse_url($target_url);
458 if (!$url_parts) {
459 return null;
460 }
461
462 // Parse existing query parameters
463 $query_params = [];
464 if (isset($url_parts['query'])) {
465 parse_str($url_parts['query'], $query_params);
466 }
467
468 // Add UTM parameters (don't overwrite existing ones if rewrite is not enabled)
469 $rewrite_existing = isset($matching_template['utm_enable_to_rewrite_existing_utm_template'])
470 ? $matching_template['utm_enable_to_rewrite_existing_utm_template']
471 : false;
472
473 $params_added = false;
474 foreach ($utm_params as $key => $value) {
475 if ($rewrite_existing || !isset($query_params[$key])) {
476 $query_params[$key] = $value;
477 $params_added = true;
478 }
479 }
480
481 // If no parameters were added, return original URL
482 if (!$params_added) {
483 return null;
484 }
485
486 // Reconstruct the URL
487 $new_url = $url_parts['scheme'] . '://' . $url_parts['host'];
488 if (isset($url_parts['port'])) {
489 $new_url .= ':' . $url_parts['port'];
490 }
491 if (isset($url_parts['path'])) {
492 $new_url .= $url_parts['path'];
493 }
494 if (!empty($query_params)) {
495 $new_url .= '?' . http_build_query($query_params);
496 }
497 if (isset($url_parts['fragment'])) {
498 $new_url .= '#' . $url_parts['fragment'];
499 }
500
501 // Update the link with new target URL
502 global $wpdb;
503 $wpdb->update(
504 $wpdb->prefix . 'betterlinks',
505 ['target_url' => $new_url],
506 ['ID' => $link_id],
507 ['%s'],
508 ['%d']
509 );
510
511 // Update JSON file if it exists
512 if (BETTERLINKS_EXISTS_LINKS_JSON) {
513 // Fetch complete link data to update JSON file
514 $link_data = $wpdb->get_row(
515 $wpdb->prepare(
516 "SELECT * FROM {$wpdb->prefix}betterlinks WHERE ID = %d",
517 $link_id
518 ),
519 ARRAY_A
520 );
521
522 if ($link_data && isset($link_data['short_url'])) {
523 // Update target_url with the new value
524 $link_data['target_url'] = $new_url;
525 \BetterLinks\Helper::update_json_into_file(
526 trailingslashit(BETTERLINKS_UPLOAD_DIR_PATH) . 'links.json',
527 $link_data,
528 $link_data['short_url']
529 );
530 }
531 }
532
533 // Clear cache
534 delete_transient(BETTERLINKS_CACHE_LINKS_NAME);
535
536 // Return the updated URL
537 return $new_url;
538 }
539 }
540