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 / class-metasync-headless-config.php

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

704 lines 25.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Headless mode configuration.
4 *
5 * A headless WordPress install stores and edits content but does not render the
6 * public site — a separate frontend (Next.js, Nuxt, Astro, ...) fetches content
7 * over an API and serves the HTML that visitors and search engines actually see.
8 *
9 * That breaks two assumptions this plugin makes everywhere else:
10 *
11 * 1. Rewriting the HTML WordPress emits is pointless, because nothing crawls it.
12 * 2. Every URL WordPress reports (get_permalink(), get_term_link(), home_url())
13 * names the WordPress host, not the host the page is served from — so
14 * canonicals, og:url and any URL in a payload come out wrong.
15 *
16 * This class owns the settings that describe the public site, so the rest of the
17 * headless code has exactly one place to ask. It is deliberately read-mostly and
18 * side-effect free: no hooks, no writes on read, no network.
19 *
20 * Storage lives under its own `headless` key in the main options array rather
21 * than inside `general`, which keeps it out of the general-settings sanitiser and
22 * makes "absent means off" true for every install that predates this feature.
23 *
24 * @package Metasync
25 * @subpackage Metasync/includes
26 */
27
28 if (!defined('ABSPATH')) {
29 exit; # Exit if accessed directly
30 }
31
32 class Metasync_Headless_Config
33 {
34 /**
35 * Key under the main plugin options array holding every headless setting.
36 */
37 const SETTINGS_KEY = 'headless';
38
39 /**
40 * Trailing-slash policies for generated public URLs.
41 *
42 * `preserve` is the default because it is the only value that cannot change
43 * a URL: it hands back whatever the site's own permalink structure already
44 * produced. `strip` suits frontends that route without trailing slashes;
45 * `add` suits those that require them.
46 */
47 const SLASH_PRESERVE = 'preserve';
48 const SLASH_STRIP = 'strip';
49 const SLASH_ADD = 'add';
50
51 /**
52 * Safe bounds for the stale-while-revalidate refresh interval, in minutes.
53 *
54 * The lower bound keeps a misconfigured value from turning the background
55 * refresh into something close to a per-request poll; the upper bound keeps
56 * it from effectively disabling the safety net. Both are enforced in
57 * normalize(), so a stored or submitted value can never sit outside them.
58 */
59 const REFRESH_INTERVAL_MIN_MINUTES = 5;
60 const REFRESH_INTERVAL_MAX_MINUTES = 1440;
61
62 /**
63 * Default refresh interval, in minutes, per the approved design.
64 */
65 const REFRESH_INTERVAL_DEFAULT_MINUTES = 30;
66
67 /**
68 * Per-request memo of the resolved settings array.
69 *
70 * Headless callers ask these questions per object, so an uncached
71 * implementation would re-read the option once per field per object. Reset
72 * with flush() when settings are written mid-request.
73 *
74 * @var array|null
75 */
76 private static $settings = null;
77
78 /**
79 * True only while this class is writing the option itself.
80 *
81 * See update_settings() for why the Advanced tab's sanitiser needs to know.
82 *
83 * @var bool
84 */
85 private static $writing = false;
86
87 /**
88 * Every setting with its default. The shape of the stored value.
89 *
90 * @return array
91 */
92 public static function defaults()
93 {
94 return array(
95 # Master switch. Off means every headless branch in the plugin is
96 # skipped and execution follows the same path it always has.
97 'enabled' => false,
98
99 # Scheme + host of the public site, no path, no trailing slash —
100 # e.g. https://www.example.com. Empty until configured.
101 'frontend_domain' => '',
102
103 # One of the SLASH_* constants above.
104 'slash_policy' => self::SLASH_PRESERVE,
105
106 # Optional path prefix per post type, keyed by post type name —
107 # e.g. array('post' => 'blog') to serve posts under /blog/... .
108 # Nothing here is a default: a frontend that mirrors WordPress's
109 # paths exactly needs no entries at all.
110 'path_prefixes' => array(),
111
112 # Minutes between background stale-while-revalidate refresh checks
113 # (Metasync_Headless_Refresh_Job). Only consulted while headless mode
114 # is active; the job is a no-op otherwise. See REFRESH_INTERVAL_MIN/MAX
115 # for the enforced bounds.
116 'refresh_interval_minutes' => self::REFRESH_INTERVAL_DEFAULT_MINUTES,
117 );
118 }
119
120 /**
121 * Resolved settings: stored values normalised over the defaults.
122 *
123 * @return array
124 */
125 public static function get_settings()
126 {
127 if (self::$settings === null) {
128 $stored = Metasync::get_option(self::SETTINGS_KEY);
129 self::$settings = self::normalize(is_array($stored) ? $stored : array());
130 }
131
132 return self::$settings;
133 }
134
135 /**
136 * Drop the memo so the next read comes from the database.
137 *
138 * Call after writing settings within the same request.
139 *
140 * @return void
141 */
142 public static function flush()
143 {
144 self::$settings = null;
145 }
146
147 /**
148 * Is headless mode switched on?
149 *
150 * This reports the flag alone. It is the right question for "should the
151 * operator's intent be honoured" — for "can headless URLs actually be
152 * built", ask is_active() instead.
153 *
154 * @return bool
155 */
156 public static function is_enabled()
157 {
158 $settings = self::get_settings();
159 return !empty($settings['enabled']);
160 }
161
162 /**
163 * Is headless mode on *and* usable?
164 *
165 * Gate on this wherever a **URL is emitted**: without a frontend domain there
166 * is nothing to build a public URL from, and a half-configured install would
167 * otherwise put the WordPress host in a canonical while claiming to be
168 * headless — worse than staying on the existing path.
169 *
170 * It is **not** the gate for resolving data. The SEO surface deliberately
171 * gates on is_enabled(), because most of a payload — title, description,
172 * robots, schema — needs no domain, and its URL fields already come back
173 * empty rather than wrong: the builder refuses without a domain. Withholding
174 * the whole payload would discard the valid majority to avoid a few empty
175 * strings, and withdrawing the GraphQL field the moment the domain is cleared
176 * would break a live frontend exactly as switching the mode off does — the
177 * case the settings UI puts a confirmation in front of.
178 *
179 * Two independent reviews read the old wording as an instruction to change
180 * the surface. It is not; the split is deliberate.
181 *
182 * @return bool
183 */
184 public static function is_active()
185 {
186 return self::is_enabled() && self::get_frontend_domain() !== '';
187 }
188
189 /**
190 * Scheme + host of the public site, with no trailing slash.
191 *
192 * @return string Empty string when unconfigured.
193 */
194 public static function get_frontend_domain()
195 {
196 $settings = self::get_settings();
197 return $settings['frontend_domain'];
198 }
199
200 /**
201 * Configured trailing-slash policy.
202 *
203 * @return string One of the SLASH_* constants.
204 */
205 public static function get_slash_policy()
206 {
207 $settings = self::get_settings();
208 return $settings['slash_policy'];
209 }
210
211 /**
212 * Every configured path prefix, keyed by post type.
213 *
214 * @return array<string,string>
215 */
216 public static function get_path_prefixes()
217 {
218 $settings = self::get_settings();
219 return $settings['path_prefixes'];
220 }
221
222 /**
223 * Path prefix configured for one post type.
224 *
225 * Callers pass whatever WordPress handed them — get_post_type() returns
226 * false for a bad ID, and a null slips through just as easily — so a
227 * non-string is treated as "no post type" rather than allowed to become an
228 * array key.
229 *
230 * @param mixed $post_type Post type name.
231 * @return string Empty string when the post type has no prefix.
232 */
233 public static function get_path_prefix($post_type)
234 {
235 $prefixes = self::get_path_prefixes();
236 $post_type = is_string($post_type) ? $post_type : '';
237
238 return isset($prefixes[$post_type]) ? $prefixes[$post_type] : '';
239 }
240
241 /**
242 * Configured stale-while-revalidate refresh interval, in minutes.
243 *
244 * Always within [REFRESH_INTERVAL_MIN_MINUTES, REFRESH_INTERVAL_MAX_MINUTES]
245 * — normalize() clamps on the way in, so callers never have to re-check.
246 *
247 * @return int
248 */
249 public static function get_refresh_interval_minutes()
250 {
251 $settings = self::get_settings();
252 return (int) $settings['refresh_interval_minutes'];
253 }
254
255 /**
256 * Normalise and sanitise a settings array against the defaults.
257 *
258 * The single entry point for untrusted input, so a stored value can never be
259 * a shape the getters do not expect: booleans are booleans, the policy is
260 * always one of the three constants, and the prefix map is always
261 * string => non-empty string.
262 *
263 * @param mixed $input Raw settings.
264 * @return array Sanitised settings, complete with every key.
265 */
266 public static function normalize($input)
267 {
268 $defaults = self::defaults();
269
270 if (!is_array($input)) {
271 return $defaults;
272 }
273
274 $clean = $defaults;
275
276 # Only a scalar can express this. filter_var() reads an array as false,
277 # so a request carrying `enabled[]=1` — a stale form, a hand-built
278 # post, an input name that gained a `[]` somewhere — would silently
279 # switch the mode off. On a live headless site that removes the
280 # GraphQL field and breaks every page, which is far too destructive
281 # an outcome for a malformed value. A shape that cannot be read is
282 # treated as absent instead, leaving whatever was already stored.
283 if (isset($input['enabled']) && self::is_scalarish($input['enabled'])) {
284 $clean['enabled'] = filter_var($input['enabled'], FILTER_VALIDATE_BOOLEAN);
285 }
286
287 if (isset($input['frontend_domain']) && self::is_scalarish($input['frontend_domain'])) {
288 $clean['frontend_domain'] = self::normalize_domain($input['frontend_domain']);
289 }
290
291 if (isset($input['slash_policy']) && self::is_scalarish($input['slash_policy'])) {
292 $policy = is_string($input['slash_policy']) ? strtolower(trim($input['slash_policy'])) : '';
293 if (in_array($policy, self::slash_policies(), true)) {
294 $clean['slash_policy'] = $policy;
295 }
296 }
297
298 # Only an array can express this one, so anything else is the same kind
299 # of malformed request as above — and reading it as an empty map would
300 # wipe every configured prefix.
301 if (isset($input['path_prefixes']) && is_array($input['path_prefixes'])) {
302 $clean['path_prefixes'] = self::normalize_path_prefixes($input['path_prefixes']);
303 }
304
305 if (isset($input['refresh_interval_minutes']) && self::is_scalarish($input['refresh_interval_minutes'])) {
306 $clean['refresh_interval_minutes'] = self::normalize_refresh_interval_minutes(
307 $input['refresh_interval_minutes']
308 );
309 }
310
311 return $clean;
312 }
313
314 /**
315 * Clamp a submitted refresh interval to the safe bounds.
316 *
317 * A non-numeric value (an empty field, a typo) falls back to the default
318 * rather than being dropped — unlike the other settings, a blank interval
319 * field is not a signal to leave the previous value alone, it is the
320 * operator clearing the field, and the safest reading of "cleared" is
321 * "use the shipped default", not "keep whatever the stale UI had".
322 *
323 * @param mixed $value Raw submitted value.
324 * @return int Minutes, within [REFRESH_INTERVAL_MIN_MINUTES, REFRESH_INTERVAL_MAX_MINUTES].
325 */
326 private static function normalize_refresh_interval_minutes($value)
327 {
328 if (!is_numeric($value)) {
329 return self::REFRESH_INTERVAL_DEFAULT_MINUTES;
330 }
331
332 $minutes = (int) round((float) $value);
333
334 return max(self::REFRESH_INTERVAL_MIN_MINUTES, min(self::REFRESH_INTERVAL_MAX_MINUTES, $minutes));
335 }
336
337 /**
338 * Reduce an incoming patch to the keys that can actually be applied.
339 *
340 * Unknown keys go, and so does any value whose shape no form could have
341 * produced for that setting — an array where a scalar belongs, or the
342 * reverse. Both are dropped rather than coerced, because coercing means
343 * overwriting good configuration on the strength of a malformed request.
344 *
345 * @param mixed $input
346 * @return array
347 */
348 private static function usable_patch($input)
349 {
350 if (!is_array($input)) {
351 return array();
352 }
353
354 $patch = array();
355
356 foreach (array('enabled', 'frontend_domain', 'slash_policy') as $key) {
357 if (array_key_exists($key, $input) && self::is_scalarish($input[$key])) {
358 $patch[$key] = $input[$key];
359 }
360 }
361
362 if (array_key_exists('refresh_interval_minutes', $input) && self::is_scalarish($input['refresh_interval_minutes'])) {
363 $patch['refresh_interval_minutes'] = $input['refresh_interval_minutes'];
364 }
365
366 if (array_key_exists('path_prefixes', $input) && is_array($input['path_prefixes'])) {
367 $patch['path_prefixes'] = $input['path_prefixes'];
368 }
369
370 return $patch;
371 }
372
373 /**
374 * Is this a value a single setting could plausibly have been submitted as?
375 *
376 * Guards the difference between "the operator said something invalid" and
377 * "this arrived in a shape no form should produce". The first deserves a
378 * default; the second deserves to be ignored, because acting on it means
379 * overwriting good configuration on the strength of a malformed request.
380 *
381 * @param mixed $value
382 * @return bool
383 */
384 private static function is_scalarish($value)
385 {
386 return is_string($value) || is_int($value) || is_float($value) || is_bool($value);
387 }
388
389 /**
390 * Persist a settings array, sanitising it on the way in.
391 *
392 * Merges into the existing plugin options rather than replacing them, and
393 * refreshes the memo so callers later in the same request see the new
394 * values.
395 *
396 * @param mixed $input Raw settings.
397 * @return array The sanitised settings that were stored.
398 */
399 public static function update_settings($input)
400 {
401 # A patch, not a replacement. Passing one key used to reset the rest to
402 # their defaults, so an update that touched only the domain switched the
403 # mode off as a side effect — and on a live headless site switching the
404 # mode off removes the GraphQL field and breaks every page. Callers
405 # legitimately want to change one setting.
406 #
407 # Keys arriving in a shape no form should produce are dropped before the
408 # merge rather than after it. Dropping them afterwards would leave the
409 # default in place, which is the same destructive outcome by a longer
410 # route: `enabled[]=1` would still end up as false.
411 $clean = self::normalize(array_merge(self::get_settings(), self::usable_patch($input)));
412
413 $options = Metasync::get_option();
414 if (!is_array($options)) {
415 $options = array();
416 }
417
418 $options[self::SETTINGS_KEY] = $clean;
419
420 # register_setting() installs the Advanced tab's sanitiser as a
421 # `sanitize_option_metasync_options` filter, and WordPress runs that on
422 # every update_option() call for the option — not only on Settings API
423 # form posts. The sanitiser drops this key so a stale or hand-built main
424 # form cannot clobber the block, which means it also drops the write
425 # happening right here: the owner of the setting could never store it.
426 # The flag tells the sanitiser that this particular write came from the
427 # owner and should be left alone. try/finally so a raised sanitiser
428 # cannot leave the flag standing and hand the main form a way through.
429 self::$writing = true;
430
431 try {
432 Metasync::set_option($options);
433 } finally {
434 self::$writing = false;
435 }
436
437 self::$settings = $clean;
438
439 return $clean;
440 }
441
442 /**
443 * Is a write from this class in progress right now?
444 *
445 * Only the Advanced tab's sanitiser has any business asking. It exists
446 * because a sanitiser registered on the whole option cannot otherwise tell
447 * the owner of this block apart from an unrelated form that happens to be
448 * carrying the key.
449 *
450 * @return bool
451 */
452 public static function is_writing()
453 {
454 return self::$writing;
455 }
456
457 /**
458 * The valid trailing-slash policies.
459 *
460 * @return string[]
461 */
462 public static function slash_policies()
463 {
464 return array(self::SLASH_PRESERVE, self::SLASH_STRIP, self::SLASH_ADD);
465 }
466
467 /**
468 * Reduce a user-supplied frontend domain to scheme://host[:port].
469 *
470 * Accepts what an operator will realistically paste — a bare host or a URL
471 * with a trailing slash — and refuses a URL with a non-root path. A bare
472 * host is assumed to be https, since a public site a search engine indexes
473 * is not served over plain http in practice.
474 *
475 * @param mixed $value Raw domain or URL.
476 * @return string Normalised origin, or empty string when unusable.
477 */
478 public static function normalize_domain($value)
479 {
480 if (!is_string($value)) {
481 return '';
482 }
483
484 $value = trim($value);
485 if ($value === '') {
486 return '';
487 }
488
489 # A bare host has no scheme to parse; assume https rather than inheriting
490 # the WordPress install's scheme, which may well be http on a local box.
491 if (strpos($value, '//') === false) {
492 $value = 'https://' . ltrim($value, '/');
493 }
494
495 $parts = wp_parse_url($value);
496 if (empty($parts['host'])) {
497 return '';
498 }
499
500 $scheme = isset($parts['scheme']) && $parts['scheme'] !== '' ? strtolower($parts['scheme']) : 'https';
501 if ($scheme !== 'http' && $scheme !== 'https') {
502 return '';
503 }
504
505 $host = strtolower($parts['host']);
506 if (!self::is_plausible_host($host)) {
507 return '';
508 }
509
510 # A frontend served from a subpath (https://example.com/blog) cannot be
511 # expressed by this setting: only per-post-type prefixes exist, so terms,
512 # archives and the homepage could never get the segment back. Silently
513 # keeping just the origin would produce plausible URLs that are all
514 # missing /blog, sitewide, with nothing to indicate why. Refusing leaves
515 # is_active() false, which is visible.
516 $path = isset($parts['path']) ? trim((string) $parts['path'], '/') : '';
517 if ($path !== '') {
518 return '';
519 }
520
521 $port = !empty($parts['port']) ? ':' . (int) $parts['port'] : '';
522
523 return $scheme . '://' . $host . $port;
524 }
525
526 /**
527 * Does this look like a host a browser could actually resolve?
528 *
529 * parse_url() is lenient: give it `https://not a domain at all` and it hands
530 * back that whole phrase as the host. Without this check a typo in the
531 * setting would satisfy is_active() and every canonical on the site would
532 * point at a URL that cannot resolve — worse than staying on the WordPress
533 * host, because it is silently wrong rather than visibly wrong.
534 *
535 * The check is deliberately shape-only, not a DNS lookup: it rejects what
536 * cannot be a host while still accepting an IDN, a bracketed IPv6 literal,
537 * and a single-label internal name such as `localhost`.
538 *
539 * @param string $host Lowercased host from wp_parse_url().
540 * @return bool
541 */
542 private static function is_plausible_host($host)
543 {
544 if ($host === '') {
545 return false;
546 }
547
548 # Bracketed IPv6 literal.
549 if (strpos($host, '[') === 0) {
550 return (bool) preg_match('/^\[[0-9a-f:.]+\]$/', $host);
551 }
552
553 # Letters (including non-ASCII, so an IDN survives), digits, dot, hyphen
554 # and underscore. Anything else — a space, a slash, a stray delimiter —
555 # means the operator pasted something that is not a host.
556 if (!preg_match('/^[\p{L}\p{N}._-]+$/u', $host)) {
557 return false;
558 }
559
560 # A leading or trailing separator, or an empty label, is never a real host.
561 if (strpos($host, '..') !== false) {
562 return false;
563 }
564
565 if (strpbrk(substr($host, 0, 1), '.-') !== false || strpbrk(substr($host, -1), '.-') !== false) {
566 return false;
567 }
568
569 # Rules out a host made only of separators.
570 return (bool) preg_match('/[\p{L}\p{N}]/u', $host);
571 }
572
573 /**
574 * Is every character in this prefix usable inside a URL path?
575 *
576 * Permits letters (including non-ASCII, so a localised prefix survives),
577 * digits, and the small set of separators a path legitimately contains.
578 * Everything else — query and fragment introducers, whitespace, control
579 * characters, percent signs that would double-encode — is refused rather
580 * than escaped, because a prefix is operator configuration and a wrong
581 * one should fail visibly instead of producing a subtly broken URL on
582 * every page of the site.
583 *
584 * @param string $prefix
585 * @return bool
586 */
587 private static function is_usable_path_prefix($prefix)
588 {
589 return (bool) preg_match('#^[\\p{L}\\p{N}/_.~-]+$#u', (string) $prefix);
590 }
591
592 /**
593 * Does this path contain a `.` or `..` segment?
594 *
595 * Checked on the decoded value as well as the raw one, since `%2e%2e` is the
596 * same segment to anything that resolves the URL later.
597 *
598 * @param mixed $path
599 * @return bool
600 */
601 public static function has_relative_segment($path)
602 {
603 if (!is_string($path)) {
604 return false;
605 }
606
607 # Decode to a fixed point rather than once. A single pass turns
608 # `%252e%252e` into the literal `%2e%2e`, which matches no segment and
609 # would sail through — yet a proxy, CDN or frontend router that performs
610 # its own further decode still ends up resolving `..`. The iteration cap
611 # keeps a pathological input from looping.
612 $candidates = array($path);
613 $decoded = $path;
614
615 for ($i = 0; $i < 8; $i++) {
616 $next = rawurldecode($decoded);
617 if ($next === $decoded) {
618 break;
619 }
620
621 $decoded = $next;
622 $candidates[] = $decoded;
623 }
624
625 foreach ($candidates as $candidate) {
626 $candidate = str_replace('\\', '/', $candidate);
627 foreach (explode('/', $candidate) as $segment) {
628 if ($segment === '.' || $segment === '..') {
629 return true;
630 }
631 }
632 }
633
634 return false;
635 }
636
637 /**
638 * Sanitise the post-type => prefix map.
639 *
640 * Prefixes are stored bare — no leading or trailing slash — so the URL
641 * builder can join them without having to guess what the operator typed.
642 * Entries that would contribute nothing are dropped rather than stored as
643 * empty strings, which keeps get_path_prefix() honest.
644 *
645 * @param mixed $value Raw map.
646 * @return array<string,string>
647 */
648 private static function normalize_path_prefixes($value)
649 {
650 if (!is_array($value)) {
651 return array();
652 }
653
654 $clean = array();
655
656 foreach ($value as $post_type => $prefix) {
657 $post_type = sanitize_key((string) $post_type);
658 if ($post_type === '') {
659 continue;
660 }
661
662 if (!is_string($prefix) && !is_numeric($prefix)) {
663 continue;
664 }
665
666 # Keep interior slashes so a nested prefix like `news/posts` works,
667 # but strip the edges and collapse repeats so the builder can rely on
668 # a single separator between segments.
669 $prefix = sanitize_text_field((string) $prefix);
670 $prefix = str_replace('\\', '/', $prefix);
671 $prefix = preg_replace('#/+#', '/', $prefix);
672 $prefix = trim((string) $prefix, "/ \t\n\r\0\x0B");
673
674 if ($prefix === '') {
675 continue;
676 }
677
678 # A relative segment in a prefix is never a real path component, and
679 # a browser or proxy would resolve it away — `../admin` would turn a
680 # canonical into https://frontend/../admin/slug/ and then into
681 # /admin/slug/. Drop the whole prefix rather than store something
682 # that resolves somewhere else.
683 if (self::has_relative_segment($prefix)) {
684 continue;
685 }
686
687 # A prefix is path segments, nothing else. A `?`, `#` or `&` would
688 # land in the middle of a canonical — `blog?x=1` produces
689 # https://frontend/blog?x=1/my-post/, where everything after the
690 # prefix becomes a query string. Whitespace and control characters
691 # are equally not path segments. This mattered less while prefixes
692 # were set by editing the database; it matters once anyone can type
693 # one into a form.
694 if (!self::is_usable_path_prefix($prefix)) {
695 continue;
696 }
697
698 $clean[$post_type] = $prefix;
699 }
700
701 return $clean;
702 }
703 }
704