PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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 +108 -10 2.6.26 → 2.7.2 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,44 @@
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 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 + }
1241 1277 $og_type = get_post_meta($post->ID, '_metasync_og_type', true) ?: 'article';
1242 1278
1243 1279 # Get Twitter Card data — check persisted key first, fall back to OTTO staging key
1244 1280 $twitter_card = get_post_meta($post->ID, '_metasync_twitter_card', true) ?: 'summary_large_image';
@@ -1740,9 +1776,8 @@
1740 1776 $preview_html = $this->generate_preview_html($title, $description, $image, $url, $twitter_title, $twitter_description, $twitter_image);
1741 1777
1742 1778 if (empty($preview_html)) {
1743 1779 wp_send_json_error(['message' => 'Failed to generate preview HTML']);
1744 - return;
1745 1780 }
1746 1781
1747 1782 wp_send_json_success(['preview' => $preview_html]);
1748 1783
@@ -1757,9 +1792,9 @@
1757 1792 private function generate_preview_html($title, $description, $image, $url, $twitter_title = '', $twitter_description = '', $twitter_image = '') {
1758 1793 # Parse domain from URL
1759 1794 $domain = '';
1760 1795 if (!empty($url)) {
1761 - $parsed = parse_url($url);
1796 + $parsed = wp_parse_url($url);
1762 1797 $domain = $parsed['host'] ?? '';
1763 1798 }
1764 1799
1765 1800 # Fallback to site URL if no domain found
@@ -1764,9 +1799,9 @@
1764 1799
1765 1800 # Fallback to site URL if no domain found
1766 1801 if (empty($domain)) {
1767 1802 $site_url = get_site_url();
1768 - $parsed = parse_url($site_url);
1803 + $parsed = wp_parse_url($site_url);
1769 1804 $domain = $parsed['host'] ?? 'your-site.com';
1770 1805 }
1771 1806
1772 1807 # Provide fallbacks for empty values
@@ -2148,8 +2183,62 @@
2148 2183 return $permalink;
2149 2184 }
2150 2185
2151 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 + /**
2152 2241 * Update OpenGraph URL when post is saved
2153 2242 */
2154 2243 public function update_opengraph_url($post_id) {
2155 2244 # Only update for supported post types
@@ -2289,11 +2378,20 @@
2289 2378 /**
2290 2379 * Check if post slug changed via edit slug functionality
2291 2380 */
2292 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 +
2293 2391 # Get the post ID from the request
2294 2392 $post_id = isset($_POST['post_id']) ? intval($_POST['post_id']) : 0;
2295 - if (!$post_id) {
2393 + if (!$post_id || !current_user_can('edit_post', $post_id)) {
2296 2394 return;
2297 2395 }
2298 2396
2299 2397 # Only check for supported post types
@@ -2326,9 +2424,9 @@
2326 2424 # This means the og:url was set to the post permalink (not a custom URL)
2327 2425 if ($current_og_url !== $current_permalink && strpos($current_og_url, '?p=') === false) {
2328 2426 # Check if the og:url was the old permalink by comparing with a generated old permalink
2329 2427 $old_post = clone $post;
2330 - $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;
2331 2429
2332 2430 # If the og:url doesn't match the current permalink, it might be the old one
2333 2431 # We'll update it to the new permalink
2334 2432 if ($current_og_url !== $current_permalink) {