PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | admin/class-metasync-post-meta-setting.php +119 -28 2.6.19 → 2.7.2 View file →
@@ -17,8 +17,19 @@
17 17 }
18 18
19 19 class Metasync_Post_Meta_Settings
20 20 {
21 + /**
22 + * Description written onto every redirection row this meta box owns.
23 + *
24 + * Rows created here live in the shared redirections table alongside rules
25 + * added by hand on the Redirections screen, and carry no other marker. The
26 + * prefix is therefore how the redirect engine tells the two apart, so that
27 + * switching the Redirection feature off stops these rows without touching
28 + * anyone's manually-added rules.
29 + */
30 + const POST_REDIRECT_DESCRIPTION_PREFIX = 'Post redirection meta box for post #';
31 +
21 32 private $common;
22 33
23 34 public function __construct()
24 35 {
@@ -46,9 +57,9 @@
46 57 $post_types = array_diff($post_types, ['attachment']);
47 58
48 59 // Resolve the current post ID in admin_init context: $_GET['post'] on the
49 60 // edit screen, $_POST['post_ID'] on save. Mirrors the sidebar enqueue.
50 - $post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0);
61 + $post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0); // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.NonceVerification.Recommended -- read-only post ID to pick meta boxes
51 62
52 63 // LPS / custom-HTML pages carry their own baked SEO served before wp_head,
53 64 // so the editable SEO fields here do nothing and mislead editors.
54 65 // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only
@@ -112,16 +123,8 @@
112 123 wp_nonce_field('metasync_common_robots_nonce', 'metasync_common_robots_nonce');
113 124 ?>
114 125 <ul class="checkbox-list">
115 126 <li>
116 - <input type="checkbox" name="common_robots_meta[index]" id="robots_common1" value="index" <?php isset($common_robots['index']) ? checked('index', $common_robots['index']) : '' ?>>
117 - <label for="robots_common1">Index </br>
118 - <span class="description">
119 - <span>Search engines to index and show these pages in the search results.</span>
120 - </span>
121 - </label>
122 - </li>
123 - <li>
124 127 <input type="checkbox" name="common_robots_meta[noindex]" id="robots_common2" value="noindex" <?php isset($common_robots['noindex']) ? checked('noindex', $common_robots['noindex']) : '' ?>>
125 128 <label for="robots_common2">No Index </br>
126 129 <span class="description">
127 130 <span>Prevents search engines from indexing and displaying these pages in search results.</span>
@@ -204,9 +207,9 @@
204 207 <li>
205 208 <label for="advanced_robots_image">
206 209 <input type="checkbox" name="advanced_robots_meta[max-image-preview][enable]" id="advanced_robots_image" value="1" <?php checked('1', esc_attr($image_advance_robots_enable)) ?>>
207 210 Image Preview </br>
208 - <select class="input-length" name="advanced_robots_meta[max-image-preview][length]' ?>" id="advanced_robots_image_value">
211 + <select class="input-length" name="advanced_robots_meta[max-image-preview][length]" id="advanced_robots_image_value">
209 212 <option value="large" <?php selected(esc_attr($image_advance_robots_length), 'large'); ?>>Large</option>
210 213 <option value="standard" <?php selected(esc_attr($image_advance_robots_length), 'standard'); ?>>Standard</option>
211 214 <option value="none" <?php selected(esc_attr($image_advance_robots_length), 'none'); ?>>None</option>
212 215 </select>
@@ -275,9 +278,9 @@
275 278 wp_nonce_field('metasync_post_canonical_nonce', 'metasync_post_canonical_nonce');
276 279 ?>
277 280 <ul>
278 281 <li> Canonical URL:
279 - <input type="text" class="regular-text" name="post_canonical_url_meta" placeholder="<?php echo get_permalink($post->ID) ?>" value="<?php echo esc_attr($post_canonical); ?>">
282 + <input type="text" class="regular-text" name="post_canonical_url_meta" placeholder="<?php echo esc_url(get_permalink($post->ID)); ?>" value="<?php echo esc_attr($post_canonical); ?>">
280 283 </li>
281 284 </ul>
282 285 <?php
283 286 }
@@ -294,13 +297,16 @@
294 297 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
295 298 return;
296 299 }
297 300
298 - $post_data = metasync_sanitize_input_array($_POST);
301 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
299 302 // Check for new field name first, then old for backward compatibility
300 303 $field_name = isset($post_data['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
301 304
302 - if (!isset($post_data['metasync_common_robots_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_common_robots_nonce'], 'metasync_common_robots_nonce'))
305 + // Unchecked checkboxes are omitted from the request. Require the nonce,
306 + // but allow an absent checkbox group so a user can clear the last saved
307 + // directive (for example, by unticking No Index).
308 + if (!isset($post_data['metasync_common_robots_nonce']) || !wp_verify_nonce($post_data['metasync_common_robots_nonce'], 'metasync_common_robots_nonce'))
303 309 return;
304 310
305 311 $old_common_robots = get_post_meta($post_id, 'metasync_common_robots', true);
306 312
@@ -325,9 +331,9 @@
325 331 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
326 332 return;
327 333 }
328 334
329 - $post_data = metasync_sanitize_input_array($_POST);
335 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
330 336 // Check for new field name first, then old for backward compatibility
331 337 $field_name = isset($post_data['advanced_robots_meta']) ? 'advanced_robots_meta' : 'advanced_robots_mata';
332 338
333 339 if (!isset($post_data['metasync_advance_robots_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_advance_robots_nonce'], 'metasync_advance_robots_nonce'))
@@ -347,9 +353,9 @@
347 353 {
348 354 if (!current_user_can('edit_post', $post_id))
349 355 return;
350 356
351 - $post_data = metasync_sanitize_input_array($_POST);
357 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
352 358 // Check for new field name first, then old for backward compatibility
353 359 $field_name = isset($post_data['post_redirect_meta']) ? 'post_redirect_meta' : 'post_redirect_mata';
354 360
355 361 if (!isset($post_data['metasync_post_redirection_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_post_redirection_nonce'], 'metasync_post_redirection_nonce'))
@@ -368,9 +374,9 @@
368 374 // sync the actual redirect into the shared redirection table (exact
369 375 // match) using the same DB layer as ?page=...-redirections&action=add. This
370 376 // keeps a single redirect code path — the existing handle_template_redirect()
371 377 // serves it — instead of a duplicate per-post frontend handler.
372 - $this->sync_post_redirect_rule($post_id, is_array($post_redirection_meta) ? $post_redirection_meta : []);
378 + self::sync_post_redirect_rule($post_id, is_array($post_redirection_meta) ? $post_redirection_meta : []);
373 379 }
374 380
375 381 /**
376 382 * Create, update, or remove a row in the shared redirection table so a redirect
@@ -378,22 +384,57 @@
378 384 * as manually-added redirections (exact match on the post's own URL). The row id we
379 385 * create is remembered in post meta so repeated saves update — never duplicate — it,
380 386 * and disabling the meta box removes it.
381 387 *
388 + * Static: besides the metabox save hook, the REST handlers call this after
389 + * writing metasync_post_redirection_meta, because platform/Gutenberg saves
390 + * never pass through the classic nonce-gated save path.
391 + *
382 392 * @param int $post_id The post being saved.
383 393 * @param array $meta Sanitized meta box values (enable/type/url).
384 394 */
385 - private function sync_post_redirect_rule($post_id, $meta)
395 + /**
396 + * Whether a redirection row was created by the per-post Redirection meta box.
397 + *
398 + * @param object $row Row from the redirections table.
399 + * @return bool True for rows this meta box owns.
400 + */
401 + public static function owns_redirect_row($row)
386 402 {
403 + // A row missing the column, or carrying a non-string, is not ours.
404 + // isset() on a non-object is simply false, so no type check is needed.
405 + if (!isset($row->description) || !is_string($row->description)) {
406 + return false;
407 + }
408 +
409 + return strncmp(
410 + $row->description,
411 + self::POST_REDIRECT_DESCRIPTION_PREFIX,
412 + strlen(self::POST_REDIRECT_DESCRIPTION_PREFIX)
413 + ) === 0;
414 + }
415 +
416 + public static function sync_post_redirect_rule($post_id, $meta)
417 + {
387 418 // Never act on autosaves or revisions — their permalink isn't the public URL.
388 419 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id) || !class_exists('Metasync_Redirection_Database')) {
389 420 return;
390 421 }
391 422
423 + // Redirection feature switched off. Return before touching the table so a
424 + // disabled feature neither creates new rows nor deletes the row a post
425 + // already owns — re-enabling must restore the redirect exactly as it was.
426 + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::REDIRECTION)) {
427 + return;
428 + }
429 +
392 430 $db = new Metasync_Redirection_Database();
393 431 $existing_row_id = (int) get_post_meta($post_id, '_metasync_post_redirect_row_id', true);
394 432
395 - $enabled = isset($meta['enable']) && $meta['enable'] === 'true';
433 + // The metabox posts the literal string 'true', but REST/platform
434 + // writes pass through sanitize_text_field() and may arrive as '1',
435 + // 'on', or boolean true. Normalize so every spelling behaves alike.
436 + $enabled = isset($meta['enable']) && in_array($meta['enable'], ['true', '1', 1, true, 'on', 'yes'], true);
396 437 $type = isset($meta['type']) ? (int) $meta['type'] : 301;
397 438 $dest = isset($meta['url']) ? trim((string) $meta['url']) : '';
398 439 $is_gone = in_array($type, [410, 451], true);
399 440
@@ -414,8 +455,38 @@
414 455 }
415 456 $parsed = wp_parse_url($permalink);
416 457 $source_path = isset($parsed['path']) ? $parsed['path'] : '/';
417 458
459 + // Same loop guard as the admin form — creating or updating this post's
460 + // rule must not wire its permalink into a cycle (e.g. another rule
461 + // already redirects the chosen destination back here). On a loop we
462 + // keep the previously stored rule and skip the write. The guard above
463 + // already returned when a non-410/451 type had an empty destination,
464 + // so $dest is known non-empty here.
465 + if (!$is_gone) {
466 + // Destination guard — same checks as the admin form and MCP tools.
467 + // Backslashes and protocol-relative hosts bypass host validation in
468 + // browsers while looking internal to wp_validate_redirect. On a
469 + // bad destination we keep the previously stored rule and skip the
470 + // write, same skip-write semantics as the loop guard below.
471 + if (!class_exists('Metasync_Redirection_Validator')) {
472 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection-validator.php';
473 + }
474 + if (!Metasync_Redirection_Validator::is_safe_destination_syntax($dest)
475 + || (!get_option('metasync_allow_external_redirects', 0) && wp_validate_redirect($dest, '') !== $dest)) {
476 + return;
477 + }
478 +
479 + if (!class_exists('Metasync_Redirection')) {
480 + require_once dirname(__FILE__, 2) . '/redirections/class-metasync-redirection.php';
481 + }
482 + $db_ref = $db;
483 + $redirection_helper = new Metasync_Redirection($db_ref);
484 + if ($redirection_helper->validate_no_loop($source_path, $dest) !== null) {
485 + return;
486 + }
487 + }
488 +
418 489 $data = [
419 490 'sources_from' => serialize([$source_path => 'exact']),
420 491 'url_redirect_to' => $is_gone ? '' : $dest,
421 492 'http_code' => $type,
@@ -421,9 +492,9 @@
421 492 'http_code' => $type,
422 493 'status' => 'active',
423 494 'pattern_type' => 'exact',
424 495 'regex_pattern' => null,
425 - 'description' => sprintf('Post redirection meta box for post #%d', $post_id),
496 + 'description' => self::POST_REDIRECT_DESCRIPTION_PREFIX . $post_id,
426 497 ];
427 498
428 499 // Update the row we own if it still exists; otherwise create it and remember its id.
429 500 if ($existing_row_id > 0 && $db->find($existing_row_id)) {
@@ -440,9 +511,9 @@
440 511 {
441 512 if (!current_user_can('edit_post', $post_id))
442 513 return;
443 514
444 - $post_data = metasync_sanitize_input_array($_POST);
515 + $post_data = metasync_sanitize_input_array($_POST); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- wp_verify_nonce() runs before any meta write
445 516 // Check for new field name first, then old for backward compatibility
446 517 $field_name = isset($post_data['post_canonical_url_meta']) ? 'post_canonical_url_meta' : 'post_canonical_url_mata';
447 518
448 519 if (!isset($post_data['metasync_post_canonical_nonce'], $post_data[$field_name]) || !wp_verify_nonce($post_data['metasync_post_canonical_nonce'], 'metasync_post_canonical_nonce'))
@@ -586,24 +657,42 @@
586 657 {
587 658 global $post;
588 659 $seo_title = get_post_meta($post->ID, '_metasync_seo_title', true);
589 660 $seo_desc = get_post_meta($post->ID, '_metasync_seo_desc', true);
590 - $otto_title = get_post_meta($post->ID, '_metasync_otto_title', true);
591 - $otto_desc = get_post_meta($post->ID, '_metasync_otto_description', true);
592 661
662 + // Values brought in by the external SEO importer apply only where OTTO
663 + // has no suggestion, so show them as the placeholder in exactly that
664 + // case. Without this the field looks empty after an import and the user
665 + // concludes nothing was imported. Which tier fills the placeholder is
666 + // Metasync_Seo_Precedence's call, not this screen's.
667 + $title_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_TITLE);
668 + $desc_fallback = Metasync_Seo_Precedence::fallback($post->ID, Metasync_Seo_Precedence::FIELD_DESCRIPTION);
669 +
670 + $title_placeholder = $title_fallback['value'];
671 + $desc_placeholder = $desc_fallback['value'];
672 +
673 + $title_is_imported = $title_fallback['source'] === Metasync_Seo_Precedence::SOURCE_IMPORTED;
674 +
593 675 wp_nonce_field('metasync_seo_meta_nonce', 'metasync_seo_meta_nonce');
594 676 ?>
595 677 <p style="color: #666; margin-bottom: 12px;">
678 + <?php /* translators: %s: OTTO name (whitelabel). */ ?>
596 679 <?php printf(esc_html__('Set the SEO Title and Meta Description for this post. Leave the field blank to use %s suggestion.', 'metasync'), esc_html(Metasync::get_whitelabel_otto_name())); ?>
597 680 </p>
681 + <?php if ($title_is_imported) : ?>
682 + <p style="color: #666; margin-bottom: 12px;">
683 + <?php /* translators: %s: OTTO name (whitelabel). */ ?>
684 + <?php printf(esc_html__('The greyed-out value below was imported from another SEO plugin. It is used only until %s has a suggestion for this page.', 'metasync'), esc_html(Metasync::get_whitelabel_otto_name())); ?>
685 + </p>
686 + <?php endif; ?>
598 687 <table class="form-table" style="margin: 0;">
599 688 <tr>
600 689 <th scope="row"><label for="metasync_seo_title"><?php esc_html_e('SEO Title', 'metasync'); ?></label></th>
601 - <td><input type="text" id="metasync_seo_title" name="metasync_seo_title" value="<?php echo esc_attr($seo_title); ?>" class="large-text" placeholder="<?php echo esc_attr($otto_title); ?>" /></td>
690 + <td><input type="text" id="metasync_seo_title" name="metasync_seo_title" value="<?php echo esc_attr($seo_title); ?>" class="large-text" placeholder="<?php echo esc_attr($title_placeholder); ?>" /></td>
602 691 </tr>
603 692 <tr>
604 693 <th scope="row"><label for="metasync_seo_desc"><?php esc_html_e('Meta Description', 'metasync'); ?></label></th>
605 - <td><textarea id="metasync_seo_desc" name="metasync_seo_desc" class="large-text" rows="3" placeholder="<?php echo esc_attr($otto_desc); ?>"><?php echo esc_textarea($seo_desc); ?></textarea></td>
694 + <td><textarea id="metasync_seo_desc" name="metasync_seo_desc" class="large-text" rows="3" placeholder="<?php echo esc_attr($desc_placeholder); ?>"><?php echo esc_textarea($seo_desc); ?></textarea></td>
606 695 </tr>
607 696 </table>
608 697 <?php
609 698 }
@@ -632,11 +721,13 @@
632 721 return;
633 722 }
634 723
635 724 if (isset($_POST['metasync_seo_title'])) {
636 - $seo_title = sanitize_text_field($_POST['metasync_seo_title']);
725 + $seo_title = sanitize_text_field(wp_unslash($_POST['metasync_seo_title']));
637 726 if (!empty($seo_title)) {
638 - update_post_meta($post_id, '_metasync_seo_title', $seo_title);
727 + // update_post_meta() unslashes again, so re-slash to keep
728 + // literal backslashes and quotes in the stored value.
729 + update_post_meta($post_id, '_metasync_seo_title', wp_slash($seo_title));
639 730 } else {
640 731 delete_post_meta($post_id, '_metasync_seo_title');
641 732 }
642 733 }
@@ -641,11 +732,11 @@
641 732 }
642 733 }
643 734
644 735 if (isset($_POST['metasync_seo_desc'])) {
645 - $seo_desc = sanitize_textarea_field($_POST['metasync_seo_desc']);
736 + $seo_desc = sanitize_textarea_field(wp_unslash($_POST['metasync_seo_desc']));
646 737 if (!empty($seo_desc)) {
647 - update_post_meta($post_id, '_metasync_seo_desc', $seo_desc);
738 + update_post_meta($post_id, '_metasync_seo_desc', wp_slash($seo_desc));
648 739 } else {
649 740 delete_post_meta($post_id, '_metasync_seo_desc');
650 741 }
651 742 }