| @@ -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 | { |
| @@ -29,8 +40,9 @@ | ||
| 29 | 40 | add_action('save_post', [$this, 'advance_robots_meta_box_save']); |
| 30 | 41 | add_action('save_post', [$this, 'redirection_meta_box_save']); |
| 31 | 42 | add_action('save_post', [$this, 'canonical_meta_box_save']); |
| 32 | 43 | add_action('save_post', [$this, 'video_sitemap_meta_box_save']); |
| 44 | + add_action('save_post', [$this, 'seo_meta_box_save']); | |
| 33 | 45 | } |
| 34 | 46 | |
| 35 | 47 | public function add_post_meta_data() |
| 36 | 48 | { |
| @@ -43,25 +55,50 @@ | ||
| 43 | 55 | $general_settings = Metasync::get_option('general', []); |
| 44 | 56 | $post_types = array_values(get_post_types(['public' => true], 'names')); |
| 45 | 57 | $post_types = array_diff($post_types, ['attachment']); |
| 46 | 58 | |
| 47 | - // Only add meta boxes if not disabled in settings | |
| 48 | - if (empty($general_settings['disable_common_robots_metabox'])) { | |
| 59 | + // Resolve the current post ID in admin_init context: $_GET['post'] on the | |
| 60 | + // edit screen, $_POST['post_ID'] on save. Mirrors the sidebar enqueue. | |
| 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 | |
| 62 | + | |
| 63 | + // LPS / custom-HTML pages carry their own baked SEO served before wp_head, | |
| 64 | + // so the editable SEO fields here do nothing and mislead editors. | |
| 65 | + // otto_pixel.php (where metasync_is_custom_or_lps_page lives) is only | |
| 66 | + // required conditionally, so the function_exists guard is needed at runtime. | |
| 67 | + $is_custom_or_lps_page = function_exists('metasync_is_custom_or_lps_page') && $post_id > 0 && metasync_is_custom_or_lps_page($post_id); | |
| 68 | + | |
| 69 | + // Only add meta boxes if not disabled in settings. | |
| 70 | + // On LPS / custom-HTML pages all of these MetaSync boxes are suppressed: they do | |
| 71 | + // nothing useful there (their output goes into wp_head, which never runs for these | |
| 72 | + // pages — serve_raw_html() exits first — and a redirect would only break the | |
| 73 | + // published page). The single SEO read-only notice below carries the messaging. | |
| 74 | + if (empty($general_settings['disable_common_robots_metabox']) && !$is_custom_or_lps_page) { | |
| 49 | 75 | add_meta_box('common-robots-meta', "Common Robots Meta by $plugin_name", [$this, 'common_robots_meta_box_display'], $post_types, 'normal', 'default'); |
| 50 | 76 | } |
| 51 | 77 | |
| 52 | - if (empty($general_settings['disable_advance_robots_metabox'])) { | |
| 78 | + if (empty($general_settings['disable_advance_robots_metabox']) && !$is_custom_or_lps_page) { | |
| 53 | 79 | add_meta_box('advance-robots-meta', "Advance Robots Meta by $plugin_name", [$this, 'advance_robots_meta_box_display'], $post_types, 'normal', 'default'); |
| 54 | 80 | } |
| 55 | 81 | |
| 56 | - if (empty($general_settings['disable_redirection_metabox'])) { | |
| 82 | + if (empty($general_settings['disable_redirection_metabox']) && !$is_custom_or_lps_page) { | |
| 57 | 83 | add_meta_box('post-redirection-meta', "Redirection by $plugin_name", [$this, 'post_redirection_display'], $post_types, 'normal', 'default'); |
| 58 | 84 | } |
| 59 | 85 | |
| 60 | - if (empty($general_settings['disable_canonical_metabox'])) { | |
| 86 | + if (empty($general_settings['disable_canonical_metabox']) && !$is_custom_or_lps_page) { | |
| 61 | 87 | add_meta_box('post-canonical-meta', "Canonical by $plugin_name", [$this, 'post_canonical_display'], $post_types, 'normal', 'default'); |
| 62 | 88 | } |
| 63 | 89 | |
| 90 | + if (empty($general_settings['disable_seo_metabox'])) { | |
| 91 | + if ($is_custom_or_lps_page) { | |
| 92 | + // Replace the editable SEO box with a read-only notice on LPS pages. | |
| 93 | + // __back_compat_meta_box hides it in Gutenberg (the block-editor sidebar | |
| 94 | + // already renders the same notice), so it shows only in the Classic editor. | |
| 95 | + add_meta_box('metasync-seo-lps-notice', "SEO by $plugin_name", [$this, 'seo_lps_notice_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]); | |
| 96 | + } else { | |
| 97 | + add_meta_box('metasync-seo-meta', "SEO by $plugin_name", [$this, 'seo_meta_box_display'], $post_types, 'normal', 'default', ['__back_compat_meta_box' => true]); | |
| 98 | + } | |
| 99 | + } | |
| 100 | + | |
| 64 | 101 | // Video Sitemap meta box — only if video sitemap is enabled |
| 65 | 102 | $video_settings = get_option('metasync_video_sitemap_settings', []); |
| 66 | 103 | if (!empty($video_settings['enabled'])) { |
| 67 | 104 | $video_post_types = !empty($video_settings['post_types']) ? (array) $video_settings['post_types'] : ['post', 'page']; |
| @@ -86,16 +123,8 @@ | ||
| 86 | 123 | wp_nonce_field('metasync_common_robots_nonce', 'metasync_common_robots_nonce'); |
| 87 | 124 | ?> |
| 88 | 125 | <ul class="checkbox-list"> |
| 89 | 126 | <li> |
| 90 | - <input type="checkbox" name="common_robots_meta[index]" id="robots_common1" value="index" <?php isset($common_robots['index']) ? checked('index', $common_robots['index']) : '' ?>> | |
| 91 | - <label for="robots_common1">Index </br> | |
| 92 | - <span class="description"> | |
| 93 | - <span>Search engines to index and show these pages in the search results.</span> | |
| 94 | - </span> | |
| 95 | - </label> | |
| 96 | - </li> | |
| 97 | - <li> | |
| 98 | 127 | <input type="checkbox" name="common_robots_meta[noindex]" id="robots_common2" value="noindex" <?php isset($common_robots['noindex']) ? checked('noindex', $common_robots['noindex']) : '' ?>> |
| 99 | 128 | <label for="robots_common2">No Index </br> |
| 100 | 129 | <span class="description"> |
| 101 | 130 | <span>Prevents search engines from indexing and displaying these pages in search results.</span> |
| @@ -178,9 +207,9 @@ | ||
| 178 | 207 | <li> |
| 179 | 208 | <label for="advanced_robots_image"> |
| 180 | 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)) ?>> |
| 181 | 210 | Image Preview </br> |
| 182 | - <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"> | |
| 183 | 212 | <option value="large" <?php selected(esc_attr($image_advance_robots_length), 'large'); ?>>Large</option> |
| 184 | 213 | <option value="standard" <?php selected(esc_attr($image_advance_robots_length), 'standard'); ?>>Standard</option> |
| 185 | 214 | <option value="none" <?php selected(esc_attr($image_advance_robots_length), 'none'); ?>>None</option> |
| 186 | 215 | </select> |
| @@ -227,22 +256,31 @@ | ||
| 227 | 256 | public function post_canonical_display() |
| 228 | 257 | { |
| 229 | 258 | global $post; |
| 230 | 259 | |
| 231 | - $post_canonical = get_post_meta($post->ID, 'meta_canonical', true) ?? ''; | |
| 232 | - // Fix legacy array values stored by sanitize_array() | |
| 233 | - if (is_array($post_canonical)) { | |
| 234 | - $post_canonical = reset($post_canonical) ?: ''; | |
| 235 | - // Repair the stored value so it won't recur | |
| 236 | - if (!empty($post_canonical)) { | |
| 237 | - update_post_meta($post->ID, 'meta_canonical', (string) $post_canonical); | |
| 260 | + $raw_canonical = get_post_meta($post->ID, 'meta_canonical', true); | |
| 261 | + $post_canonical = Metasync_Canonical_Sanitizer::sanitize($raw_canonical); | |
| 262 | + | |
| 263 | + // Repair legacy array rows and rows corrupted to the literal "Array" | |
| 264 | + // A nested array here previously became the string "Array" | |
| 265 | + // via a single reset() + (string) cast, permanently corrupting the row. | |
| 266 | + if (is_array($raw_canonical) || Metasync_Canonical_Sanitizer::is_corrupted($raw_canonical)) { | |
| 267 | + if ($post_canonical !== '') { | |
| 268 | + update_post_meta($post->ID, 'meta_canonical', $post_canonical); | |
| 269 | + } else { | |
| 270 | + delete_post_meta($post->ID, 'meta_canonical'); | |
| 238 | 271 | } |
| 272 | + } elseif ($post_canonical === '' && is_string($raw_canonical) && trim($raw_canonical) !== '') { | |
| 273 | + // Stored value the validator doesn't recognize (and is not a | |
| 274 | + // corruption artifact): show it as-is so a routine post save | |
| 275 | + // doesn't silently wipe it. It is not emitted while invalid. | |
| 276 | + $post_canonical = trim($raw_canonical); | |
| 239 | 277 | } |
| 240 | 278 | wp_nonce_field('metasync_post_canonical_nonce', 'metasync_post_canonical_nonce'); |
| 241 | 279 | ?> |
| 242 | 280 | <ul> |
| 243 | 281 | <li> Canonical URL: |
| 244 | - <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); ?>"> | |
| 245 | 283 | </li> |
| 246 | 284 | </ul> |
| 247 | 285 | <?php |
| 248 | 286 | } |
| @@ -251,9 +289,9 @@ | ||
| 251 | 289 | { |
| 252 | 290 | if (!current_user_can('edit_post', $post_id)) |
| 253 | 291 | return; |
| 254 | 292 | |
| 255 | - // WP-197: When saving from Gutenberg (REST API context) and the sidebar | |
| 293 | + // When saving from Gutenberg (REST API context) and the sidebar | |
| 256 | 294 | // JSON exists, skip — the sidebar auto-save is the source of truth. |
| 257 | 295 | // Classic editor form submits (non-REST) always proceed so classic-only |
| 258 | 296 | // users can still save via the meta boxes. |
| 259 | 297 | if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) { |
| @@ -259,13 +297,16 @@ | ||
| 259 | 297 | if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) { |
| 260 | 298 | return; |
| 261 | 299 | } |
| 262 | 300 | |
| 263 | - $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 | |
| 264 | 302 | // Check for new field name first, then old for backward compatibility |
| 265 | 303 | $field_name = isset($post_data['common_robots_meta']) ? 'common_robots_meta' : 'common_robots_mata'; |
| 266 | 304 | |
| 267 | - 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')) | |
| 268 | 309 | return; |
| 269 | 310 | |
| 270 | 311 | $old_common_robots = get_post_meta($post_id, 'metasync_common_robots', true); |
| 271 | 312 | |
| @@ -284,15 +325,15 @@ | ||
| 284 | 325 | { |
| 285 | 326 | if (!current_user_can('edit_post', $post_id)) |
| 286 | 327 | return; |
| 287 | 328 | |
| 288 | - // WP-197: When saving from Gutenberg (REST API context) and the sidebar | |
| 329 | + // When saving from Gutenberg (REST API context) and the sidebar | |
| 289 | 330 | // JSON exists, skip — the sidebar auto-save is the source of truth. |
| 290 | 331 | if (defined('REST_REQUEST') && REST_REQUEST && !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))) { |
| 291 | 332 | return; |
| 292 | 333 | } |
| 293 | 334 | |
| 294 | - $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 | |
| 295 | 336 | // Check for new field name first, then old for backward compatibility |
| 296 | 337 | $field_name = isset($post_data['advanced_robots_meta']) ? 'advanced_robots_meta' : 'advanced_robots_mata'; |
| 297 | 338 | |
| 298 | 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')) |
| @@ -312,9 +353,9 @@ | ||
| 312 | 353 | { |
| 313 | 354 | if (!current_user_can('edit_post', $post_id)) |
| 314 | 355 | return; |
| 315 | 356 | |
| 316 | - $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 | |
| 317 | 358 | // Check for new field name first, then old for backward compatibility |
| 318 | 359 | $field_name = isset($post_data['post_redirect_meta']) ? 'post_redirect_meta' : 'post_redirect_mata'; |
| 319 | 360 | |
| 320 | 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')) |
| @@ -323,20 +364,156 @@ | ||
| 323 | 364 | $old_post_redirection_meta = get_post_meta($post_id, 'metasync_post_redirection_meta', true); |
| 324 | 365 | |
| 325 | 366 | $post_redirection_meta = $this->common->sanitize_array($post_data[$field_name]); |
| 326 | 367 | |
| 368 | + // Persist the meta box UI state (checkbox / type / destination) so the form repopulates. | |
| 327 | 369 | if (isset($post_redirection_meta['enable'])) |
| 328 | 370 | update_post_meta($post_id, 'metasync_post_redirection_meta', $post_redirection_meta); |
| 329 | 371 | else |
| 330 | 372 | delete_post_meta($post_id, 'metasync_post_redirection_meta', $old_post_redirection_meta); |
| 373 | + | |
| 374 | + // sync the actual redirect into the shared redirection table (exact | |
| 375 | + // match) using the same DB layer as ?page=...-redirections&action=add. This | |
| 376 | + // keeps a single redirect code path — the existing handle_template_redirect() | |
| 377 | + // serves it — instead of a duplicate per-post frontend handler. | |
| 378 | + self::sync_post_redirect_rule($post_id, is_array($post_redirection_meta) ? $post_redirection_meta : []); | |
| 331 | 379 | } |
| 332 | 380 | |
| 381 | + /** | |
| 382 | + * Create, update, or remove a row in the shared redirection table so a redirect | |
| 383 | + * configured on the post-edit "Redirection" meta box is served by the same engine | |
| 384 | + * as manually-added redirections (exact match on the post's own URL). The row id we | |
| 385 | + * create is remembered in post meta so repeated saves update — never duplicate — it, | |
| 386 | + * and disabling the meta box removes it. | |
| 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 | + * | |
| 392 | + * @param int $post_id The post being saved. | |
| 393 | + * @param array $meta Sanitized meta box values (enable/type/url). | |
| 394 | + */ | |
| 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) | |
| 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 | + { | |
| 418 | + // Never act on autosaves or revisions — their permalink isn't the public URL. | |
| 419 | + if (wp_is_post_revision($post_id) || wp_is_post_autosave($post_id) || !class_exists('Metasync_Redirection_Database')) { | |
| 420 | + return; | |
| 421 | + } | |
| 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 | + | |
| 430 | + $db = new Metasync_Redirection_Database(); | |
| 431 | + $existing_row_id = (int) get_post_meta($post_id, '_metasync_post_redirect_row_id', true); | |
| 432 | + | |
| 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); | |
| 437 | + $type = isset($meta['type']) ? (int) $meta['type'] : 301; | |
| 438 | + $dest = isset($meta['url']) ? trim((string) $meta['url']) : ''; | |
| 439 | + $is_gone = in_array($type, [410, 451], true); | |
| 440 | + | |
| 441 | + // Disabled, or a redirect type that needs a destination but has none → drop any | |
| 442 | + // row we previously created for this post. | |
| 443 | + if (!$enabled || (!$is_gone && $dest === '')) { | |
| 444 | + if ($existing_row_id > 0) { | |
| 445 | + $db->delete([$existing_row_id]); | |
| 446 | + delete_post_meta($post_id, '_metasync_post_redirect_row_id'); | |
| 447 | + } | |
| 448 | + return; | |
| 449 | + } | |
| 450 | + | |
| 451 | + // Source = this post's own relative URL; visiting it triggers the redirect. | |
| 452 | + $permalink = get_permalink($post_id); | |
| 453 | + if (!$permalink) { | |
| 454 | + return; | |
| 455 | + } | |
| 456 | + $parsed = wp_parse_url($permalink); | |
| 457 | + $source_path = isset($parsed['path']) ? $parsed['path'] : '/'; | |
| 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 | + | |
| 489 | + $data = [ | |
| 490 | + 'sources_from' => serialize([$source_path => 'exact']), | |
| 491 | + 'url_redirect_to' => $is_gone ? '' : $dest, | |
| 492 | + 'http_code' => $type, | |
| 493 | + 'status' => 'active', | |
| 494 | + 'pattern_type' => 'exact', | |
| 495 | + 'regex_pattern' => null, | |
| 496 | + 'description' => self::POST_REDIRECT_DESCRIPTION_PREFIX . $post_id, | |
| 497 | + ]; | |
| 498 | + | |
| 499 | + // Update the row we own if it still exists; otherwise create it and remember its id. | |
| 500 | + if ($existing_row_id > 0 && $db->find($existing_row_id)) { | |
| 501 | + $db->update($data, (string) $existing_row_id); | |
| 502 | + } else { | |
| 503 | + $new_id = $db->add($data); | |
| 504 | + if ($new_id) { | |
| 505 | + update_post_meta($post_id, '_metasync_post_redirect_row_id', (int) $new_id); | |
| 506 | + } | |
| 507 | + } | |
| 508 | + } | |
| 509 | + | |
| 333 | 510 | public function canonical_meta_box_save($post_id) |
| 334 | 511 | { |
| 335 | 512 | if (!current_user_can('edit_post', $post_id)) |
| 336 | 513 | return; |
| 337 | 514 | |
| 338 | - $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 | |
| 339 | 516 | // Check for new field name first, then old for backward compatibility |
| 340 | 517 | $field_name = isset($post_data['post_canonical_url_meta']) ? 'post_canonical_url_meta' : 'post_canonical_url_mata'; |
| 341 | 518 | |
| 342 | 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')) |
| @@ -343,14 +520,13 @@ | ||
| 343 | 520 | return; |
| 344 | 521 | |
| 345 | 522 | $old_post_canonical_meta = get_post_meta($post_id, 'meta_canonical', true); |
| 346 | 523 | |
| 347 | - // Canonical is a URL string — sanitize as URL, not array | |
| 348 | - $raw_value = $post_data[$field_name]; | |
| 349 | - if (is_array($raw_value)) { | |
| 350 | - $raw_value = reset($raw_value); // extract first element if array | |
| 351 | - } | |
| 352 | - $post_canonical_meta = esc_url_raw(trim((string) $raw_value)); | |
| 524 | + // Canonical is a URL string — extract the first scalar from nested | |
| 525 | + // arrays, normalize schemeless input, and validate. Unusable input | |
| 526 | + // (including the corruption literal "Array") clears the meta instead | |
| 527 | + // of persisting garbage like "http://Array". | |
| 528 | + $post_canonical_meta = Metasync_Canonical_Sanitizer::sanitize_for_save($post_data[$field_name]); | |
| 353 | 529 | |
| 354 | 530 | if (!empty($post_canonical_meta)) |
| 355 | 531 | update_post_meta($post_id, 'meta_canonical', $post_canonical_meta); |
| 356 | 532 | else |
| @@ -455,8 +631,114 @@ | ||
| 455 | 631 | if (!empty($value)) { |
| 456 | 632 | update_post_meta($post_id, $meta_key, $value); |
| 457 | 633 | } else { |
| 458 | 634 | delete_post_meta($post_id, $meta_key); |
| 635 | + } | |
| 636 | + } | |
| 637 | + } | |
| 638 | + | |
| 639 | + /** | |
| 640 | + * Display a read-only notice in place of the SEO meta box on LPS / custom-HTML | |
| 641 | + * pages, whose SEO is baked into their own HTML bundle and managed by WebStudio. | |
| 642 | + * No inputs, nonce, or editable fields are rendered. | |
| 643 | + */ | |
| 644 | + public function seo_lps_notice_meta_box_display() | |
| 645 | + { | |
| 646 | + echo '<p><strong>The SEO for this page is managed by WebStudio.</strong></p>'; | |
| 647 | + } | |
| 648 | + | |
| 649 | + /** | |
| 650 | + * Display the SEO meta box (SEO Title & Meta Description) in the Classic editor. | |
| 651 | + * | |
| 652 | + * Reads/writes the same _metasync_seo_title / _metasync_seo_desc keys used by the | |
| 653 | + * Gutenberg sidebar. OTTO values are used only as placeholder hints so the | |
| 654 | + * custom-value > OTTO priority is preserved. | |
| 655 | + */ | |
| 656 | + public function seo_meta_box_display() | |
| 657 | + { | |
| 658 | + global $post; | |
| 659 | + $seo_title = get_post_meta($post->ID, '_metasync_seo_title', true); | |
| 660 | + $seo_desc = get_post_meta($post->ID, '_metasync_seo_desc', true); | |
| 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 | + | |
| 675 | + wp_nonce_field('metasync_seo_meta_nonce', 'metasync_seo_meta_nonce'); | |
| 676 | + ?> | |
| 677 | + <p style="color: #666; margin-bottom: 12px;"> | |
| 678 | + <?php /* translators: %s: OTTO name (whitelabel). */ ?> | |
| 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())); ?> | |
| 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; ?> | |
| 687 | + <table class="form-table" style="margin: 0;"> | |
| 688 | + <tr> | |
| 689 | + <th scope="row"><label for="metasync_seo_title"><?php esc_html_e('SEO Title', 'metasync'); ?></label></th> | |
| 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> | |
| 691 | + </tr> | |
| 692 | + <tr> | |
| 693 | + <th scope="row"><label for="metasync_seo_desc"><?php esc_html_e('Meta Description', 'metasync'); ?></label></th> | |
| 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> | |
| 695 | + </tr> | |
| 696 | + </table> | |
| 697 | + <?php | |
| 698 | + } | |
| 699 | + | |
| 700 | + /** | |
| 701 | + * Save the SEO meta box data. | |
| 702 | + * | |
| 703 | + * The nonce field is never present in Gutenberg REST saves, so the nonce check | |
| 704 | + * alone prevents this handler from running in that context — leaving the sidebar | |
| 705 | + * as the source of truth there. | |
| 706 | + * | |
| 707 | + * @param int $post_id The post ID. | |
| 708 | + */ | |
| 709 | + public function seo_meta_box_save($post_id) | |
| 710 | + { | |
| 711 | + if (!isset($_POST['metasync_seo_meta_nonce']) || | |
| 712 | + !wp_verify_nonce($_POST['metasync_seo_meta_nonce'], 'metasync_seo_meta_nonce')) { | |
| 713 | + return; | |
| 714 | + } | |
| 715 | + | |
| 716 | + if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) { | |
| 717 | + return; | |
| 718 | + } | |
| 719 | + | |
| 720 | + if (!current_user_can('edit_post', $post_id)) { | |
| 721 | + return; | |
| 722 | + } | |
| 723 | + | |
| 724 | + if (isset($_POST['metasync_seo_title'])) { | |
| 725 | + $seo_title = sanitize_text_field(wp_unslash($_POST['metasync_seo_title'])); | |
| 726 | + if (!empty($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)); | |
| 730 | + } else { | |
| 731 | + delete_post_meta($post_id, '_metasync_seo_title'); | |
| 732 | + } | |
| 733 | + } | |
| 734 | + | |
| 735 | + if (isset($_POST['metasync_seo_desc'])) { | |
| 736 | + $seo_desc = sanitize_textarea_field(wp_unslash($_POST['metasync_seo_desc'])); | |
| 737 | + if (!empty($seo_desc)) { | |
| 738 | + update_post_meta($post_id, '_metasync_seo_desc', wp_slash($seo_desc)); | |
| 739 | + } else { | |
| 740 | + delete_post_meta($post_id, '_metasync_seo_desc'); | |
| 459 | 741 | } |
| 460 | 742 | } |
| 461 | 743 | } |
| 462 | 744 | } |