PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / metasync-helpers.php

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

393 lines 15.3 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']);
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 * When AND-combined with any query, this drops posts whose
76 * _metasync_is_custom_html_page marker is set to '1' while keeping all
77 * WordPress-managed posts (where the key is absent or set to anything else).
78 *
79 * @return array meta_query fragment.
80 */
81 function metasync_get_custom_page_exclusion_meta_query(){
82 return array(
83 'relation' => 'OR',
84 array(
85 'key' => '_metasync_is_custom_html_page',
86 'compare' => 'NOT EXISTS',
87 ),
88 array(
89 'key' => '_metasync_is_custom_html_page',
90 'value' => '1',
91 'compare' => '!=',
92 ),
93 );
94 }
95 }
96
97 if (!function_exists('metasync_get_sitemap_post_type_objects')) {
98 /**
99 * Return the post types that may be selected by a sitemap picker.
100 *
101 * Sitemap settings are shared by the general, news, and video sitemap
102 * screens. Keep their visibility rules in one helper so the three pickers
103 * cannot drift apart when a page builder or WordPress adds an internal
104 * post type.
105 *
106 * @return array<int, WP_Post_Type> Viewable public post type objects.
107 */
108 function metasync_get_sitemap_post_type_objects(): array
109 {
110 $default_excluded_post_types = [
111 'attachment',
112 'revision',
113 'nav_menu_item',
114 'elementor_library',
115 'elementor-hf',
116 'e-floating-buttons',
117 'bricks_template',
118 'ct_template',
119 'oxy_user_library',
120 'brizy-template',
121 'fusion_template',
122 'fusion_tb_section',
123 'ae_global_templates',
124 'custom_css',
125 'customize_changeset',
126 'oembed_cache',
127 'user_request',
128 'wp_block',
129 'wp_template',
130 'wp_template_part',
131 'wp_global_styles',
132 'wp_navigation',
133 'acf-field-group',
134 'acf-field',
135 'fl-builder-template',
136 'fl-theme-layout',
137 'wpr_mega_menu',
138 'wpr_templates',
139 ];
140
141 /**
142 * Allow integrations to remove additional internal post types from
143 * all sitemap post-type pickers.
144 *
145 * @param string[] $excluded_post_types Post type slugs to exclude.
146 */
147 /** @var mixed $filtered_excluded_post_types */
148 $filtered_excluded_post_types = apply_filters(
149 'metasync_sitemap_excluded_post_types',
150 $default_excluded_post_types
151 );
152 $excluded_post_types = is_array($filtered_excluded_post_types)
153 ? $filtered_excluded_post_types
154 : $default_excluded_post_types;
155
156 /**
157 * Allow integrations and tests to adjust the public post-type objects
158 * before the common exclusion and viewability rules are applied.
159 *
160 * @param array<string, WP_Post_Type> $post_types Public post types.
161 */
162 /** @var mixed $filtered_post_types */
163 $filtered_post_types = apply_filters(
164 'metasync_sitemap_post_types',
165 get_post_types(['public' => true], 'objects')
166 );
167 $post_types = is_array($filtered_post_types) ? $filtered_post_types : [];
168 $filtered = [];
169 foreach ($post_types as $post_type) {
170 if (!is_object($post_type) || empty($post_type->name)) {
171 continue;
172 }
173 if (in_array($post_type->name, $excluded_post_types, true)) {
174 continue;
175 }
176 if (!is_post_type_viewable($post_type)) {
177 continue;
178 }
179 $filtered[] = $post_type;
180 }
181
182 return $filtered;
183 }
184 }
185
186 if (!function_exists('metasync_repair_scheme_prefixed_media_id')) {
187 /**
188 * Recover a media-library attachment ID from the corrupted value the legacy
189 * logo sanitizer wrote.
190 *
191 * The legacy save path ran the Local Business logo through
192 * sanitize_url() (= esc_url_raw()), which prepends a scheme to any value
193 * that has none. A stored attachment ID such as "45589" therefore became
194 * "http://45589". That shape is unambiguous — "https?://" followed by
195 * digits only, with no dot and no path, which can never be a resolvable
196 * host — so the digits are exactly the original attachment ID and
197 * recovering them is mechanically safe.
198 *
199 * Values that are not of that shape are returned untouched, so this is
200 * safe to call on every read of a media setting: a real URL (which always
201 * carries a dot in its host) and a plain attachment ID both pass through.
202 *
203 * Used by the schema output, the admin preview, and the one-time database
204 * repair migration, so all three can never disagree.
205 *
206 * @param mixed $value Stored media setting (attachment ID, URL, or corrupted value).
207 * @return string|int The attachment ID when $value was corrupted, $value otherwise.
208 */
209 function metasync_repair_scheme_prefixed_media_id($value){
210 if (is_numeric($value) || !is_string($value)) {
211 return $value;
212 }
213
214 if (!preg_match('/^https?:\/\/(\d+)\/?$/i', trim($value), $matches)) {
215 return $value;
216 }
217
218 return (int) $matches[1];
219 }
220 }
221
222 if (!function_exists('metasync_discard_buffered_output')) {
223 /**
224 * Discard any pending output buffers before emitting a machine-readable body.
225 *
226 * Endpoints that serve XML/plain text (sitemaps, llms.txt, the IndexNow key
227 * file) must start at the very first byte of the response. When another
228 * plugin emits output earlier in the request — most commonly a PHP
229 * Deprecated/Notice/Warning rendered as HTML because display_errors or
230 * WP_DEBUG_DISPLAY is on — that text sits in the output buffer and gets
231 * flushed ahead of the `<?xml` declaration, making the response invalid and
232 * causing crawlers to reject the whole document.
233 *
234 * Call immediately before the header()/echo pair. On a healthy request there
235 * is nothing buffered and this is a no-op.
236 *
237 * @return bool True when the output is guaranteed clean; false when stray
238 * bytes could not be discarded (see the two cases below).
239 */
240 function metasync_discard_buffered_output(){
241 // Headers already sent means the buffer was flushed to the client, so the
242 // stray bytes are on the wire and cannot be recalled. Discarding buffers
243 // now would only drop legitimate content.
244 if (headers_sent()) {
245 return false;
246 }
247
248 while (ob_get_level() > 0) {
249 $status = ob_get_status();
250 $flags = isset($status['flags']) ? (int) $status['flags'] : 0;
251 $needed = PHP_OUTPUT_HANDLER_CLEANABLE | PHP_OUTPUT_HANDLER_REMOVABLE;
252
253 // Some buffers cannot be discarded at all — zlib.output_compression
254 // and handlers started without the cleanable/removable flags. Check
255 // first, because calling ob_end_clean() on one emits a PHP notice,
256 // which would add to the very corruption this guards against.
257 if (($flags & $needed) !== $needed) {
258 return false;
259 }
260
261 $level_before = ob_get_level();
262 ob_end_clean();
263
264 // Only keep going while the level is actually falling. Looping on
265 // ob_get_level() alone would spin forever against a buffer that
266 // refuses to close.
267 if (ob_get_level() >= $level_before) {
268 return false;
269 }
270 }
271
272 return true;
273 }
274 }
275
276 if (!function_exists('metasync_escape_json_ld_for_script')) {
277 /**
278 * Escape a JSON document string for safe embedding inside a
279 * `<script type="application/ld+json">` element.
280 *
281 * A JSON-LD block lives inside the HTML document, so a string value that
282 * contains the literal `</script>` (or any `</` that could grow into one)
283 * would let the browser close the script element early and parse the rest
284 * of the payload as page markup (XSS / page corruption). JSON itself has
285 * no closing-tag concept, so the sequence is neutralised by replacing every
286 * `</` with `<\/` — `\/` is a valid JSON escape for `/`, so any
287 * spec-compliant JSON consumer (Google, validators, OTTO) decodes the value
288 * back to identical content.
289 *
290 * @param string $json An already-encoded JSON document.
291 * @return string The escaped JSON, safe to echo inside a script element.
292 */
293 function metasync_escape_json_ld_for_script($json){
294 return str_replace('</', '<\/', (string) $json);
295 }
296 }
297
298 if (!function_exists('metasync_safe_json_ld_encode')) {
299 /**
300 * Encode a JSON-LD document so it is safe to embed inside a
301 * `<script type="application/ld+json">` element.
302 *
303 * Encodes with wp_json_encode() using the caller's flags, then applies
304 * metasync_escape_json_ld_for_script() so a literal `</script>` inside any
305 * string value cannot break out of the surrounding script block. When no
306 * `</` is present the output is byte-identical to wp_json_encode().
307 *
308 * @param mixed $data The JSON-LD document to encode.
309 * @param int $flags json_encode bitmask, e.g. JSON_UNESCAPED_SLASHES.
310 * @return string|false The safely-encoded JSON, or false on encode failure
311 * (mirrors wp_json_encode semantics).
312 */
313 function metasync_safe_json_ld_encode($data, $flags = JSON_UNESCAPED_SLASHES){
314 $json = wp_json_encode($data, $flags);
315
316 if (!is_string($json)) {
317 return $json;
318 }
319
320 return metasync_escape_json_ld_for_script($json);
321 }
322 }
323
324 if (!function_exists('metasync_escape_json_ld_blocks_in_html')) {
325 /**
326 * Escape every JSON-LD script block inside an HTML fragment.
327 *
328 * Remote HTML fragments (e.g. OTTO's header insertion) arrive as assembled
329 * markup, so a JSON string value that contains a literal `</script>` cannot
330 * be fixed with a naive non-greedy `<script>...</script>` match — the match
331 * would stop at the injected closer and leave the payload live. Instead,
332 * for each `application/ld+json` opening tag the content is extended past
333 * successive closing tags until it parses as valid JSON: that is the span
334 * the producer meant to be script content, so every `</` inside it is
335 * neutralised as `<\/` (a valid JSON escape decoding to the same value).
336 *
337 * Blocks whose content never parses as JSON are left untouched rather than
338 * guessing at their extent. Non-JSON-LD script tags are never modified.
339 *
340 * @param string $html HTML fragment possibly containing JSON-LD blocks.
341 * @return string Fragment with JSON-LD payload closers escaped.
342 */
343 function metasync_escape_json_ld_blocks_in_html($html){
344 $html = (string) $html;
345 if (stripos($html, 'application/ld+json') === false) {
346 return $html;
347 }
348
349 preg_match_all('/<script\b[^>]*application\/ld\+json[^>]*>/i', $html, $opens, PREG_OFFSET_CAPTURE);
350 if (empty($opens[0])) {
351 return $html;
352 }
353 if (!preg_match_all('/<\/script\s*>/i', $html, $closers, PREG_OFFSET_CAPTURE)) {
354 return $html;
355 }
356
357 $result = $html;
358 $processed_from = PHP_INT_MAX;
359
360 // Right to left, so escaping a block never invalidates the offsets of
361 // the blocks still to be processed.
362 for ($i = count($opens[0]) - 1; $i >= 0; $i--) {
363 $open = $opens[0][$i];
364 $content_start = $open[1] + strlen($open[0]);
365 if ($open[1] >= $processed_from) {
366 continue; // already inside an escaped span
367 }
368
369 foreach ($closers[0] as $closer) {
370 $end = $closer[1];
371 if ($end <= $content_start) {
372 continue;
373 }
374
375 $candidate = substr($html, $content_start, $end - $content_start);
376 json_decode($candidate);
377 if (json_last_error() !== JSON_ERROR_NONE) {
378 continue; // keep extending to the next closing tag
379 }
380
381 $escaped = str_replace('</', '<\/', $candidate);
382 if ($escaped !== $candidate) {
383 $result = substr_replace($result, $escaped, $content_start, $end - $content_start);
384 }
385 $processed_from = min($processed_from, $content_start);
386 break;
387 }
388 }
389
390 return $result;
391 }
392 }
393