| @@ -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 |
| @@ -267,9 +278,9 @@ | ||
| 267 | 278 | wp_nonce_field('metasync_post_canonical_nonce', 'metasync_post_canonical_nonce'); |
| 268 | 279 | ?> |
| 269 | 280 | <ul> |
| 270 | 281 | <li> Canonical URL: |
| 271 | - <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); ?>"> | |
| 272 | 283 | </li> |
| 273 | 284 | </ul> |
| 274 | 285 | <?php |
| 275 | 286 | } |
| @@ -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 | } |