| 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 |
|