PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-opengraph.php +101 -9 2.6.26 → 2.7.1 View file →
@@ -178,9 +178,9 @@
178 178 if ($value === '') {
179 179 return false;
180 180 }
181 181 # 'default' text domain, matching the __() call core itself uses.
182 - 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
183 183 return true;
184 184 }
185 185 # Registry arm: a variant captured in another locale's admin request.
186 186 # Without it the guard above is English-only on every non-admin request,
@@ -241,9 +241,9 @@
241 241 *
242 242 * @return void
243 243 */
244 244 public static function seed_auto_draft_placeholder() {
245 - 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
246 246 }
247 247
248 248 /**
249 249 * Read a persisted social meta value, collapsing the "Auto Draft" placeholder
@@ -730,9 +730,9 @@
730 730
731 731 # LPS / custom-HTML pages bake their own OG/social tags into their HTML bundle,
732 732 # served before wp_head — so this box does nothing on them. Hide it; the SEO
733 733 # read-only notice covers the messaging.
734 - $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
735 735 if (function_exists('metasync_is_custom_or_lps_page') && $lps_post_id > 0 && metasync_is_custom_or_lps_page($lps_post_id)) {
736 736 return;
737 737 }
738 738
@@ -742,8 +742,9 @@
742 742
743 743 foreach ($post_types as $post_type) {
744 744 add_meta_box(
745 745 self::META_BOX_ID,
746 + /* translators: %s: effective plugin name (whitelabel-aware). */
746 747 sprintf(esc_html__('Social Media & Open Graph by %s', 'metasync'), $plugin_name),
747 748 [$this, 'render_meta_box'],
748 749 $post_type,
749 750 'normal',
@@ -1044,9 +1045,9 @@
1044 1045 # remember_auto_draft_placeholder() already skips. Verified against
1045 1046 # __( 'Auto Draft' ), never trusted as a bare string, so a builder's
1046 1047 # custom-seeded auto-draft title is never captured.
1047 1048 if ($post->post_status === 'auto-draft'
1048 - && $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
1049 1050 self::remember_auto_draft_placeholder($title);
1050 1051 }
1051 1052
1052 1053 # Flag maintenance: recognition is context-independent thanks to the
@@ -1236,9 +1237,37 @@
1236 1237 $og_description = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_DESCRIPTION)
1237 1238 ?: $this->get_post_excerpt($post);
1238 1239 $og_image = Metasync_Seo_Precedence::value($post->ID, Metasync_Seo_Precedence::FIELD_OG_IMAGE)
1239 1240 ?: $this->get_featured_image_url($post->ID);
1240 - $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 same two-key chain the canonical tag itself resolves, so the
1244 + # two cannot disagree. meta_canonical is not a legacy alias: the
1245 + # REST sync and the classic-editor Canonical box both still write
1246 + # it, and the GraphQL surface reads both keys as well.
1247 + foreach (array('_metasync_canonical_url', 'meta_canonical') as $canonical_key) {
1248 + $canonical_override = Metasync_Canonical_Sanitizer::sanitize(
1249 + get_post_meta($post->ID, $canonical_key, true)
1250 + );
1251 + if ($canonical_override !== '') {
1252 + break;
1253 + }
1254 + }
1255 + }
1256 +
1257 + # A validated canonical override is the author's final answer. It may
1258 + # deliberately name another host, so it bypasses headless rehosting.
1259 + if ($canonical_override !== '') {
1260 + $og_url = esc_url($canonical_override);
1261 + } else {
1262 + $og_url = get_post_meta($post->ID, '_metasync_og_url', true) ?: $this->get_canonical_url($post);
1263 +
1264 + # On a headless site the rendered page is not the public page, so
1265 + # an own-host og:url must name the frontend. Applied at the single
1266 + # emission source so update_opengraph_url() continues to persist
1267 + # the WordPress URL used outside headless mode.
1268 + $og_url = $this->maybe_build_headless_url($og_url, $post->post_type);
1269 + }
1241 1270 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
1242 1271
1243 1272 # Get Twitter Card data — check persisted key first, fall back to OTTO staging key
1244 1273 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image';
@@ -1757,9 +1786,9 @@
1757 1786 private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') {
1758 1787 # Parse domain from URL
1759 1788 $domain = '';
1760 1789 if (!empty($url)) {
1761 - $parsed = parse_url($url);
1790 + $parsed = wp_parse_url($url);
1762 1791 $domain = $parsed['host'] ?? '';
1763 1792 }
1764 1793
1765 1794 # Fallback to site URL if no domain found
@@ -1764,9 +1793,9 @@
1764 1793
1765 1794 # Fallback to site URL if no domain found
1766 1795 if (empty($domain)) {
1767 1796 $site_url = get_site_url();
1768 - $parsed = parse_url($site_url);
1797 + $parsed = wp_parse_url($site_url);
1769 1798 $domain = $parsed['host'] ?? 'your-site.com';
1770 1799 }
1771 1800
1772 1801 # Provide fallbacks for empty values
@@ -2148,8 +2177,62 @@
2148 2177 return $permalink;
2149 2178 }
2150 2179
2151 2180 /**
2181 + * Rehost a URL onto the public frontend when headless mode is active.
2182 + *
2183 + * Unlike the derived-URL callers elsewhere, the value handed here can be a
2184 + * stored one an editor typed, which may legitimately point at a third-party
2185 + * host. Only a URL on this site's own host is rewritten; anything else is
2186 + * returned untouched. A builder failure is returned as an empty string so
2187 + * the emitter cannot advertise the WordPress host on an active headless site.
2188 + *
2189 + * @param mixed $url URL to consider rehosting.
2190 + * @param string $post_type Post type, for the frontend path prefix.
2191 + * @return mixed Original URL when inactive or foreign; public URL or empty
2192 + * string for an own-host URL while active.
2193 + */
2194 + private function maybe_build_headless_url($url, $post_type = '') {
2195 + if (!class_exists('Metasync_Headless_Config') || !Metasync_Headless_Config::is_active() || !$this->is_own_host_url($url)) {
2196 + return $url;
2197 + }
2198 +
2199 + return Metasync_Headless_Url_Builder::from_wp_url($url, $post_type);
2200 + }
2201 +
2202 + /**
2203 + * Whether a URL addresses this WordPress site rather than a foreign host.
2204 + *
2205 + * Accepts only HTTP(S) URLs on the exact site host, protocol-relative URLs
2206 + * on that host, and genuine site-relative paths beginning with one slash.
2207 + *
2208 + * @param mixed $url URL to test.
2209 + * @return bool
2210 + */
2211 + private function is_own_host_url($url) {
2212 + if (!is_string($url) || $url === '' || preg_match('/\s/', $url)) {
2213 + return false;
2214 + }
2215 +
2216 + if ($url[0] === '/' && (!isset($url[1]) || $url[1] !== '/')) {
2217 + return true;
2218 + }
2219 +
2220 + $is_protocol_relative = strpos($url, '//') === 0;
2221 + $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
2222 + if (!$is_protocol_relative && !in_array($scheme, array('http', 'https'), true)) {
2223 + return false;
2224 + }
2225 +
2226 + $url_host = wp_parse_url($url, PHP_URL_HOST);
2227 + $site_host = wp_parse_url(home_url('/'), PHP_URL_HOST);
2228 +
2229 + return !empty($url_host)
2230 + && !empty($site_host)
2231 + && strcasecmp((string) $url_host, (string) $site_host) === 0;
2232 + }
2233 +
2234 + /**
2152 2235 * Update OpenGraph URL when post is saved
2153 2236 */
2154 2237 public function update_opengraph_url($post_id) {
2155 2238 # Only update for supported post types
@@ -2289,11 +2372,20 @@
2289 2372 /**
2290 2373 * Check if post slug changed via edit slug functionality
2291 2374 */
2292 2375 public function check_slug_change() {
2376 + # Fires on the same admin-ajax request as core's sample-permalink
2377 + # handler, but at priority 5 — before core validates the nonce and
2378 + # capability at its own priority-10 callback. Verify them ourselves
2379 + # before any post-meta write. Return (not die) so core still serves
2380 + # the preview request normally.
2381 + if (!check_ajax_referer('samplepermalink', 'samplepermalinknonce', false)) {
2382 + return;
2383 + }
2384 +
2293 2385 # Get the post ID from the request
2294 2386 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
2295 - if (!$post_id) {
2387 + if (!$post_id || !current_user_can('edit_post', $post_id)) {
2296 2388 return;
2297 2389 }
2298 2390
2299 2391 # Only check for supported post types
@@ -2326,9 +2418,9 @@
2326 2418 # This means the og:url was set to the post permalink (not a custom URL)
2327 2419 if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) {
2328 2420 # Check if the og:url was the old permalink by comparing with a generated old permalink
2329 2421 $old_post = clone $post;
2330 - $old_slug = isset($_POST['new_slug']) ? sanitize_title($_POST['new_slug']) : $post->post_name;
2422 + $old_slug = isset($_POST['new_slug']) ? sanitize_title(wp_unslash($_POST['new_slug'])) : $post->post_name;
2331 2423
2332 2424 # If the og:url doesn't match the current permalink, it might be the old one
2333 2425 # We'll update it to the new permalink
2334 2426 if ($current_og_url !== $current_permalink) {