| @@ -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) { |