PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
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 +113 -17 2.6.21 → 2.7.1 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
@@ -286,9 +297,9 @@
286 297 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
287 298 return;
288 299 }
289 300
290 - $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
291 302 // Check for new field name first, then old for backward compatibility
292 303 $field_name = isset($post_data['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata';
293 304
294 305 // Unchecked checkboxes are omitted from the request. Require the nonce,
@@ -320,9 +331,9 @@
320 331 if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) {
321 332 return;
322 333 }
323 334
324 - $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
325 336 // Check for new field name first, then old for backward compatibility
326 337 $field_name = isset($post_data['advanced_robots_meta']) ? 'advanced_robots_meta' : 'advanced_robots_mata';
327 338
328 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'))
@@ -342,9 +353,9 @@
342 353 {
343 354 if (!current_user_can('edit_post', $post_id))
344 355 return;
345 356
346 - $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
347 358 // Check for new field name first, then old for backward compatibility
348 359 $field_name = isset($post_data['post_redirect_meta']) ? 'post_redirect_meta' : 'post_redirect_mata';
349 360
350 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'))
@@ -363,9 +374,9 @@
363 374 // sync the actual redirect into the shared redirection table (exact
364 375 // match) using the same DB layer as ?page=...-redirections&action=add. This
365 376 // keeps a single redirect code path — the existing handle_template_redirect()
366 377 // serves it — instead of a duplicate per-post frontend handler.
367 - $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 : []);
368 379 }
369 380
370 381 /**
371 382 * Create, update, or remove a row in the shared redirection table so a redirect
@@ -373,22 +384,57 @@
373 384 * as manually-added redirections (exact match on the post's own URL). The row id we
374 385 * create is remembered in post meta so repeated saves update — never duplicate — it,
375 386 * and disabling the meta box removes it.
376 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 + *
377 392 * @param int $post_id The post being saved.
378 393 * @param array $meta Sanitized meta box values (enable/type/url).
379 394 */
380 - 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)
381 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 + {
382 418 // Never act on autosaves or revisions — their permalink isn't the public URL.
383 419 if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id) || !class_exists('Metasync_Redirection_Database')) {
384 420 return;
385 421 }
386 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 +
387 430 $db = new Metasync_Redirection_Database();
388 431 $existing_row_id = (int) get_post_meta($post_id, '_metasync_post_redirect_row_id', true);
389 432
390 - $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);
391 437 $type = isset($meta['type']) ? (int) $meta['type'] : 301;
392 438 $dest = isset($meta['url']) ? trim((string) $meta['url']) : '';
393 439 $is_gone = in_array($type, [410, 451], true);
394 440
@@ -409,8 +455,38 @@
409 455 }
410 456 $parsed = wp_parse_url($permalink);
411 457 $source_path = isset($parsed['path']) ? $parsed['path'] : '/';
412 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 +
413 489 $data = [
414 490 'sources_from' => serialize([$source_path => 'exact']),
415 491 'url_redirect_to' => $is_gone ? '' : $dest,
416 492 'http_code' => $type,
@@ -416,9 +492,9 @@
416 492 'http_code' => $type,
417 493 'status' => 'active',
418 494 'pattern_type' => 'exact',
419 495 'regex_pattern' => null,
420 - 'description' => sprintf('Post redirection meta box for post #%d', $post_id),
496 + 'description' => self::POST_REDIRECT_DESCRIPTION_PREFIX . $post_id,
421 497 ];
422 498
423 499 // Update the row we own if it still exists; otherwise create it and remember its id.
424 500 if ($existing_row_id > 0 && $db->find($existing_row_id)) {
@@ -435,9 +511,9 @@
435 511 {
436 512 if (!current_user_can('edit_post', $post_id))
437 513 return;
438 514
439 - $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
440 516 // Check for new field name first, then old for backward compatibility
441 517 $field_name = isset($post_data['post_canonical_url_meta']) ? 'post_canonical_url_meta' : 'post_canonical_url_mata';
442 518
443 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'))
@@ -581,24 +657,42 @@
581 657 {
582 658 global $post;
583 659 $seo_title = get_post_meta($post->ID, '_metasync_seo_title', true);
584 660 $seo_desc = get_post_meta($post->ID, '_metasync_seo_desc', true);
585 - $otto_title = get_post_meta($post->ID, '_metasync_otto_title', true);
586 - $otto_desc = get_post_meta($post->ID, '_metasync_otto_description', true);
587 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 +
588 675 wp_nonce_field('metasync_seo_meta_nonce', 'metasync_seo_meta_nonce');
589 676 ?>
590 677 <p style="color: #666; margin-bottom: 12px;">
678 + <?php /* translators: %s: OTTO name (whitelabel). */ ?>
591 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())); ?>
592 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; ?>
593 687 <table class="form-table" style="margin: 0;">
594 688 <tr>
595 689 <th scope="row"><label for="metasync_seo_title"><?php esc_html_e('SEO Title', 'metasync'); ?></label></th>
596 - <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>
597 691 </tr>
598 692 <tr>
599 693 <th scope="row"><label for="metasync_seo_desc"><?php esc_html_e('Meta Description', 'metasync'); ?></label></th>
600 - <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>
601 695 </tr>
602 696 </table>
603 697 <?php
604 698 }
@@ -627,11 +721,13 @@
627 721 return;
628 722 }
629 723
630 724 if (isset($_POST['metasync_seo_title'])) {
631 - $seo_title = sanitize_text_field($_POST['metasync_seo_title']);
725 + $seo_title = sanitize_text_field(wp_unslash($_POST['metasync_seo_title']));
632 726 if (!empty($seo_title)) {
633 - 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));
634 730 } else {
635 731 delete_post_meta($post_id, '_metasync_seo_title');
636 732 }
637 733 }
@@ -636,11 +732,11 @@
636 732 }
637 733 }
638 734
639 735 if (isset($_POST['metasync_seo_desc'])) {
640 - $seo_desc = sanitize_textarea_field($_POST['metasync_seo_desc']);
736 + $seo_desc = sanitize_textarea_field(wp_unslash($_POST['metasync_seo_desc']));
641 737 if (!empty($seo_desc)) {
642 - update_post_meta($post_id, '_metasync_seo_desc', $seo_desc);
738 + update_post_meta($post_id, '_metasync_seo_desc', wp_slash($seo_desc));
643 739 } else {
644 740 delete_post_meta($post_id, '_metasync_seo_desc');
645 741 }
646 742 }