| @@ -40,8 +40,17 @@ | ||
| 40 | 40 | */ |
| 41 | 41 | private static $instance; |
| 42 | 42 | |
| 43 | 43 | /** |
| 44 | + * Per-request memo for get_default_og_values(), keyed on post ID. See that | |
| 45 | + * method for why the defaults are worth memoizing and where the memo is | |
| 46 | + * dropped. | |
| 47 | + * | |
| 48 | + * @var array<int, array{title:string,description:string,image:string}> | |
| 49 | + */ | |
| 50 | + private static $default_og_values_memo = []; | |
| 51 | + | |
| 52 | + /** | |
| 44 | 53 | * Meta box ID |
| 45 | 54 | */ |
| 46 | 55 | const META_BOX_ID = 'metasync_opengraph_meta_box'; |
| 47 | 56 | |
| @@ -57,8 +66,35 @@ | ||
| 57 | 66 | '_metasync_twitter_description', |
| 58 | 67 | ]; |
| 59 | 68 | |
| 60 | 69 | /** |
| 70 | + * The social title keys whose meta box default is the post title itself. | |
| 71 | + * Unlike the description keys — whose pre-fill (the excerpt) is content the | |
| 72 | + * editor curates separately — these mirror the title verbatim, so a stored | |
| 73 | + * value equal to it carries no information of its own: it is the pre-fill | |
| 74 | + * snapshot, not a customization. Reads collapse such rows to '' so the live | |
| 75 | + * title (which a rename keeps fresh) applies instead. | |
| 76 | + */ | |
| 77 | + const TITLE_DEFAULTED_KEYS = [ | |
| 78 | + '_metasync_og_title', | |
| 79 | + '_metasync_twitter_title', | |
| 80 | + ]; | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * The social description keys whose meta box default is the resolved | |
| 84 | + * description (the excerpt the emitter would derive). The pre-fill persisted | |
| 85 | + * that default verbatim on save, so rows exist where these keys hold a copy | |
| 86 | + * of the excerpt as it was on save day — stale the moment the excerpt or the | |
| 87 | + * content it is generated from changes. Reads collapse such rows to '' so | |
| 88 | + * the live default applies instead. A value that differs from the default is | |
| 89 | + * untouched: only text the editor genuinely typed survives. | |
| 90 | + */ | |
| 91 | + const DESCRIPTION_DEFAULTED_KEYS = [ | |
| 92 | + '_metasync_og_description', | |
| 93 | + '_metasync_twitter_description', | |
| 94 | + ]; | |
| 95 | + | |
| 96 | + /** | |
| 61 | 97 | * Site option holding every "Auto Draft" placeholder variant this install |
| 62 | 98 | * has actually seen, keyed by nothing, de-duplicated, capped. Core fills a |
| 63 | 99 | * new post's title with __( 'Auto Draft' ) in the *creating admin user's* |
| 64 | 100 | * locale, and that translation is not loaded on front-end requests, so no |
| @@ -75,8 +111,19 @@ | ||
| 75 | 111 | */ |
| 76 | 112 | const AUTO_DRAFT_PLACEHOLDER_CAP = 20; |
| 77 | 113 | |
| 78 | 114 | /** |
| 115 | + * Upper bound on posts held in the get_default_og_values() memo. One | |
| 116 | + * front-end render touches one or two posts; a back-end loop (an importer, | |
| 117 | + * a bulk action, a resync walking every post) would otherwise grow the | |
| 118 | + * memo — and the excerpt-derivation results it caches — without end for | |
| 119 | + * the life of the request. When full the oldest entry is dropped first: | |
| 120 | + * insertion order is the only order the memo has, and a dropped post | |
| 121 | + * simply re-derives on its next read. | |
| 122 | + */ | |
| 123 | + const DEFAULT_OG_VALUES_MEMO_CAP = 10; | |
| 124 | + | |
| 125 | + /** | |
| 79 | 126 | * Per-post flag marking "this post was saved while its title was still the |
| 80 | 127 | * untitled placeholder". Stores the exact placeholder string that was |
| 81 | 128 | * recognized at save time, so the render path can suppress by state (flag |
| 82 | 129 | * present + title still equals it) rather than by string matching alone — |
| @@ -131,9 +178,9 @@ | ||
| 131 | 178 | if ($value === '') { |
| 132 | 179 | return false; |
| 133 | 180 | } |
| 134 | 181 | # 'default' text domain, matching the __() call core itself uses. |
| 135 | - if ($value === 'Auto Draft' || $value === trim(__('Auto Draft'))) { | |
| 182 | + if ($value === 'Auto Draft' || $value === trim(__('Auto Draft', 'default'))) { // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- resolves core's own 'Auto Draft' placeholder, which only exists in core's 'default' domain; the plugin text domain cannot reproduce it | |
| 136 | 183 | return true; |
| 137 | 184 | } |
| 138 | 185 | # Registry arm: a variant captured in another locale's admin request. |
| 139 | 186 | # Without it the guard above is English-only on every non-admin request, |
| @@ -194,9 +241,9 @@ | ||
| 194 | 241 | * |
| 195 | 242 | * @return void |
| 196 | 243 | */ |
| 197 | 244 | public static function seed_auto_draft_placeholder() { |
| 198 | - self::remember_auto_draft_placeholder(__('Auto Draft')); | |
| 245 | + self::remember_auto_draft_placeholder(__('Auto Draft', 'default')); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- resolves core's own 'Auto Draft' placeholder, which only exists in core's 'default' domain; the plugin text domain cannot reproduce it | |
| 199 | 246 | } |
| 200 | 247 | |
| 201 | 248 | /** |
| 202 | 249 | * Read a persisted social meta value, collapsing the "Auto Draft" placeholder |
| @@ -225,9 +272,12 @@ | ||
| 225 | 272 | } |
| 226 | 273 | |
| 227 | 274 | /** |
| 228 | 275 | * Read one of the AUTO_DRAFT_PRONE_KEYS for a post with the placeholder |
| 229 | - * collapsed to ''. | |
| 276 | + * collapsed to '' — and, for the title keys, a stored snapshot of the | |
| 277 | + * title default the pre-fill showed (the post title, or the og title for | |
| 278 | + * the twitter twin), and for the description keys a stored snapshot of | |
| 279 | + * the resolved description, collapsed too. | |
| 230 | 280 | * |
| 231 | 281 | * @param int $post_id |
| 232 | 282 | * @param string $key |
| 233 | 283 | * @return string |
| @@ -232,12 +282,193 @@ | ||
| 232 | 282 | * @param string $key |
| 233 | 283 | * @return string |
| 234 | 284 | */ |
| 235 | 285 | public static function get_social_meta($post_id, $key) { |
| 236 | - return self::strip_auto_draft_title(get_post_meta($post_id, $key, true)); | |
| 286 | + return self::strip_description_snapshot( | |
| 287 | + $post_id, | |
| 288 | + $key, | |
| 289 | + self::strip_title_snapshot( | |
| 290 | + $post_id, | |
| 291 | + $key, | |
| 292 | + self::strip_auto_draft_title(get_post_meta($post_id, $key, true)) | |
| 293 | + ) | |
| 294 | + ); | |
| 237 | 295 | } |
| 238 | 296 | |
| 239 | 297 | /** |
| 298 | + * Collapse a stored social title that merely mirrors the post's own title. | |
| 299 | + * | |
| 300 | + * The meta box used to pre-fill Title from the post title as a real value | |
| 301 | + * and persist it on save, so rows exist where `_metasync_og_title` (and the | |
| 302 | + * twitter twin) hold a verbatim copy of the title as it was on save day. | |
| 303 | + * Renaming the post then left that snapshot stale with nothing to tell it | |
| 304 | + * apart from a typed title. Collapsing it here — at the read, the same | |
| 305 | + * place the "Auto Draft" placeholder collapses — makes such a row read as | |
| 306 | + * unset, and the render-time fallback to the *live* title keeps the social | |
| 307 | + * title in step with every rename. A value that differs from the title is | |
| 308 | + * untouched: only text the editor genuinely typed survives. | |
| 309 | + * | |
| 310 | + * The twitter twin compares against what the og title field showed when | |
| 311 | + * the pre-fill was rendered — a stored og title if one is set, otherwise | |
| 312 | + * the same live post title — since that is exactly what the pre-fill | |
| 313 | + * echoed into the twitter field. Without that chain, a twitter row echoed | |
| 314 | + * from a typed og title would read as a deliberate override and stop | |
| 315 | + * following the og title it always rendered. | |
| 316 | + * | |
| 317 | + * Compared after trim on both sides: stored values pass through | |
| 318 | + * sanitize_text_field, which trims, so only the post title side can carry | |
| 319 | + * surrounding whitespace. | |
| 320 | + * | |
| 321 | + * Public and static for the same reason strip_auto_draft_title() is: the | |
| 322 | + * consumers live in different classes (this emitter, the precedence | |
| 323 | + * resolver, the plugin sync mirror, the SEO suite). | |
| 324 | + * | |
| 325 | + * @param int $post_id | |
| 326 | + * @param string $key One of TITLE_DEFAULTED_KEYS; others pass through. | |
| 327 | + * @param mixed $value Stored meta value, already placeholder-collapsed. | |
| 328 | + * @return string | |
| 329 | + */ | |
| 330 | + public static function strip_title_snapshot($post_id, $key, $value) { | |
| 331 | + $value = is_scalar($value) ? (string) $value : ''; | |
| 332 | + if ($value === '' || !in_array($key, self::TITLE_DEFAULTED_KEYS, true)) { | |
| 333 | + return $value; | |
| 334 | + } | |
| 335 | + | |
| 336 | + $default = self::default_social_title($post_id, $key); | |
| 337 | + if ($default === '') { | |
| 338 | + return $value; | |
| 339 | + } | |
| 340 | + | |
| 341 | + return trim($value) === trim($default) ? '' : $value; | |
| 342 | + } | |
| 343 | + | |
| 344 | + /** | |
| 345 | + * The default a title key renders when its own field is blank: the live | |
| 346 | + * post title for the og key, and for the twitter key whatever the og field | |
| 347 | + * would show (a stored og title if one is set, otherwise the same live | |
| 348 | + * title) — the exact chain the meta box fallback used to pre-fill as a | |
| 349 | + * value and still shows as the twitter field's placeholder. | |
| 350 | + * | |
| 351 | + * social_post_title() — not the raw post_title — is what the pre-fill ever | |
| 352 | + * stored, and it returns '' for a still-untitled post, where there is no | |
| 353 | + * meaningful default to compare against anyway (such rows are the | |
| 354 | + * placeholder case strip_auto_draft_title() already handles). | |
| 355 | + * | |
| 356 | + * @param int $post_id | |
| 357 | + * @param string $key | |
| 358 | + * @return string | |
| 359 | + */ | |
| 360 | + private static function default_social_title($post_id, $key) { | |
| 361 | + $title = self::social_post_title(get_post($post_id)); | |
| 362 | + if ($key === '_metasync_og_title') { | |
| 363 | + return $title; | |
| 364 | + } | |
| 365 | + | |
| 366 | + $og = self::get_social_meta($post_id, '_metasync_og_title'); | |
| 367 | + return $og !== '' ? $og : $title; | |
| 368 | + } | |
| 369 | + | |
| 370 | + /** | |
| 371 | + * Collapse a stored social description that merely mirrors the default the | |
| 372 | + * meta box pre-filled. | |
| 373 | + * | |
| 374 | + * The meta box pre-filled Description from the resolved description (the | |
| 375 | + * manual excerpt, or one generated from the content) as a real value and | |
| 376 | + * persisted it on save, so rows exist where `_metasync_og_description` (and | |
| 377 | + * the twitter twin) hold a copy of the excerpt as it was on save day. | |
| 378 | + * Changing the excerpt — or the content the excerpt is generated from — then | |
| 379 | + * left that snapshot stale with nothing to tell it apart from a typed | |
| 380 | + * description. Collapsing it here — at the read, the same place the "Auto | |
| 381 | + * Draft" placeholder and title snapshots collapse — makes such a row read as | |
| 382 | + * unset, and the render-time fallback to the *live* excerpt keeps the social | |
| 383 | + * description in step. A value that differs from the default is untouched: | |
| 384 | + * only text the editor genuinely typed survives. | |
| 385 | + * | |
| 386 | + * The twitter twin compares against what the og description field showed | |
| 387 | + * when the pre-fill was rendered — a stored og description if one is set, | |
| 388 | + * otherwise the same resolved default — since that is exactly what the | |
| 389 | + * pre-fill echoed into the twitter field. | |
| 390 | + * | |
| 391 | + * Compared after whitespace normalization on both sides: descriptions pass | |
| 392 | + * through sanitize_textarea_field, which preserves line breaks, and a manual | |
| 393 | + * excerpt can carry them while the generated default cannot, so a snapshot | |
| 394 | + * and its default must be compared with runs of whitespace treated as one | |
| 395 | + * space rather than byte-for-byte. | |
| 396 | + * | |
| 397 | + * Public and static for the same reason strip_title_snapshot() is: the | |
| 398 | + * consumers live in different classes (this emitter, the precedence | |
| 399 | + * resolver, the plugin sync mirror, the SEO suite). | |
| 400 | + * | |
| 401 | + * @param int $post_id | |
| 402 | + * @param string $key One of DESCRIPTION_DEFAULTED_KEYS; others pass through. | |
| 403 | + * @param mixed $value Stored meta value, already placeholder-collapsed. | |
| 404 | + * @return string | |
| 405 | + */ | |
| 406 | + public static function strip_description_snapshot($post_id, $key, $value) { | |
| 407 | + $value = is_scalar($value) ? (string) $value : ''; | |
| 408 | + if ($value === '' || !in_array($key, self::DESCRIPTION_DEFAULTED_KEYS, true)) { | |
| 409 | + return $value; | |
| 410 | + } | |
| 411 | + | |
| 412 | + $default = self::default_social_description($post_id, $key); | |
| 413 | + if ($default === '') { | |
| 414 | + return $value; | |
| 415 | + } | |
| 416 | + | |
| 417 | + return self::normalize_description_compare($value) === self::normalize_description_compare($default) | |
| 418 | + ? '' | |
| 419 | + : $value; | |
| 420 | + } | |
| 421 | + | |
| 422 | + /** | |
| 423 | + * The default a description key renders when its own field is blank: the | |
| 424 | + * resolved description for the og key, and for the twitter key whatever the | |
| 425 | + * og field would show (a stored og description if one is set, otherwise the | |
| 426 | + * same resolved default — even when that default is empty) — the exact | |
| 427 | + * chain the meta box fallback used to pre-fill as a value. | |
| 428 | + * | |
| 429 | + * Resolved through the emitter's own instance so it matches what actually | |
| 430 | + * renders; when no instance is available the default cannot be proven, so '' | |
| 431 | + * is returned and the caller keeps the stored value rather than risk | |
| 432 | + * discarding text the editor typed. | |
| 433 | + * | |
| 434 | + * @param int $post_id | |
| 435 | + * @param string $key | |
| 436 | + * @return string | |
| 437 | + */ | |
| 438 | + private static function default_social_description($post_id, $key) { | |
| 439 | + $instance = self::get_instance(); | |
| 440 | + if (!$instance) { | |
| 441 | + return ''; | |
| 442 | + } | |
| 443 | + | |
| 444 | + $default = (string) $instance->get_default_og_values($post_id)['description']; | |
| 445 | + if ($key === '_metasync_og_description') { | |
| 446 | + return $default; | |
| 447 | + } | |
| 448 | + | |
| 449 | + # The og chain runs for the twitter twin even when the resolved default | |
| 450 | + # is empty: the pre-fill echoed the og description into the twitter | |
| 451 | + # field whenever the og field carried a value, so that — not the empty | |
| 452 | + # excerpt — is the default a stored twitter echo must match to collapse. | |
| 453 | + $og = self::get_social_meta($post_id, '_metasync_og_description'); | |
| 454 | + return $og !== '' ? $og : $default; | |
| 455 | + } | |
| 456 | + | |
| 457 | + /** | |
| 458 | + * Normalize a description for equality comparison: trimmed, with runs of | |
| 459 | + * whitespace collapsed to a single space. Falls back to a plain trim when | |
| 460 | + * the value is not valid UTF-8 and the /u pattern therefore fails. | |
| 461 | + * | |
| 462 | + * @param string $value | |
| 463 | + * @return string | |
| 464 | + */ | |
| 465 | + private static function normalize_description_compare($value) { | |
| 466 | + $normalized = preg_replace('/\s+/u', ' ', trim((string) $value)); | |
| 467 | + return is_string($normalized) ? $normalized : trim((string) $value); | |
| 468 | + } | |
| 469 | + | |
| 470 | + /** | |
| 240 | 471 | * A post's title for social use, suppressing only WordPress's own placeholder. |
| 241 | 472 | * |
| 242 | 473 | * Distinct from strip_auto_draft_title(), which is for *stored* meta values: |
| 243 | 474 | * there the string is all we have, since a legacy row carries no clue about the |
| @@ -268,8 +499,25 @@ | ||
| 268 | 499 | return (string) $post->post_title; |
| 269 | 500 | } |
| 270 | 501 | |
| 271 | 502 | /** |
| 503 | + * Whether MetaSync social output is disabled for a post. | |
| 504 | + * | |
| 505 | + * The site-wide switch and the per-post switch both disable only MetaSync's | |
| 506 | + * social output; callers must leave third-party SEO output untouched. | |
| 507 | + * | |
| 508 | + * @param int $post_id Post ID. | |
| 509 | + * @return bool | |
| 510 | + */ | |
| 511 | + public static function is_social_output_disabled($post_id = 0) { | |
| 512 | + if (Metasync_Feature_Flags::is_disabled(Metasync_Feature_Flags::SOCIAL_OG)) { | |
| 513 | + return true; | |
| 514 | + } | |
| 515 | + | |
| 516 | + return $post_id > 0 && get_post_meta($post_id, '_metasync_og_enabled', true) === '0'; | |
| 517 | + } | |
| 518 | + | |
| 519 | + /** | |
| 272 | 520 | * The default OG values the meta box pre-fills for a post: post title, |
| 273 | 521 | * generated excerpt, and featured image. Computed with the same helpers the |
| 274 | 522 | * meta box/emitter use, so a caller can compare a stored _metasync_og_* value |
| 275 | 523 | * against the default and tell whether the user genuinely customized it (the |
| @@ -280,17 +528,45 @@ | ||
| 280 | 528 | * "Auto Draft" placeholder, so the meta box pre-fills nothing and callers |
| 281 | 529 | * comparing a stored value against this default don't read the placeholder as |
| 282 | 530 | * a deliberate override. |
| 283 | 531 | * |
| 532 | + * Memoized per request: the values are pure functions of the post, and one | |
| 533 | + * front-end render resolves them several times (every collapsed description | |
| 534 | + * read asks for the default, and the OTTO precedence walk asks again) — on | |
| 535 | + * page-builder content the excerpt pass alone re-runs the whole | |
| 536 | + * shortcode-strip pipeline each time. The save handler drops the memo | |
| 537 | + * before its comparisons, since save_post fires after the post row write | |
| 538 | + * and the comparison must see the new title/excerpt. Capped at | |
| 539 | + * DEFAULT_OG_VALUES_MEMO_CAP posts for loops that walk many posts in one | |
| 540 | + * request. | |
| 541 | + * | |
| 284 | 542 | * @param int $post_id |
| 285 | 543 | * @return array{title:string,description:string,image:string} |
| 286 | 544 | */ |
| 287 | 545 | public function get_default_og_values($post_id) { |
| 546 | + $post_id = (int) $post_id; | |
| 547 | + // No real post: get_post(0) answers with the global post, which would | |
| 548 | + // then be memoized under key 0 and served for every later bogus id. | |
| 549 | + if ($post_id <= 0) { | |
| 550 | + return ['title' => '', 'description' => '', 'image' => '']; | |
| 551 | + } | |
| 552 | + if (isset(self::$default_og_values_memo[$post_id])) { | |
| 553 | + return self::$default_og_values_memo[$post_id]; | |
| 554 | + } | |
| 555 | + | |
| 288 | 556 | $post = get_post($post_id); |
| 289 | 557 | if (!$post instanceof WP_Post) { |
| 290 | 558 | return ['title' => '', 'description' => '', 'image' => '']; |
| 291 | 559 | } |
| 292 | - return [ | |
| 560 | + | |
| 561 | + if (count(self::$default_og_values_memo) >= self::DEFAULT_OG_VALUES_MEMO_CAP) { | |
| 562 | + // unset() on the first key, NOT array_shift(): the memo is keyed by | |
| 563 | + // post id, and array_shift() reindexes integer keys from zero — | |
| 564 | + // after one eviction the survivors would live under 0..8 and any | |
| 565 | + // post with a low id would be served another post's defaults. | |
| 566 | + unset(self::$default_og_values_memo[array_key_first(self::$default_og_values_memo)]); | |
| 567 | + } | |
| 568 | + return self::$default_og_values_memo[$post_id] = [ | |
| 293 | 569 | 'title' => self::social_post_title($post), |
| 294 | 570 | 'description' => (string) $this->get_post_excerpt($post), |
| 295 | 571 | 'image' => (string) $this->get_featured_image_url($post->ID), |
| 296 | 572 | ]; |
| @@ -296,8 +572,19 @@ | ||
| 296 | 572 | ]; |
| 297 | 573 | } |
| 298 | 574 | |
| 299 | 575 | /** |
| 576 | + * Drop the get_default_og_values() memo. Called on the save path, where the | |
| 577 | + * just-written post row must be re-read rather than served from a memo | |
| 578 | + * populated earlier in the request; also the seam long-lived processes | |
| 579 | + * (tests, CLI) use to simulate a fresh request. Mirrors | |
| 580 | + * Metasync_Otto_Config::clear_cache(). | |
| 581 | + */ | |
| 582 | + public static function clear_default_og_values_memo() { | |
| 583 | + self::$default_og_values_memo = []; | |
| 584 | + } | |
| 585 | + | |
| 586 | + /** | |
| 300 | 587 | * Register all hooks for this class |
| 301 | 588 | */ |
| 302 | 589 | public function init() { |
| 303 | 590 | # Admin hooks |
| @@ -443,9 +730,9 @@ | ||
| 443 | 730 | |
| 444 | 731 | # LPS / custom-HTML pages bake their own OG/social tags into their HTML bundle, |
| 445 | 732 | # served before wp_head — so this box does nothing on them. Hide it; the SEO |
| 446 | 733 | # read-only notice covers the messaging. |
| 447 | - $lps_post_id = isset($_GET['post']) ? intval($_GET['post']) : (isset($_POST['post_ID']) ? intval($_POST['post_ID']) : 0); | |
| 734 | + $lps_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 | |
| 448 | 735 | if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) { |
| 449 | 736 | return; |
| 450 | 737 | } |
| 451 | 738 | |
| @@ -455,8 +742,9 @@ | ||
| 455 | 742 | |
| 456 | 743 | foreach ($post_types as $post_type) { |
| 457 | 744 | add_meta_box( |
| 458 | 745 | self::META_BOX_ID, |
| 746 | + /* translators: %s: effective plugin name (whitelabel-aware). */ | |
| 459 | 747 | sprintf(esc_html__('Social Media & Open Graph by %s', 'metasync'), $plugin_name), |
| 460 | 748 | [$this, 'render_meta_box'], |
| 461 | 749 | $post_type, |
| 462 | 750 | 'normal', |
| @@ -472,10 +760,12 @@ | ||
| 472 | 760 | # Add nonce for security |
| 473 | 761 | wp_nonce_field('metasync_opengraph_nonce', 'metasync_opengraph_nonce'); |
| 474 | 762 | |
| 475 | 763 | # Get existing values. The four social title/description keys are read through |
| 476 | - # get_social_meta() so a row polluted with "Auto Draft" before this fix shipped | |
| 477 | - # shows the real pre-fill in the editor — and re-saving the post clears it. | |
| 764 | + # get_social_meta(), which collapses the "Auto Draft" placeholder, a stored | |
| 765 | + # snapshot of the post title, and a stored snapshot of the resolved | |
| 766 | + # description to '' — so a polluted legacy row shows an empty field (the | |
| 767 | + # default lives in the placeholder) and re-saving clears it. | |
| 478 | 768 | $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true); |
| 479 | 769 | $og_title = self::get_social_meta($post->ID, '_metasync_og_title'); |
| 480 | 770 | $og_description = self::get_social_meta($post->ID, '_metasync_og_description'); |
| 481 | 771 | $og_image = get_post_meta($post->ID, '_metasync_og_image', true); |
| @@ -509,16 +799,36 @@ | ||
| 509 | 799 | if ($og_enabled === '') { |
| 510 | 800 | # For new posts, default to enabled |
| 511 | 801 | $og_enabled = '1'; |
| 512 | 802 | } |
| 513 | - if (empty($og_title)) { | |
| 514 | - # Never pre-fill WordPress's own "Auto Draft" placeholder: it would be | |
| 515 | - # submitted back as a real value and persist as the social title forever. | |
| 516 | - $og_title = self::social_post_title($post); | |
| 517 | - } | |
| 518 | - if (empty($og_description)) { | |
| 519 | - $og_description = $this->get_post_excerpt($post); | |
| 520 | - } | |
| 803 | + # The post title is the default social title, but it is shown as a | |
| 804 | + # placeholder, never as the field's value. Submitting a page carries | |
| 805 | + # every value attribute to the save handler, so pre-filling the title | |
| 806 | + # persisted a verbatim snapshot of it — one a later rename left stale | |
| 807 | + # (and indistinguishable from a typed title). An empty field plus a | |
| 808 | + # placeholder keeps the default visible while storing nothing, and the | |
| 809 | + # render-time fallback to the live title tracks renames on its own. | |
| 810 | + # social_post_title() suppresses WordPress's own "Auto Draft" | |
| 811 | + # placeholder, so a brand-new post shows no misleading hint either. | |
| 812 | + $title_placeholder = self::social_post_title($post); | |
| 813 | + # What twitter:title actually renders when its own field is blank: the | |
| 814 | + # OG title if one is set, otherwise the same live post title. Shown as | |
| 815 | + # the twitter field's placeholder for the same reason as above. | |
| 816 | + $twitter_title_placeholder = ($og_title !== '') ? $og_title : $title_placeholder; | |
| 817 | + # The resolved description (the manual excerpt, or one generated from | |
| 818 | + # the content) is the default social description, and like the title it | |
| 819 | + # is shown as a placeholder, never as the field's value. Pre-filling it | |
| 820 | + # persisted a snapshot of the excerpt as of save day — one a later | |
| 821 | + # excerpt or content edit left stale, and indistinguishable from a | |
| 822 | + # typed description. An empty field plus a placeholder keeps the | |
| 823 | + # default visible while storing nothing, and the render-time fallback | |
| 824 | + # to the live excerpt tracks edits on its own. | |
| 825 | + $description_placeholder = $this->get_post_excerpt($post); | |
| 826 | + # What twitter:description actually renders when its own field is | |
| 827 | + # blank: the OG description if one is set, otherwise the same resolved | |
| 828 | + # default. Shown as the twitter field's placeholder for the same | |
| 829 | + # reason as above. | |
| 830 | + $twitter_description_placeholder = ($og_description !== '') ? $og_description : $description_placeholder; | |
| 521 | 831 | if (empty($og_url)) { |
| 522 | 832 | $og_url = $this->get_canonical_url($post); |
| 523 | 833 | } |
| 524 | 834 | if (empty($og_type)) { |
| @@ -526,19 +836,13 @@ | ||
| 526 | 836 | } |
| 527 | 837 | if (empty($og_image)) { |
| 528 | 838 | $og_image = $this->get_featured_image_url($post->ID); |
| 529 | 839 | } |
| 530 | - | |
| 840 | + | |
| 531 | 841 | # Twitter defaults |
| 532 | 842 | if (empty($twitter_card)) { |
| 533 | 843 | $twitter_card = 'summary_large_image'; |
| 534 | 844 | } |
| 535 | - if (empty($twitter_title)) { | |
| 536 | - $twitter_title = $og_title; | |
| 537 | - } | |
| 538 | - if (empty($twitter_description)) { | |
| 539 | - $twitter_description = $og_description; | |
| 540 | - } | |
| 541 | 845 | if (empty($twitter_image)) { |
| 542 | 846 | $twitter_image = $og_image; |
| 543 | 847 | } |
| 544 | 848 | |
| @@ -552,10 +856,20 @@ | ||
| 552 | 856 | /** |
| 553 | 857 | * Save meta box data |
| 554 | 858 | */ |
| 555 | 859 | public function save_meta_box_data($post_id) { |
| 860 | + # Drop the defaults memo before anything below can bail. This handler is | |
| 861 | + # hooked to save_post, so it runs for EVERY save of the post — including | |
| 862 | + # ones initiated by another meta box, a bulk action, or a sync flow that | |
| 863 | + # carries no OG nonce and would have returned early above the old flush | |
| 864 | + # position. Without this clear, a memo seeded earlier in the same request | |
| 865 | + # (a metabox render, a sync read) survives the post-row write and serves | |
| 866 | + # the pre-save title/excerpt to the snapshot comparisons in later | |
| 867 | + # save_post subscribers — the Yoast re-sync on shutdown among them. | |
| 868 | + self::clear_default_og_values_memo(); | |
| 869 | + | |
| 556 | 870 | # Check if nonce is valid |
| 557 | - if (!isset($_POST['metasync_opengraph_nonce']) || | |
| 871 | + if (!isset($_POST['metasync_opengraph_nonce']) || | |
| 558 | 872 | !wp_verify_nonce($_POST['metasync_opengraph_nonce'], 'metasync_opengraph_nonce')) { |
| 559 | 873 | return; |
| 560 | 874 | } |
| 561 | 875 | |
| @@ -632,9 +946,45 @@ | ||
| 632 | 946 | ) { |
| 633 | 947 | $value = ''; |
| 634 | 948 | } |
| 635 | 949 | |
| 636 | - update_post_meta($post_id, $field, $value); | |
| 950 | + # A social title identical to the post's title is the default, not a | |
| 951 | + # customization — whether the editor left an older pre-filled form | |
| 952 | + # untouched or typed the title back verbatim. Store empty so the | |
| 953 | + # render-time fallback to the live title applies, which is what keeps | |
| 954 | + # a rename from leaving a stale snapshot behind. Text that differs | |
| 955 | + # from the title passes through untouched. save_post fires after the | |
| 956 | + # post row is written, so the comparison is against the *new* title. | |
| 957 | + if (in_array($field, self::TITLE_DEFAULTED_KEYS, true)) { | |
| 958 | + $value = self::strip_title_snapshot($post_id, $field, $value); | |
| 959 | + } | |
| 960 | + | |
| 961 | + # A social description identical to its rendered default — the | |
| 962 | + # resolved excerpt the older pre-filled form carried, or the | |
| 963 | + # same text typed back verbatim — is the default, not a | |
| 964 | + # customization. Store empty so the render-time fallback to the | |
| 965 | + # live excerpt applies, which is what keeps an excerpt or | |
| 966 | + # content edit from leaving a stale snapshot behind. Text that | |
| 967 | + # differs from the default passes through untouched. save_post | |
| 968 | + # fires after the post row is written, so the comparison is | |
| 969 | + # against the *new* excerpt and content. | |
| 970 | + if (in_array($field, self::DESCRIPTION_DEFAULTED_KEYS, true)) { | |
| 971 | + $value = self::strip_description_snapshot($post_id, $field, $value); | |
| 972 | + } | |
| 973 | + | |
| 974 | + # An empty value means "no value" for every field in this box, | |
| 975 | + # and must not be stored as one: update_post_meta(..., '') keeps | |
| 976 | + # a row with an empty meta_value in the table (visible only to | |
| 977 | + # metadata_exists, EXISTS queries and exports, which would then | |
| 978 | + # report a customization where the field is merely blank). The | |
| 979 | + # plain read answers '' for a missing row too, so deleting is | |
| 980 | + # behaviour-preserving for every reader. Same pattern the | |
| 981 | + # untitled-flag write below has always used. | |
| 982 | + if ($value === '') { | |
| 983 | + delete_post_meta($post_id, $field); | |
| 984 | + } else { | |
| 985 | + update_post_meta($post_id, $field, $value); | |
| 986 | + } | |
| 637 | 987 | } |
| 638 | 988 | } |
| 639 | 989 | } |
| 640 | 990 | |
| @@ -695,9 +1045,9 @@ | ||
| 695 | 1045 | # remember_auto_draft_placeholder() already skips. Verified against |
| 696 | 1046 | # __( 'Auto Draft' ), never trusted as a bare string, so a builder's |
| 697 | 1047 | # custom-seeded auto-draft title is never captured. |
| 698 | 1048 | if ($post->post_status === 'auto-draft' |
| 699 | - && $title !== '' && $title === trim(__('Auto Draft'))) { | |
| 1049 | + && $title !== '' && $title === trim(__('Auto Draft', 'default'))) { // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch -- resolves core's own 'Auto Draft' placeholder, which only exists in core's 'default' domain; the plugin text domain cannot reproduce it | |
| 700 | 1050 | self::remember_auto_draft_placeholder($title); |
| 701 | 1051 | } |
| 702 | 1052 | |
| 703 | 1053 | # Flag maintenance: recognition is context-independent thanks to the |
| @@ -854,10 +1204,9 @@ | ||
| 854 | 1204 | # Check if Open Graph is enabled for this post. |
| 855 | 1205 | # Only an explicit '0' opt-out suppresses output; unset/empty counts as enabled |
| 856 | 1206 | # so a MetaSync-only site gets one consolidated set whether or not the meta |
| 857 | 1207 | # box was ever saved. Must stay in sync with will_emit(). |
| 858 | - $og_enabled = get_post_meta($post->ID, '_metasync_og_enabled', true); | |
| 859 | - if ($og_enabled === '0') { | |
| 1208 | + if (self::is_social_output_disabled($post->ID)) { | |
| 860 | 1209 | return; |
| 861 | 1210 | } |
| 862 | 1211 | |
| 863 | 1212 | # When OTTO owns this page's OG (enabled + persisted OG data), skip legacy |
| @@ -876,12 +1225,14 @@ | ||
| 876 | 1225 | # Open Graph data. The tier order — what the customer set, then OTTO, then |
| 877 | 1226 | # a value brought in from another SEO plugin — comes from |
| 878 | 1227 | # Metasync_Seo_Precedence so this emitter and the conflict handler cannot |
| 879 | 1228 | # disagree about which value the page should carry. The resolver collapses a |
| 880 | - # stored "Auto Draft" placeholder to '' as it walks the chain, so a row | |
| 881 | - # polluted by the meta box pre-fill falls through to OTTO's tier rather than | |
| 882 | - # outranking it. The literal fallbacks below the chain (post title, excerpt, | |
| 883 | - # featured image) stay here: they are derived at render time, not stored. | |
| 1229 | + # stored "Auto Draft" placeholder, a snapshot of the post title, and a | |
| 1230 | + # snapshot of the resolved description to '' as it walks the chain, so a | |
| 1231 | + # row polluted by the meta box pre-fill falls through to the live | |
| 1232 | + # fallback (or OTTO's tier) rather than outranking it. The literal | |
| 1233 | + # fallbacks below the chain (post title, excerpt, featured image) stay | |
| 1234 | + # here: they are derived at render time, not stored. | |
| 884 | 1235 | $og_title = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_TITLE) |
| 885 | 1236 | ?: self::social_post_title($post); |
| 886 | 1237 | $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION) |
| 887 | 1238 | ?: $this->get_post_excerpt($post); |
| @@ -886,9 +1237,44 @@ | ||
| 886 | 1237 | $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION) |
| 887 | 1238 | ?: $this->get_post_excerpt($post); |
| 888 | 1239 | $og_image = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_IMAGE) |
| 889 | 1240 | ?: $this->get_featured_image_url($post->ID); |
| 890 | - $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post); | |
| 1241 | + $canonical_override = ''; | |
| 1242 | + if (class_exists('Metasync_Headless_Config') && Metasync_Headless_Config::is_active()) { | |
| 1243 | + # The two manual keys of the canonical chain, in the same order the | |
| 1244 | + # canonical tag resolves them. OTTO's volatile staging tier is | |
| 1245 | + # deliberately left out: headless rehosts derived WordPress URLs, | |
| 1246 | + # and an OTTO suggestion is not the author's answer about where | |
| 1247 | + # content lives — a staged cross-domain canonical must not flow | |
| 1248 | + # into headless og:url. The two can therefore disagree with the | |
| 1249 | + # WordPress-render canonical tag only when OTTO is staging a | |
| 1250 | + # canonical and no manual value exists; that is the documented | |
| 1251 | + # scope split. meta_canonical is not a legacy alias: the REST sync | |
| 1252 | + # and the classic-editor Canonical box both still write it, and | |
| 1253 | + # the GraphQL surface reads both keys as well. | |
| 1254 | + foreach (array('_metasync_canonical_url', 'meta_canonical') as $canonical_key) { | |
| 1255 | + $canonical_override = Metasync_Canonical_Sanitizer::sanitize( | |
| 1256 | + get_post_meta($post->ID, $canonical_key, true) | |
| 1257 | + ); | |
| 1258 | + if ($canonical_override !== '') { | |
| 1259 | + break; | |
| 1260 | + } | |
| 1261 | + } | |
| 1262 | + } | |
| 1263 | + | |
| 1264 | + # A validated canonical override is the author's final answer. It may | |
| 1265 | + # deliberately name another host, so it bypasses headless rehosting. | |
| 1266 | + if ($canonical_override !== '') { | |
| 1267 | + $og_url = esc_url($canonical_override); | |
| 1268 | + } else { | |
| 1269 | + $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post); | |
| 1270 | + | |
| 1271 | + # On a headless site the rendered page is not the public page, so | |
| 1272 | + # an own-host og:url must name the frontend. Applied at the single | |
| 1273 | + # emission source so update_opengraph_url() continues to persist | |
| 1274 | + # the WordPress URL used outside headless mode. | |
| 1275 | + $og_url = $this->maybe_build_headless_url($og_url, $post->post_type); | |
| 1276 | + } | |
| 891 | 1277 | $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article'; |
| 892 | 1278 | |
| 893 | 1279 | # Get Twitter Card data — check persisted key first, fall back to OTTO staging key |
| 894 | 1280 | $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image'; |
| @@ -1122,8 +1508,12 @@ | ||
| 1122 | 1508 | if (!$post instanceof WP_Post) { |
| 1123 | 1509 | return; |
| 1124 | 1510 | } |
| 1125 | 1511 | |
| 1512 | + if (self::is_social_output_disabled($post->ID)) { | |
| 1513 | + return; | |
| 1514 | + } | |
| 1515 | + | |
| 1126 | 1516 | $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article'; |
| 1127 | 1517 | if ($og_type !== 'article') { |
| 1128 | 1518 | return; |
| 1129 | 1519 | } |
| @@ -1244,8 +1634,12 @@ | ||
| 1244 | 1634 | |
| 1245 | 1635 | # Yoast: remove individual presenters based on which MetaSync features are enabled |
| 1246 | 1636 | if ($yoast_active && ($article_timestamps_enabled || $article_author_enabled)) { |
| 1247 | 1637 | add_filter('wpseo_frontend_presenters', function( $presenters ) use ( $article_timestamps_enabled, $article_author_enabled ) { |
| 1638 | + $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0; | |
| 1639 | + if (self::is_social_output_disabled($post_id)) { | |
| 1640 | + return $presenters; | |
| 1641 | + } | |
| 1248 | 1642 | foreach ( $presenters as $key => $presenter ) { |
| 1249 | 1643 | if ( $article_timestamps_enabled && ( |
| 1250 | 1644 | $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Published_Time_Presenter || |
| 1251 | 1645 | $presenter instanceof \Yoast\WP\SEO\Presenters\Open_Graph\Article_Modified_Time_Presenter |
| @@ -1265,20 +1659,24 @@ | ||
| 1265 | 1659 | # Rank Math's tag() method passes content through rank_math/opengraph/facebook/{property} |
| 1266 | 1660 | # where {property} is the OG property with colons replaced by underscores. |
| 1267 | 1661 | # Returning false causes tag() to skip output (empty($content) check). |
| 1268 | 1662 | if ($rank_math_active) { |
| 1663 | + $keep_third_party = function ($value) { | |
| 1664 | + $post_id = function_exists('get_queried_object_id') ? (int) get_queried_object_id() : 0; | |
| 1665 | + return self::is_social_output_disabled($post_id) ? $value : false; | |
| 1666 | + }; | |
| 1269 | 1667 | if ($article_timestamps_enabled) { |
| 1270 | - add_filter('rank_math/opengraph/facebook/article_published_time', '__return_false', 999); | |
| 1271 | - add_filter('rank_math/opengraph/facebook/article_modified_time', '__return_false', 999); | |
| 1668 | + add_filter('rank_math/opengraph/facebook/article_published_time', $keep_third_party, 999); | |
| 1669 | + add_filter('rank_math/opengraph/facebook/article_modified_time', $keep_third_party, 999); | |
| 1272 | 1670 | } |
| 1273 | 1671 | if ($article_tags_enabled) { |
| 1274 | - add_filter('rank_math/opengraph/facebook/article_tag', '__return_false', 999); | |
| 1672 | + add_filter('rank_math/opengraph/facebook/article_tag', $keep_third_party, 999); | |
| 1275 | 1673 | } |
| 1276 | 1674 | if ($article_author_enabled) { |
| 1277 | - add_filter('rank_math/opengraph/facebook/article_author', '__return_false', 999); | |
| 1675 | + add_filter('rank_math/opengraph/facebook/article_author', $keep_third_party, 999); | |
| 1278 | 1676 | } |
| 1279 | 1677 | if ($article_section_enabled) { |
| 1280 | - add_filter('rank_math/opengraph/facebook/article_section', '__return_false', 999); | |
| 1678 | + add_filter('rank_math/opengraph/facebook/article_section', $keep_third_party, 999); | |
| 1281 | 1679 | } |
| 1282 | 1680 | } |
| 1283 | 1681 | } |
| 1284 | 1682 | |
| @@ -1378,9 +1776,8 @@ | ||
| 1378 | 1776 | $preview_html = $this->generate_preview_html($title, $description, $image, $url, $twitter_title, $twitter_description, $twitter_image); |
| 1379 | 1777 | |
| 1380 | 1778 | if (empty($preview_html)) { |
| 1381 | 1779 | wp_send_json_error(['message' => 'Failed to generate preview HTML']); |
| 1382 | - return; | |
| 1383 | 1780 | } |
| 1384 | 1781 | |
| 1385 | 1782 | wp_send_json_success(['preview' => $preview_html]); |
| 1386 | 1783 | |
| @@ -1395,9 +1792,9 @@ | ||
| 1395 | 1792 | private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') { |
| 1396 | 1793 | # Parse domain from URL |
| 1397 | 1794 | $domain = ''; |
| 1398 | 1795 | if (!empty($url)) { |
| 1399 | - $parsed = parse_url($url); | |
| 1796 | + $parsed = wp_parse_url($url); | |
| 1400 | 1797 | $domain = $parsed['host'] ?? ''; |
| 1401 | 1798 | } |
| 1402 | 1799 | |
| 1403 | 1800 | # Fallback to site URL if no domain found |
| @@ -1402,9 +1799,9 @@ | ||
| 1402 | 1799 | |
| 1403 | 1800 | # Fallback to site URL if no domain found |
| 1404 | 1801 | if (empty($domain)) { |
| 1405 | 1802 | $site_url = get_site_url(); |
| 1406 | - $parsed = parse_url($site_url); | |
| 1803 | + $parsed = wp_parse_url($site_url); | |
| 1407 | 1804 | $domain = $parsed['host'] ?? 'your-site.com'; |
| 1408 | 1805 | } |
| 1409 | 1806 | |
| 1410 | 1807 | # Provide fallbacks for empty values |
| @@ -1786,8 +2183,62 @@ | ||
| 1786 | 2183 | return $permalink; |
| 1787 | 2184 | } |
| 1788 | 2185 | |
| 1789 | 2186 | /** |
| 2187 | + * Rehost a URL onto the public frontend when headless mode is active. | |
| 2188 | + * | |
| 2189 | + * Unlike the derived-URL callers elsewhere, the value handed here can be a | |
| 2190 | + * stored one an editor typed, which may legitimately point at a third-party | |
| 2191 | + * host. Only a URL on this site's own host is rewritten; anything else is | |
| 2192 | + * returned untouched. A builder failure is returned as an empty string so | |
| 2193 | + * the emitter cannot advertise the WordPress host on an active headless site. | |
| 2194 | + * | |
| 2195 | + * @param mixed $url URL to consider rehosting. | |
| 2196 | + * @param string $post_type Post type, for the frontend path prefix. | |
| 2197 | + * @return mixed Original URL when inactive or foreign; public URL or empty | |
| 2198 | + * string for an own-host URL while active. | |
| 2199 | + */ | |
| 2200 | + private function maybe_build_headless_url($url, $post_type = '') { | |
| 2201 | + if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_active() || !$this->is_own_host_url($url)) { | |
| 2202 | + return $url; | |
| 2203 | + } | |
| 2204 | + | |
| 2205 | + return Metasync_Headless_Url_Builder::from_wp_url($url, $post_type); | |
| 2206 | + } | |
| 2207 | + | |
| 2208 | + /** | |
| 2209 | + * Whether a URL addresses this WordPress site rather than a foreign host. | |
| 2210 | + * | |
| 2211 | + * Accepts only HTTP(S) URLs on the exact site host, protocol-relative URLs | |
| 2212 | + * on that host, and genuine site-relative paths beginning with one slash. | |
| 2213 | + * | |
| 2214 | + * @param mixed $url URL to test. | |
| 2215 | + * @return bool | |
| 2216 | + */ | |
| 2217 | + private function is_own_host_url($url) { | |
| 2218 | + if (!is_string($url) || $url === '' || preg_match('/\s/', $url)) { | |
| 2219 | + return false; | |
| 2220 | + } | |
| 2221 | + | |
| 2222 | + if ($url[0] === '/' && (!isset($url[1]) || $url[1] !== '/')) { | |
| 2223 | + return true; | |
| 2224 | + } | |
| 2225 | + | |
| 2226 | + $is_protocol_relative = strpos($url, '//') === 0; | |
| 2227 | + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME)); | |
| 2228 | + if (!$is_protocol_relative && !in_array($scheme, array('http', 'https'), true)) { | |
| 2229 | + return false; | |
| 2230 | + } | |
| 2231 | + | |
| 2232 | + $url_host = wp_parse_url($url, PHP_URL_HOST); | |
| 2233 | + $site_host = wp_parse_url(home_url('/'), PHP_URL_HOST); | |
| 2234 | + | |
| 2235 | + return !empty($url_host) | |
| 2236 | + && !empty($site_host) | |
| 2237 | + && strcasecmp((string) $url_host, (string) $site_host) === 0; | |
| 2238 | + } | |
| 2239 | + | |
| 2240 | + /** | |
| 1790 | 2241 | * Update OpenGraph URL when post is saved |
| 1791 | 2242 | */ |
| 1792 | 2243 | public function update_opengraph_url($post_id) { |
| 1793 | 2244 | # Only update for supported post types |
| @@ -1927,11 +2378,20 @@ | ||
| 1927 | 2378 | /** |
| 1928 | 2379 | * Check if post slug changed via edit slug functionality |
| 1929 | 2380 | */ |
| 1930 | 2381 | public function check_slug_change() { |
| 2382 | + # Fires on the same admin-ajax request as core's sample-permalink | |
| 2383 | + # handler, but at priority 5 — before core validates the nonce and | |
| 2384 | + # capability at its own priority-10 callback. Verify them ourselves | |
| 2385 | + # before any post-meta write. Return (not die) so core still serves | |
| 2386 | + # the preview request normally. | |
| 2387 | + if (!check_ajax_referer('samplepermalink', 'samplepermalinknonce', false)) { | |
| 2388 | + return; | |
| 2389 | + } | |
| 2390 | + | |
| 1931 | 2391 | # Get the post ID from the request |
| 1932 | 2392 | $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0; |
| 1933 | - if (!$post_id) { | |
| 2393 | + if (!$post_id || !current_user_can('edit_post', $post_id)) { | |
| 1934 | 2394 | return; |
| 1935 | 2395 | } |
| 1936 | 2396 | |
| 1937 | 2397 | # Only check for supported post types |
| @@ -1964,9 +2424,9 @@ | ||
| 1964 | 2424 | # This means the og:url was set to the post permalink (not a custom URL) |
| 1965 | 2425 | if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) { |
| 1966 | 2426 | # Check if the og:url was the old permalink by comparing with a generated old permalink |
| 1967 | 2427 | $old_post = clone $post; |
| 1968 | - $old_slug = isset($_POST['new_slug']) ? sanitize_title($_POST['new_slug']) : $post->post_name; | |
| 2428 | + $old_slug = isset($_POST['new_slug']) ? sanitize_title(wp_unslash($_POST['new_slug'])) : $post->post_name; | |
| 1969 | 2429 | |
| 1970 | 2430 | # If the og:url doesn't match the current permalink, it might be the old one |
| 1971 | 2431 | # We'll update it to the new permalink |
| 1972 | 2432 | if ($current_og_url !== $current_permalink) { |