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
metasync / includes / metasync-helpers.php

metasync-helpers.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at includes/metasync-helpers.php

529 lines 20.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Shared MetaSync helper functions.
4 *
5 * Always loaded (in all request contexts: admin, REST, MCP, AJAX) so that the
6 * custom/LPS page detection rule lives in exactly one place and every SEO
7 * surface applies the same exclusion without copy-paste drift.
8 *
9 * @package Search_Atlas
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 if (!function_exists('metasync_is_custom_or_lps_page')) {
17 /**
18 * Determine whether a post is a MetaSync-built custom page that ships its own
19 * complete, self-contained SEO (Custom HTML pages and LPS-imported pages).
20 *
21 * These pages already include a full SEO head (title, meta description, OG,
22 * Twitter, JSON-LD schema) generated by LPS, so OTTO must not run on them —
23 * otherwise it injects/overwrites SEO from a different/older OTTO project and
24 * produces a page with two contradictory SEO identities.
25 *
26 * @param int $post_id Post ID to inspect.
27 * @return bool True when the post carries any custom-page / LPS marker.
28 */
29 function metasync_is_custom_or_lps_page($post_id){
30 $post_id = (int) $post_id;
31 if ($post_id <= 0 || !class_exists('Metasync_Custom_Pages')) {
32 return false;
33 }
34
35 if (get_post_meta($post_id, Metasync_Custom_Pages::META_IS_CUSTOM_HTML_PAGE, true) === '1') {
36 return true;
37 }
38 if (!empty(get_post_meta($post_id, Metasync_Custom_Pages::META_LPS_IMPORT, true))) {
39 return true;
40 }
41 if (!empty(get_post_meta($post_id, Metasync_Custom_Pages::META_CREATED_VIA_API, true))) {
42 return true;
43 }
44
45 return false;
46 }
47 }
48
49 if (!function_exists('metasync_is_scrape_request')) {
50 /**
51 * Detect WordPress core's internal file-editor "scrape" self-check request.
52 *
53 * When an admin saves a file in the Plugin/Theme Editor, WP core
54 * (wp_edit_theme_plugin_file()) fires a loopback request carrying
55 * wp_scrape_key + wp_scrape_nonce to detect whether the edit white-screened
56 * the site. For a THEME edit that loopback targets home_url('/') — a
57 * front-end GET that is NOT is_admin() — so it slips past OTTO's admin/AJAX/
58 * REST guards in metasync_start_otto(). OTTO must never buffer/rewrite it:
59 * the SimpleHtmlDom rewrite, and any fatal thrown inside the
60 * ob_start() display handler, corrupt the scrape and surface as the
61 * misleading "preg_match(): Cannot use output buffering in output buffering
62 * display handlers" error.
63 *
64 * @return bool True when the current request is a WP core scrape self-check.
65 */
66 function metasync_is_scrape_request(){
67 return isset($_GET['wp_scrape_key']) || isset($_GET['wp_scrape_nonce']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only front-end flag to skip buffering
68 }
69 }
70
71 if (!function_exists('metasync_get_custom_page_exclusion_meta_query')) {
72 /**
73 * Build a WP_Query meta_query fragment that excludes custom/LPS pages.
74 *
75 * Mirrors the sibling predicate metasync_is_custom_or_lps_page(): a post is
76 * excluded when ANY of the three markers is set — _metasync_is_custom_html_page
77 * = '1', or a non-empty _metasync_lps_import / _metasync_created_via_api.
78 * Checking only the first marker let LPS placeholder pages leak into SEO
79 * audits as real pages.
80 *
81 * @return array meta_query fragment.
82 */
83 function metasync_get_custom_page_exclusion_meta_query(){
84 return array(
85 'relation' => 'AND',
86 array(
87 'relation' => 'OR',
88 array(
89 'key' => '_metasync_is_custom_html_page',
90 'compare' => 'NOT EXISTS',
91 ),
92 array(
93 'key' => '_metasync_is_custom_html_page',
94 'value' => '1',
95 'compare' => '!=',
96 ),
97 ),
98 array(
99 'relation' => 'OR',
100 array(
101 'key' => '_metasync_lps_import',
102 'compare' => 'NOT EXISTS',
103 ),
104 // IN ('', '0') mirrors empty() semantics for stored strings.
105 array(
106 'key' => '_metasync_lps_import',
107 'value' => array('', '0'),
108 'compare' => 'IN',
109 ),
110 ),
111 array(
112 'relation' => 'OR',
113 array(
114 'key' => '_metasync_created_via_api',
115 'compare' => 'NOT EXISTS',
116 ),
117 array(
118 'key' => '_metasync_created_via_api',
119 'value' => array('', '0'),
120 'compare' => 'IN',
121 ),
122 ),
123 );
124 }
125 }
126
127 if (!function_exists('metasync_get_custom_page_exclusion_where_sql')) {
128 /**
129 * Build a prepared SQL WHERE fragment that excludes custom/LPS pages.
130 *
131 * Produces three AND-ed NOT EXISTS correlated anti-joins against wp_postmeta.
132 * Unlike WP core's meta_query translation (which generates unanchored
133 * post_id-only LEFT JOINs multiplied across meta rows), this WHERE clause
134 * checks only the 3 specific marker keys and avoids joining postmeta.
135 *
136 * Semantics match metasync_is_custom_or_lps_page() and
137 * metasync_get_custom_page_exclusion_meta_query() exactly:
138 * - Excluded if _metasync_is_custom_html_page = '1'
139 * - Excluded if _metasync_lps_import NOT IN ('', '0')
140 * - Excluded if _metasync_created_via_api NOT IN ('', '0')
141 *
142 * @return string Prepared SQL fragment starting with ' AND ...'
143 */
144 function metasync_get_custom_page_exclusion_where_sql() {
145 global $wpdb;
146
147 if (!isset($wpdb) || !is_object($wpdb) || empty($wpdb->postmeta) || empty($wpdb->posts)) {
148 return '';
149 }
150
151 return $wpdb->prepare(
152 " AND NOT EXISTS (\n"
153 . "\tSELECT 1 FROM {$wpdb->postmeta} AS _ms_ex_html\n"
154 . "\tWHERE _ms_ex_html.post_id = {$wpdb->posts}.ID\n"
155 . "\t\tAND _ms_ex_html.meta_key = %s\n"
156 . "\t\tAND _ms_ex_html.meta_value = %s\n"
157 . ")\n"
158 . "AND NOT EXISTS (\n"
159 . "\tSELECT 1 FROM {$wpdb->postmeta} AS _ms_ex_lps\n"
160 . "\tWHERE _ms_ex_lps.post_id = {$wpdb->posts}.ID\n"
161 . "\t\tAND _ms_ex_lps.meta_key = %s\n"
162 . "\t\tAND _ms_ex_lps.meta_value NOT IN ('', '0')\n"
163 . ")\n"
164 . "AND NOT EXISTS (\n"
165 . "\tSELECT 1 FROM {$wpdb->postmeta} AS _ms_ex_api\n"
166 . "\tWHERE _ms_ex_api.post_id = {$wpdb->posts}.ID\n"
167 . "\t\tAND _ms_ex_api.meta_key = %s\n"
168 . "\t\tAND _ms_ex_api.meta_value NOT IN ('', '0')\n"
169 . ")",
170 '_metasync_is_custom_html_page',
171 '1',
172 '_metasync_lps_import',
173 '_metasync_created_via_api'
174 );
175 }
176 }
177
178 if (!function_exists('metasync_filter_exclude_custom_pages_where')) {
179 /**
180 * WP_Query posts_where filter callback to exclude custom/LPS pages.
181 *
182 * Checks for the sentinel query variable 'metasync_exclude_custom_pages'.
183 *
184 * @param string $where Existing WHERE clause.
185 * @param WP_Query $query Current query.
186 * @return string Modified WHERE clause.
187 */
188 function metasync_filter_exclude_custom_pages_where($where, $query) {
189 if (empty($query->get('metasync_exclude_custom_pages'))) {
190 return $where;
191 }
192
193 return $where . metasync_get_custom_page_exclusion_where_sql();
194 }
195 }
196
197 if (!function_exists('metasync_get_sitemap_post_type_objects')) {
198 /**
199 * Return the post types that may be selected by a sitemap picker.
200 *
201 * Sitemap settings are shared by the general, news, and video sitemap
202 * screens. Keep their visibility rules in one helper so the three pickers
203 * cannot drift apart when a page builder or WordPress adds an internal
204 * post type.
205 *
206 * @return array<int, WP_Post_Type> Viewable public post type objects.
207 */
208 function metasync_get_sitemap_post_type_objects(): array
209 {
210 $default_excluded_post_types = [
211 'attachment',
212 'revision',
213 'nav_menu_item',
214 'elementor_library',
215 'elementor-hf',
216 'e-floating-buttons',
217 'bricks_template',
218 'ct_template',
219 'oxy_user_library',
220 'brizy-template',
221 'fusion_template',
222 'fusion_tb_section',
223 'ae_global_templates',
224 'custom_css',
225 'customize_changeset',
226 'oembed_cache',
227 'user_request',
228 'wp_block',
229 'wp_template',
230 'wp_template_part',
231 'wp_global_styles',
232 'wp_navigation',
233 'acf-field-group',
234 'acf-field',
235 'fl-builder-template',
236 'fl-theme-layout',
237 'wpr_mega_menu',
238 'wpr_templates',
239 ];
240
241 /**
242 * Allow integrations to remove additional internal post types from
243 * all sitemap post-type pickers.
244 *
245 * @param string[] $excluded_post_types Post type slugs to exclude.
246 */
247 /** @var mixed $filtered_excluded_post_types */
248 $filtered_excluded_post_types = apply_filters(
249 'metasync_sitemap_excluded_post_types',
250 $default_excluded_post_types
251 );
252 $excluded_post_types = is_array($filtered_excluded_post_types)
253 ? $filtered_excluded_post_types
254 : $default_excluded_post_types;
255
256 /**
257 * Allow integrations and tests to adjust the public post-type objects
258 * before the common exclusion and viewability rules are applied.
259 *
260 * @param array<string, WP_Post_Type> $post_types Public post types.
261 */
262 /** @var mixed $filtered_post_types */
263 $filtered_post_types = apply_filters(
264 'metasync_sitemap_post_types',
265 get_post_types(['public' => true], 'objects')
266 );
267 $post_types = is_array($filtered_post_types) ? $filtered_post_types : [];
268 $filtered = [];
269 foreach ($post_types as $post_type) {
270 if (!is_object($post_type) || empty($post_type->name)) {
271 continue;
272 }
273 if (in_array($post_type->name, $excluded_post_types, true)) {
274 continue;
275 }
276 if (!is_post_type_viewable($post_type)) {
277 continue;
278 }
279 $filtered[] = $post_type;
280 }
281
282 return $filtered;
283 }
284 }
285
286 if (!function_exists('metasync_repair_scheme_prefixed_media_id')) {
287 /**
288 * Recover a media-library attachment ID from the corrupted value the legacy
289 * logo sanitizer wrote.
290 *
291 * The legacy save path ran the Local Business logo through
292 * sanitize_url() (= esc_url_raw()), which prepends a scheme to any value
293 * that has none. A stored attachment ID such as "45589" therefore became
294 * "http://45589". That shape is unambiguous — "https?://" followed by
295 * digits only, with no dot and no path, which can never be a resolvable
296 * host — so the digits are exactly the original attachment ID and
297 * recovering them is mechanically safe.
298 *
299 * Values that are not of that shape are returned untouched, so this is
300 * safe to call on every read of a media setting: a real URL (which always
301 * carries a dot in its host) and a plain attachment ID both pass through.
302 *
303 * Used by the schema output, the admin preview, and the one-time database
304 * repair migration, so all three can never disagree.
305 *
306 * @param mixed $value Stored media setting (attachment ID, URL, or corrupted value).
307 * @return string|int The attachment ID when $value was corrupted, $value otherwise.
308 */
309 function metasync_repair_scheme_prefixed_media_id($value){
310 if (is_numeric($value) || !is_string($value)) {
311 return $value;
312 }
313
314 if (!preg_match('/^https?:\/\/(\d+)\/?$/i', trim($value), $matches)) {
315 return $value;
316 }
317
318 return (int) $matches[1];
319 }
320 }
321
322 if (!function_exists('metasync_discard_buffered_output')) {
323 /**
324 * Discard any pending output buffers before emitting a machine-readable body.
325 *
326 * Endpoints that serve XML/plain text (sitemaps, llms.txt, the IndexNow key
327 * file) must start at the very first byte of the response. When another
328 * plugin emits output earlier in the request — most commonly a PHP
329 * Deprecated/Notice/Warning rendered as HTML because display_errors or
330 * WP_DEBUG_DISPLAY is on — that text sits in the output buffer and gets
331 * flushed ahead of the `<?xml` declaration, making the response invalid and
332 * causing crawlers to reject the whole document.
333 *
334 * Call immediately before the header()/echo pair. On a healthy request there
335 * is nothing buffered and this is a no-op.
336 *
337 * @return bool True when the output is guaranteed clean; false when stray
338 * bytes could not be discarded (see the two cases below).
339 */
340 function metasync_discard_buffered_output(){
341 // Headers already sent means the buffer was flushed to the client, so the
342 // stray bytes are on the wire and cannot be recalled. Discarding buffers
343 // now would only drop legitimate content.
344 if (headers_sent()) {
345 return false;
346 }
347
348 while (ob_get_level() > 0) {
349 $status = ob_get_status();
350 $flags = isset($status['flags']) ? (int) $status['flags'] : 0;
351 $needed = PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_REMOVABLE;
352
353 // Some buffers cannot be discarded at all — zlib.output_compression
354 // and handlers started without the cleanable/removable flags. Check
355 // first, because calling ob_end_clean() on one emits a PHP notice,
356 // which would add to the very corruption this guards against.
357 if (($flags & $needed) !== $needed) {
358 return false;
359 }
360
361 $level_before = ob_get_level();
362 ob_end_clean();
363
364 // Only keep going while the level is actually falling. Looping on
365 // ob_get_level() alone would spin forever against a buffer that
366 // refuses to close.
367 if (ob_get_level() >= $level_before) {
368 return false;
369 }
370 }
371
372 return true;
373 }
374 }
375
376 if (!function_exists('metasync_escape_json_ld_for_script')) {
377 /**
378 * Escape a JSON document string for safe embedding inside a
379 * `<script type="application/ld+json">` element.
380 *
381 * A JSON-LD block lives inside the HTML document, so a string value that
382 * contains the literal `</script>` (or any `</` that could grow into one)
383 * would let the browser close the script element early and parse the rest
384 * of the payload as page markup (XSS / page corruption). JSON itself has
385 * no closing-tag concept, so the sequence is neutralised by replacing every
386 * `</` with `<\/` — `\/` is a valid JSON escape for `/`, so any
387 * spec-compliant JSON consumer (Google, validators, OTTO) decodes the value
388 * back to identical content.
389 *
390 * @param string $json An already-encoded JSON document.
391 * @return string The escaped JSON, safe to echo inside a script element.
392 */
393 function metasync_escape_json_ld_for_script($json){
394 return str_replace('</', '<\/', (string) $json);
395 }
396 }
397
398 if (!function_exists('metasync_safe_json_ld_encode')) {
399 /**
400 * Encode a JSON-LD document so it is safe to embed inside a
401 * `<script type="application/ld+json">` element.
402 *
403 * Encodes with wp_json_encode() using the caller's flags, then applies
404 * metasync_escape_json_ld_for_script() so a literal `</script>` inside any
405 * string value cannot break out of the surrounding script block. When no
406 * `</` is present the output is byte-identical to wp_json_encode().
407 *
408 * @param mixed $data The JSON-LD document to encode.
409 * @param int $flags json_encode bitmask, e.g. JSON_UNESCAPED_SLASHES.
410 * @return string|false The safely-encoded JSON, or false on encode failure
411 * (mirrors wp_json_encode semantics).
412 */
413 function metasync_safe_json_ld_encode($data, $flags = JSON_UNESCAPED_SLASHES){
414 $json = wp_json_encode($data, $flags);
415
416 if (!is_string($json)) {
417 return $json;
418 }
419
420 return metasync_escape_json_ld_for_script($json);
421 }
422 }
423
424 if (!function_exists('metasync_escape_json_ld_blocks_in_html')) {
425 /**
426 * Escape every JSON-LD script block inside an HTML fragment.
427 *
428 * Remote HTML fragments (e.g. OTTO's header insertion) arrive as assembled
429 * markup, so a JSON string value that contains a literal `</script>` cannot
430 * be fixed with a naive non-greedy `<script>...</script>` match — the match
431 * would stop at the injected closer and leave the payload live. Instead,
432 * for each `application/ld+json` opening tag the content is extended past
433 * successive closing tags until it parses as valid JSON: that is the span
434 * the producer meant to be script content, so every `</` inside it is
435 * neutralised as `<\/` (a valid JSON escape decoding to the same value).
436 *
437 * Blocks whose content never parses as JSON are left untouched rather than
438 * guessing at their extent. Non-JSON-LD script tags are never modified.
439 *
440 * @param string $html HTML fragment possibly containing JSON-LD blocks.
441 * @return string Fragment with JSON-LD payload closers escaped.
442 */
443 function metasync_escape_json_ld_blocks_in_html($html){
444 $html = (string) $html;
445 if (stripos($html, 'application/ld+json') === false) {
446 return $html;
447 }
448
449 preg_match_all('/<script\b[^>]*application\/ld\+json[^>]*>/i', $html, $opens, PREG_OFFSET_CAPTURE);
450 if (empty($opens[0])) {
451 return $html;
452 }
453 if (!preg_match_all('/<\/script\s*>/i', $html, $closers, PREG_OFFSET_CAPTURE)) {
454 return $html;
455 }
456
457 $result = $html;
458 $processed_from = PHP_INT_MAX;
459
460 // Right to left, so escaping a block never invalidates the offsets of
461 // the blocks still to be processed.
462 for ($i = count($opens[0]) - 1; $i >= 0; $i--) {
463 $open = $opens[0][$i];
464 $content_start = $open[1] + strlen($open[0]);
465 if ($open[1] >= $processed_from) {
466 continue; // already inside an escaped span
467 }
468
469 foreach ($closers[0] as $closer) {
470 $end = $closer[1];
471 if ($end <= $content_start) {
472 continue;
473 }
474
475 $candidate = substr($html, $content_start, $end - $content_start);
476 json_decode($candidate);
477 if (json_last_error() !== JSON_ERROR_NONE) {
478 continue; // keep extending to the next closing tag
479 }
480
481 $escaped = str_replace('</', '<\/', $candidate);
482 if ($escaped !== $candidate) {
483 $result = substr_replace($result, $escaped, $content_start, $end - $content_start);
484 }
485 $processed_from = min($processed_from, $content_start);
486 break;
487 }
488 }
489
490 return $result;
491 }
492 }
493
494 /**
495 * Is headless mode active right now?
496 *
497 * Single skew-proof entry point for every caller that needs the headless flag.
498 *
499 * Metasync_Headless_Config is reached ONLY through the committed Composer
500 * classmap — nothing require_once's it, and vendor/composer/autoload_psr4.php
501 * is empty, so there is no fallback lookup path. A partially updated install
502 * (an interrupted upgrade, a half-finished file sync, or stale opcache bytecode
503 * for a single file) can therefore leave a newer caller beside a missing or
504 * older copy of the class, and an unguarded static call fatals. Several callers
505 * run on wp/wp_head, where that fatal is a white screen on every page view
506 * rather than a degraded feature.
507 *
508 * This file IS explicitly require_once'd by metasync.php, so it cannot itself
509 * be the unresolvable one — which is why the guard lives here rather than in a
510 * class that would have the same problem it is trying to solve.
511 *
512 * Treating an unresolvable class as "not headless" is the safe default: the
513 * site keeps rendering exactly as it did before headless mode existed.
514 *
515 * @return bool True only when the class resolves AND reports headless active.
516 */
517 function metasync_headless_is_active()
518 {
519 // class_exists() is redundant to static analysis — the class ships in this
520 // same plugin, so PHPStan proves the call always resolves. It is kept for
521 // the upgrade window described above, which exists only at runtime.
522 // @phpstan-ignore-next-line function.alreadyNarrowedType
523 if (!class_exists('Metasync_Headless_Config')) {
524 return false;
525 }
526
527 return (bool) Metasync_Headless_Config::is_active();
528 }
529