Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
92.74% |
409 / 441 |
|
79.49% |
31 / 39 |
CRAP | |
0.00% |
0 / 1 |
| Dashboard_Data | |
92.74% |
409 / 441 |
|
79.49% |
31 / 39 |
134.26 | |
0.00% |
0 / 1 |
| rest_reads | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| rest_read_paths | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
2 | |||
| register_rest_reads | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
6 | |||
| permission_check | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register_rest_settings | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
2 | |||
| force_setting_args | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| read_boolean_setting | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| option_names | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| settings_definitions | |
100.00% |
54 / 54 |
|
100.00% |
1 / 1 |
1 | |||
| title_format_schemas | |
100.00% |
48 / 48 |
|
100.00% |
1 / 1 |
2 | |||
| front_page_description_option | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| has_legacy_front_page_meta | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| get_front_page_meta_description | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| get_front_page_meta_description_without_helper | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| is_jetpack_seo_enabled | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| is_jetpack_seo_enabled_without_helper | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| after_setting_write | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| after_settings_request | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
8 | |||
| has_module | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| switch_module | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
| is_site_private | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| block_publishing_a_private_site | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| sanitize_front_page_description | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| sanitize_title_formats | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
8 | |||
| sanitize_verification_codes | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
7 | |||
| sanitize_ai_crawler_overrides | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| module_settings | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| register_module_routes | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
| update_modules | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
4 | |||
| apply_module_setting | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
6 | |||
| get_overview_data | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
2 | |||
| normalize_title_formats | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
10 | |||
| get_settings_data | |
96.97% |
32 / 33 |
|
0.00% |
0 / 1 |
9 | |||
| get_google_verify_data | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
3.01 | |||
| get_geo_data | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
4 | |||
| get_content_data | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| get_site_data | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
4.01 | |||
| get_default_title_separator | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
| get_reachable_sitemap_url | |
60.00% |
6 / 10 |
|
0.00% |
0 / 1 |
10.14 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * The SEO dashboard's REST surface: the read-only routes behind each data-backed |
| 4 | * tab (preloaded onto the page so a normal load resolves them with no request), |
| 5 | * the settings the dashboard writes, and the one write that isn't a setting. |
| 6 | * |
| 7 | * Writes go through WordPress core's `/wp/v2/settings` wherever the value is an |
| 8 | * option — see {@see self::register_rest_settings()} — so the dashboard depends |
| 9 | * on nothing but core REST. Module activation is the one thing `register_setting()` |
| 10 | * can't express, and has its own small route ({@see self::register_module_routes()}). |
| 11 | * |
| 12 | * @package automattic/jetpack-seo-package |
| 13 | */ |
| 14 | |
| 15 | namespace Automattic\Jetpack\SEO; |
| 16 | |
| 17 | use Automattic\Jetpack\Modules; |
| 18 | use Automattic\Jetpack\Status\Host; |
| 19 | use Jetpack_SEO_Utils; |
| 20 | use WP_Error; |
| 21 | use WP_REST_Request; |
| 22 | |
| 23 | /** |
| 24 | * Registers the dashboard's REST routes and settings, and builds their payloads. |
| 25 | */ |
| 26 | class Dashboard_Data { |
| 27 | |
| 28 | /** |
| 29 | * REST namespace, shared with the package's other Jetpack routes. |
| 30 | * |
| 31 | * @var string |
| 32 | */ |
| 33 | const REST_NAMESPACE = 'jetpack/v4'; |
| 34 | |
| 35 | /** |
| 36 | * Route, relative to the namespace, for the dashboard toggles that map to |
| 37 | * Jetpack module activation rather than to an option. |
| 38 | * |
| 39 | * @var string |
| 40 | */ |
| 41 | const MODULES_REST_BASE = '/seo/modules'; |
| 42 | |
| 43 | /** |
| 44 | * Settings API group the package registers its options under. |
| 45 | * |
| 46 | * Only the Settings API's `options.php` form handler reads groups, and the |
| 47 | * dashboard doesn't go through it — `register_setting()` requires a group, and |
| 48 | * it's otherwise inert here. |
| 49 | * |
| 50 | * @var string |
| 51 | */ |
| 52 | const SETTINGS_GROUP = 'jetpack_seo'; |
| 53 | |
| 54 | /** |
| 55 | * Option holding the site's verification service codes, one per service. |
| 56 | * |
| 57 | * @var string |
| 58 | */ |
| 59 | const VERIFICATION_CODES_OPTION = 'verification_services_codes'; |
| 60 | |
| 61 | /** |
| 62 | * Option holding the SEO page title structures. |
| 63 | * |
| 64 | * @var string |
| 65 | */ |
| 66 | const TITLE_FORMATS_OPTION = 'advanced_seo_title_formats'; |
| 67 | |
| 68 | /** |
| 69 | * Option holding the front page meta description. |
| 70 | * |
| 71 | * Mirrors `Jetpack_SEO_Utils::FRONT_PAGE_META_OPTION`, which the package can't |
| 72 | * rely on being loaded. Doubles as the REST key for the setting, which stays |
| 73 | * stable even when the value is stored in the legacy option instead |
| 74 | * ({@see self::front_page_description_option()}). |
| 75 | * |
| 76 | * @var string |
| 77 | */ |
| 78 | const FRONT_PAGE_META_OPTION = 'advanced_seo_front_page_description'; |
| 79 | |
| 80 | /** |
| 81 | * Option holding the AI SEO Enhancer's on/off state. |
| 82 | * |
| 83 | * @var string |
| 84 | */ |
| 85 | const AI_SEO_ENHANCER_OPTION = 'ai_seo_enhancer_enabled'; |
| 86 | |
| 87 | /** |
| 88 | * Legacy option the front page meta description is stored in on WordPress.com |
| 89 | * sites that set one while SEO tools were free for every Simple site. Mirrors |
| 90 | * `Jetpack_SEO_Utils::LEGACY_META_OPTION`. |
| 91 | * |
| 92 | * @var string |
| 93 | */ |
| 94 | const LEGACY_FRONT_PAGE_META_OPTION = 'seo_meta_description'; |
| 95 | |
| 96 | /** |
| 97 | * Map of read-only dashboard routes: tab slug => data-builder callable. The |
| 98 | * single source of truth for both the registered routes and the paths |
| 99 | * preloaded onto the page, so the two can't drift. |
| 100 | * |
| 101 | * @return array<string, callable> |
| 102 | */ |
| 103 | private static function rest_reads() { |
| 104 | return array( |
| 105 | 'overview' => array( __CLASS__, 'get_overview_data' ), |
| 106 | 'settings' => array( __CLASS__, 'get_settings_data' ), |
| 107 | 'geo' => array( __CLASS__, 'get_geo_data' ), |
| 108 | 'content' => array( __CLASS__, 'get_content_data' ), |
| 109 | ); |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * REST paths the dashboard reads its initial state from, preloaded into the |
| 114 | * page (see {@see Admin_Page::inject_script_data()}) and fetched by the app. |
| 115 | * |
| 116 | * @return string[] |
| 117 | */ |
| 118 | public static function rest_read_paths() { |
| 119 | return array_map( |
| 120 | static function ( $slug ) { |
| 121 | return '/' . self::REST_NAMESPACE . '/seo/' . $slug; |
| 122 | }, |
| 123 | array_keys( self::rest_reads() ) |
| 124 | ); |
| 125 | } |
| 126 | |
| 127 | /** |
| 128 | * Register the read-only REST routes the dashboard hydrates from — one per |
| 129 | * data-backed tab, each returning the same builder payload previously injected |
| 130 | * synchronously onto the page. Read-only and gated to the page's own |
| 131 | * `manage_options`; writes still go through their existing endpoints. |
| 132 | * |
| 133 | * @return void |
| 134 | */ |
| 135 | public static function register_rest_reads() { |
| 136 | foreach ( self::rest_reads() as $slug => $builder ) { |
| 137 | register_rest_route( |
| 138 | self::REST_NAMESPACE, |
| 139 | '/seo/' . $slug, |
| 140 | array( |
| 141 | 'methods' => \WP_REST_Server::READABLE, |
| 142 | 'callback' => static function () use ( $builder ) { |
| 143 | return rest_ensure_response( call_user_func( $builder ) ); |
| 144 | }, |
| 145 | 'permission_callback' => array( __CLASS__, 'permission_check' ), |
| 146 | ) |
| 147 | ); |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Capability gate for the dashboard's REST routes — the same `manage_options` |
| 153 | * the SEO admin page itself requires, and the same capability the core settings |
| 154 | * controller enforces on the settings the dashboard writes there. |
| 155 | * |
| 156 | * @return bool |
| 157 | */ |
| 158 | public static function permission_check() { |
| 159 | return current_user_can( 'manage_options' ); |
| 160 | } |
| 161 | |
| 162 | /** |
| 163 | * Expose every option the SEO dashboard writes to WordPress core's |
| 164 | * `/wp/v2/settings` endpoint. |
| 165 | * |
| 166 | * The dashboard used to save these through the Jetpack plugin's |
| 167 | * `/jetpack/v4/settings` route, which doesn't exist everywhere the package |
| 168 | * runs — WordPress.com Simple sites boot Jetpack from a small file list that |
| 169 | * doesn't include it, so every write 404'd there. Core's settings endpoint is |
| 170 | * registered by WordPress itself, needs no new route, and enforces the same |
| 171 | * `manage_options` capability, so it works on every platform with one code path. |
| 172 | * |
| 173 | * Only settings registered with `show_in_rest` round-trip through that |
| 174 | * endpoint, and it silently ignores keys that aren't registered — which is why |
| 175 | * this runs behind the same `seo-tools` gate as the rest of the settings |
| 176 | * surface, and why the dashboard hides its controls when that module is off. |
| 177 | * |
| 178 | * Hooked on `rest_api_init`, not earlier: which option the front page description is |
| 179 | * stored in depends on `jetpack_disable_seo_tools`, and a conflicting SEO plugin can |
| 180 | * add that filter from anywhere up to the end of `init`. Registering here settles the |
| 181 | * question after every one of those has had its say, and still before core builds the |
| 182 | * settings route from the registry at priority 99. |
| 183 | * |
| 184 | * @return void |
| 185 | */ |
| 186 | public static function register_rest_settings() { |
| 187 | // Search-engine visibility is a WordPress core option, not a Jetpack one. |
| 188 | register_setting( |
| 189 | 'reading', |
| 190 | 'blog_public', |
| 191 | array( |
| 192 | 'show_in_rest' => true, |
| 193 | 'type' => 'integer', |
| 194 | 'default' => 1, |
| 195 | ) |
| 196 | ); |
| 197 | |
| 198 | // Refuse an indexing write that would publish a private site. Scoped to core's |
| 199 | // settings route rather than attached as `blog_public`'s sanitizer, because a |
| 200 | // `register_setting()` sanitizer applies to every writer of the option for the |
| 201 | // rest of the request — including the site's own visibility control, which must |
| 202 | // stay able to change it. |
| 203 | add_filter( 'rest_pre_update_setting', array( __CLASS__, 'block_publishing_a_private_site' ), 10, 3 ); |
| 204 | |
| 205 | foreach ( self::settings_definitions() as $option => $args ) { |
| 206 | register_setting( self::SETTINGS_GROUP, $option, $args ); |
| 207 | } |
| 208 | |
| 209 | // `verification_services_codes` is also registered by the verification-tools |
| 210 | // module, and the last `register_setting()` for an option name replaces the |
| 211 | // registry entry — which would drop the `show_in_rest` exposure the dashboard |
| 212 | // saves through, leaving core to ignore the write and still answer 200. Rather |
| 213 | // than depend on hook order, force our own arguments back on for the options we |
| 214 | // own, whoever registers them and whenever. Added after the loop above so our |
| 215 | // own registrations don't recurse through it. |
| 216 | add_filter( 'register_setting_args', array( __CLASS__, 'force_setting_args' ), 10, 4 ); |
| 217 | add_filter( 'rest_pre_get_setting', array( __CLASS__, 'read_boolean_setting' ), 10, 3 ); |
| 218 | |
| 219 | // The one thing that has to happen after the write rather than during it: |
| 220 | // dropping the superseded legacy front-page option. Both actions pass the |
| 221 | // option name first, and `added_option` covers a first write, which |
| 222 | // `update_option()` routes through `add_option()`. |
| 223 | add_action( 'add_option_' . self::FRONT_PAGE_META_OPTION, array( __CLASS__, 'after_setting_write' ) ); |
| 224 | add_action( 'update_option_' . self::FRONT_PAGE_META_OPTION, array( __CLASS__, 'after_setting_write' ) ); |
| 225 | add_filter( 'rest_request_after_callbacks', array( __CLASS__, 'after_settings_request' ), 10, 3 ); |
| 226 | } |
| 227 | |
| 228 | /** |
| 229 | * Keep the REST contract of the options this package owns authoritative, no matter |
| 230 | * who else registers them or in what order. |
| 231 | * |
| 232 | * Only the three arguments that decide whether and how the option round-trips |
| 233 | * through `/wp/v2/settings` are forced. Everything else is the caller's: its label |
| 234 | * and description still describe its own surface, and leaving its `sanitize_callback` |
| 235 | * in place is what keeps the verification-tools module's `jetpack_verification_validate` |
| 236 | * attached — and with it the `jetpack_site_verification_validate` action its consumers |
| 237 | * listen for. Our own sanitizer is attached by our own `register_setting()` call and |
| 238 | * stays attached alongside it, since sanitize filters accumulate. |
| 239 | * |
| 240 | * @param array $args Arguments the caller passed to `register_setting()`. |
| 241 | * @param array $defaults Default arguments. |
| 242 | * @param string $group Setting group (unused). |
| 243 | * @param string $option Option name. |
| 244 | * @return array |
| 245 | */ |
| 246 | public static function force_setting_args( $args, $defaults, $group, $option ) { |
| 247 | // Cheap membership test first. This filter fires for every `register_setting()` |
| 248 | // call in the request — core alone registers ~20 — and building the definitions |
| 249 | // rebuilds the title-format schemas and re-decides which option the front page |
| 250 | // description lives in, which on WordPress.com Simple is a plan lookup. |
| 251 | if ( ! in_array( $option, self::option_names(), true ) ) { |
| 252 | return $args; |
| 253 | } |
| 254 | |
| 255 | $ours = self::settings_definitions(); |
| 256 | if ( ! isset( $ours[ $option ] ) ) { |
| 257 | return $args; |
| 258 | } |
| 259 | |
| 260 | return array_merge( |
| 261 | $args, |
| 262 | array( |
| 263 | 'type' => $ours[ $option ]['type'], |
| 264 | // `filter_default_option()` reads this key unconditionally once the option |
| 265 | // is registered, so it has to survive a caller that didn't set one. |
| 266 | 'default' => $ours[ $option ]['default'], |
| 267 | 'show_in_rest' => $ours[ $option ]['show_in_rest'], |
| 268 | ) |
| 269 | ); |
| 270 | } |
| 271 | |
| 272 | /** |
| 273 | * Normalize false options stored as empty strings so core REST returns a boolean, not null. |
| 274 | * |
| 275 | * @param mixed $value Existing filtered setting value. |
| 276 | * @param string $name REST setting name. |
| 277 | * @param array $args REST setting registration arguments. |
| 278 | * @return mixed Filtered setting value. |
| 279 | */ |
| 280 | public static function read_boolean_setting( $value, $name, $args ) { |
| 281 | if ( null !== $value || ! in_array( $name, array( self::AI_SEO_ENHANCER_OPTION, Llms_Txt::OPTION ), true ) ) { |
| 282 | return $value; |
| 283 | } |
| 284 | |
| 285 | return (bool) get_option( $args['option_name'], $args['schema']['default'] ); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Every option name this package can own, including both homes the front page |
| 290 | * description can have. A flat list so {@see self::force_setting_args()} can rule |
| 291 | * an option out without building the definitions. |
| 292 | * |
| 293 | * @return string[] |
| 294 | */ |
| 295 | private static function option_names() { |
| 296 | return array( |
| 297 | self::AI_SEO_ENHANCER_OPTION, |
| 298 | Llms_Txt::OPTION, |
| 299 | Ai_Crawlers::OPTION, |
| 300 | self::TITLE_FORMATS_OPTION, |
| 301 | self::VERIFICATION_CODES_OPTION, |
| 302 | self::FRONT_PAGE_META_OPTION, |
| 303 | self::LEGACY_FRONT_PAGE_META_OPTION, |
| 304 | ); |
| 305 | } |
| 306 | |
| 307 | /** |
| 308 | * The SEO options exposed to `/wp/v2/settings`, keyed by option name. |
| 309 | * |
| 310 | * Types, defaults and sanitizers mirror what `/jetpack/v4/settings` enforced for |
| 311 | * the same keys, so a save behaves the same as before wherever that route exists. |
| 312 | * |
| 313 | * @return array<string, array> |
| 314 | */ |
| 315 | private static function settings_definitions() { |
| 316 | return array( |
| 317 | self::AI_SEO_ENHANCER_OPTION => array( |
| 318 | 'type' => 'boolean', |
| 319 | 'default' => false, |
| 320 | 'show_in_rest' => true, |
| 321 | ), |
| 322 | Llms_Txt::OPTION => array( |
| 323 | 'type' => 'boolean', |
| 324 | 'default' => false, |
| 325 | 'show_in_rest' => true, |
| 326 | ), |
| 327 | Ai_Crawlers::OPTION => array( |
| 328 | 'type' => 'object', |
| 329 | 'default' => array(), |
| 330 | 'sanitize_callback' => array( __CLASS__, 'sanitize_ai_crawler_overrides' ), |
| 331 | 'show_in_rest' => array( |
| 332 | 'schema' => array( |
| 333 | 'type' => 'object', |
| 334 | // A sparse `crawler slug => blocked` map, so the keys are the |
| 335 | // catalog's, not a fixed property list. Ai_Crawlers::get_overrides() |
| 336 | // drops slugs it doesn't know on read. |
| 337 | 'additionalProperties' => array( 'type' => 'boolean' ), |
| 338 | ), |
| 339 | ), |
| 340 | ), |
| 341 | self::TITLE_FORMATS_OPTION => array( |
| 342 | 'type' => 'object', |
| 343 | 'default' => array(), |
| 344 | 'sanitize_callback' => array( __CLASS__, 'sanitize_title_formats' ), |
| 345 | 'show_in_rest' => array( |
| 346 | 'schema' => array( |
| 347 | 'type' => 'object', |
| 348 | 'properties' => self::title_format_schemas(), |
| 349 | ), |
| 350 | ), |
| 351 | ), |
| 352 | self::VERIFICATION_CODES_OPTION => array( |
| 353 | 'type' => 'object', |
| 354 | 'default' => array(), |
| 355 | 'sanitize_callback' => array( __CLASS__, 'sanitize_verification_codes' ), |
| 356 | 'show_in_rest' => array( |
| 357 | 'schema' => array( |
| 358 | 'type' => 'object', |
| 359 | 'properties' => array_fill_keys( |
| 360 | array( 'google', 'bing', 'pinterest', 'yandex', 'facebook' ), |
| 361 | array( 'type' => 'string' ) |
| 362 | ), |
| 363 | ), |
| 364 | ), |
| 365 | ), |
| 366 | self::front_page_description_option() => array( |
| 367 | 'type' => 'string', |
| 368 | 'default' => '', |
| 369 | 'sanitize_callback' => array( __CLASS__, 'sanitize_front_page_description' ), |
| 370 | // The REST key stays the modern option name even when the value lives in |
| 371 | // the legacy one, so the client never has to know which is in play. |
| 372 | 'show_in_rest' => array( 'name' => self::FRONT_PAGE_META_OPTION ), |
| 373 | ), |
| 374 | ); |
| 375 | } |
| 376 | |
| 377 | /** |
| 378 | * Per-page-type schema for a title structure, restoring the contract |
| 379 | * `Jetpack_SEO_Titles::are_valid_title_formats()` enforced on the Jetpack settings |
| 380 | * endpoint: both keys are required (the renderer reads `type` on every public |
| 381 | * request), and a token has to be one this page type actually offers. |
| 382 | * |
| 383 | * @return array<string, array> |
| 384 | */ |
| 385 | private static function title_format_schemas() { |
| 386 | // Mirrors `Jetpack_SEO_Titles::get_allowed_tokens()`. Duplicated rather than |
| 387 | // called: that class ships with the Jetpack plugin, and the schema has to hold |
| 388 | // wherever the package runs. |
| 389 | $allowed_tokens = array( |
| 390 | 'front_page' => array( 'site_name', 'tagline' ), |
| 391 | 'posts' => array( 'site_name', 'tagline', 'post_title' ), |
| 392 | 'pages' => array( 'site_name', 'tagline', 'page_title' ), |
| 393 | 'groups' => array( 'site_name', 'tagline', 'group_title' ), |
| 394 | 'archives' => array( 'site_name', 'tagline', 'date', 'archive_title' ), |
| 395 | ); |
| 396 | |
| 397 | $schemas = array(); |
| 398 | foreach ( $allowed_tokens as $page_type => $tokens ) { |
| 399 | $schemas[ $page_type ] = array( |
| 400 | 'type' => 'array', |
| 401 | 'items' => array( |
| 402 | 'type' => 'object', |
| 403 | // Both keys required: the front-end renderer reads `type` on every |
| 404 | // public request, so a token missing it must never reach storage. |
| 405 | 'required' => array( 'type', 'value' ), |
| 406 | 'properties' => array( |
| 407 | 'type' => array( |
| 408 | 'type' => 'string', |
| 409 | 'enum' => array( 'string', 'token' ), |
| 410 | ), |
| 411 | 'value' => array( 'type' => 'string' ), |
| 412 | ), |
| 413 | // Which values `value` may take depends on `type`, which one flat |
| 414 | // object can't say. Core applies `oneOf` first and then the shape |
| 415 | // above, so the two together are the old helper's contract. |
| 416 | 'oneOf' => array( |
| 417 | array( |
| 418 | 'type' => 'object', |
| 419 | 'properties' => array( |
| 420 | 'type' => array( |
| 421 | 'type' => 'string', |
| 422 | 'enum' => array( 'string' ), |
| 423 | ), |
| 424 | ), |
| 425 | ), |
| 426 | array( |
| 427 | 'type' => 'object', |
| 428 | 'properties' => array( |
| 429 | 'type' => array( |
| 430 | 'type' => 'string', |
| 431 | 'enum' => array( 'token' ), |
| 432 | ), |
| 433 | 'value' => array( |
| 434 | 'type' => 'string', |
| 435 | 'enum' => $tokens, |
| 436 | ), |
| 437 | ), |
| 438 | ), |
| 439 | ), |
| 440 | ), |
| 441 | ); |
| 442 | } |
| 443 | |
| 444 | return $schemas; |
| 445 | } |
| 446 | |
| 447 | /** |
| 448 | * The option the front page meta description is stored in for this site. |
| 449 | * |
| 450 | * A WordPress.com site that set a description while SEO tools were free for every |
| 451 | * Simple site keeps editing that legacy option while it's plan-gated, because the |
| 452 | * front end still reads the description from there. Decided from the site's own |
| 453 | * state — the gate and the stored value — rather than from `Jetpack_SEO_Utils`, |
| 454 | * which ships with the Jetpack plugin: reading it through `class_exists()` would |
| 455 | * quietly answer "modern" wherever the plugin's SEO files aren't loaded, and the |
| 456 | * save would then overwrite the wrong option and drop a live legacy description. |
| 457 | * |
| 458 | * @return string |
| 459 | */ |
| 460 | private static function front_page_description_option() { |
| 461 | return self::has_legacy_front_page_meta() |
| 462 | ? self::LEGACY_FRONT_PAGE_META_OPTION |
| 463 | : self::FRONT_PAGE_META_OPTION; |
| 464 | } |
| 465 | |
| 466 | /** |
| 467 | * Whether the front page description still lives in the legacy option — Jetpack's |
| 468 | * SEO output is off for this site and a description is stored there, so that's what |
| 469 | * the front end reads and what an edit has to write back to. |
| 470 | * |
| 471 | * The rule `Jetpack_SEO_Utils::has_legacy_front_page_meta()` applies, evaluated from |
| 472 | * the site's own state so that the read path, the write path and the front end can't |
| 473 | * disagree on a site where that class isn't loaded. |
| 474 | * |
| 475 | * @return bool |
| 476 | */ |
| 477 | private static function has_legacy_front_page_meta() { |
| 478 | return ! self::is_jetpack_seo_enabled() && (bool) get_option( self::LEGACY_FRONT_PAGE_META_OPTION ); |
| 479 | } |
| 480 | |
| 481 | /** |
| 482 | * The front page meta description this site's front end actually emits. |
| 483 | * |
| 484 | * `Jetpack_SEO_Utils::get_front_page_meta_description()` reproduced for sites that |
| 485 | * don't load it — which includes WordPress.com Simple, where reading it through |
| 486 | * `class_exists()` returned `''` for every site and left the Settings field blank |
| 487 | * over a live description. Same shape as {@see self::is_jetpack_seo_enabled()}: |
| 488 | * defer to the helper wherever it's loaded, reproduce it exactly everywhere else, |
| 489 | * so the field the user edits is the value the front end reads. |
| 490 | * |
| 491 | * @return string |
| 492 | */ |
| 493 | private static function get_front_page_meta_description() { |
| 494 | if ( class_exists( 'Jetpack_SEO_Utils' ) ) { |
| 495 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists. |
| 496 | return (string) Jetpack_SEO_Utils::get_front_page_meta_description(); |
| 497 | } |
| 498 | |
| 499 | return self::get_front_page_meta_description_without_helper(); |
| 500 | } |
| 501 | |
| 502 | /** |
| 503 | * `Jetpack_SEO_Utils::get_front_page_meta_description()` reproduced for sites that |
| 504 | * don't load it — which includes WordPress.com Simple. |
| 505 | * |
| 506 | * Split out from {@see self::get_front_page_meta_description()} so this branch is |
| 507 | * reachable from a test even where the helper class is loaded, the same way |
| 508 | * {@see self::is_jetpack_seo_enabled_without_helper()} is. |
| 509 | * |
| 510 | * @return string |
| 511 | */ |
| 512 | private static function get_front_page_meta_description_without_helper() { |
| 513 | if ( self::is_jetpack_seo_enabled_without_helper() ) { |
| 514 | $modern = get_option( self::FRONT_PAGE_META_OPTION ); |
| 515 | return (string) ( $modern ? $modern : get_option( self::LEGACY_FRONT_PAGE_META_OPTION, '' ) ); |
| 516 | } |
| 517 | |
| 518 | return (string) get_option( self::LEGACY_FRONT_PAGE_META_OPTION, '' ); |
| 519 | } |
| 520 | |
| 521 | /** |
| 522 | * Whether Jetpack's SEO output is live for this site, which is what decides |
| 523 | * where the front page description is stored and read from. |
| 524 | * |
| 525 | * Defers to `Jetpack_SEO_Utils` wherever it's loaded, because that helper is what the |
| 526 | * front end reads the description by and the two must not disagree. Everywhere else |
| 527 | * {@see self::is_jetpack_seo_enabled_without_helper()} reproduces it. |
| 528 | * |
| 529 | * @return bool |
| 530 | */ |
| 531 | private static function is_jetpack_seo_enabled() { |
| 532 | if ( class_exists( 'Jetpack_SEO_Utils' ) ) { |
| 533 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists. |
| 534 | return (bool) Jetpack_SEO_Utils::is_enabled_jetpack_seo(); |
| 535 | } |
| 536 | |
| 537 | return self::is_jetpack_seo_enabled_without_helper(); |
| 538 | } |
| 539 | |
| 540 | /** |
| 541 | * `Jetpack_SEO_Utils::is_enabled_jetpack_seo()` reproduced for sites that don't load |
| 542 | * it — which includes WordPress.com Simple, where this decides which option a save |
| 543 | * targets. |
| 544 | * |
| 545 | * Exactly, not approximately: the conflicting-plugin filter, then the plan gate on |
| 546 | * WordPress.com **Simple only**. The helper plan-gates behind `IS_WPCOM`, so an |
| 547 | * Atomic site is never gated by it even without `advanced-seo`, and a wider copy |
| 548 | * would send that site's edits to the legacy option while its front end kept reading |
| 549 | * the modern one. |
| 550 | * |
| 551 | * Split out from {@see self::is_jetpack_seo_enabled()} so this branch is reachable |
| 552 | * from a test even where the helper class is loaded. |
| 553 | * |
| 554 | * @return bool |
| 555 | */ |
| 556 | private static function is_jetpack_seo_enabled_without_helper() { |
| 557 | /** This filter is documented in projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-utils.php */ |
| 558 | if ( apply_filters( 'jetpack_disable_seo_tools', false ) ) { |
| 559 | return false; |
| 560 | } |
| 561 | |
| 562 | // `Initializer::is_gated()` on Simple reduces to the helper's own |
| 563 | // `wpcom_site_has_feature( 'advanced-seo' )` check. |
| 564 | return ( new Host() )->is_wpcom_simple() ? ! Initializer::is_gated() : true; |
| 565 | } |
| 566 | |
| 567 | /** |
| 568 | * Drop the superseded legacy front page description once a modern one is saved. |
| 569 | * |
| 570 | * The front end falls back to the legacy option whenever the modern value is |
| 571 | * empty, so leaving it in place would let a cleared description resurrect text the |
| 572 | * site set years ago. Matches what the Jetpack settings endpoint did on the same |
| 573 | * write, including its condition: only where Jetpack's SEO output is actually live, |
| 574 | * so a gated site's legacy description is never touched. |
| 575 | * |
| 576 | * Hooked to the option-scoped `add_option_*` / `update_option_*` actions rather |
| 577 | * than the site-wide ones, so it fires on a real change to this one option instead |
| 578 | * of being consulted on every option write in the request. |
| 579 | * |
| 580 | * @return void |
| 581 | */ |
| 582 | public static function after_setting_write() { |
| 583 | if ( self::is_jetpack_seo_enabled() ) { |
| 584 | delete_option( self::LEGACY_FRONT_PAGE_META_OPTION ); |
| 585 | } |
| 586 | } |
| 587 | |
| 588 | /** |
| 589 | * Clear legacy text after a successful empty save, including an unchanged modern option. |
| 590 | * |
| 591 | * An unchanged option does not fire the add/update hooks, so an explicit REST clear |
| 592 | * must also remove legacy text that the front end would otherwise use as a fallback. |
| 593 | * |
| 594 | * @param mixed $response REST response. |
| 595 | * @param array $handler Route handler (unused). |
| 596 | * @param WP_REST_Request $request REST request. |
| 597 | * @return mixed |
| 598 | */ |
| 599 | public static function after_settings_request( $response, $handler, WP_REST_Request $request ) { |
| 600 | if ( is_wp_error( $response ) || rest_ensure_response( $response )->get_status() >= 400 |
| 601 | || ! preg_match( '#^/wp/v2/(?:sites/[^/]+/)?settings$#', $request->get_route() ) |
| 602 | || ! in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH' ), true ) |
| 603 | || '' !== $request->get_param( self::FRONT_PAGE_META_OPTION ) ) { |
| 604 | return $response; |
| 605 | } |
| 606 | |
| 607 | $settings = get_registered_settings(); |
| 608 | if ( isset( $settings[ self::FRONT_PAGE_META_OPTION ] ) && '' === get_option( self::FRONT_PAGE_META_OPTION, '' ) ) { |
| 609 | self::after_setting_write(); |
| 610 | } |
| 611 | |
| 612 | return $response; |
| 613 | } |
| 614 | |
| 615 | /** |
| 616 | * Whether this site has the given Jetpack module at all. |
| 617 | * |
| 618 | * Deliberately not `Modules::is_module()`: that's a path-traversal check, and it |
| 619 | * passes every slug on a site whose module list is empty — which is the case this |
| 620 | * has to detect. WordPress.com Simple ships no Jetpack modules and provides the |
| 621 | * behavior itself. |
| 622 | * |
| 623 | * @param string $module Module slug. |
| 624 | * @return bool |
| 625 | */ |
| 626 | private static function has_module( $module ) { |
| 627 | // Simple's platform-managed generator is not controlled by module activation. |
| 628 | if ( 'sitemaps' === $module && ( new Host() )->is_wpcom_simple() ) { |
| 629 | return false; |
| 630 | } |
| 631 | |
| 632 | return in_array( $module, ( new Modules() )->get_available(), true ); |
| 633 | } |
| 634 | |
| 635 | /** |
| 636 | * Switch a module without persisting wpcomsh's temporary private-site suppression. |
| 637 | * |
| 638 | * `Modules::is_active()` reads through `jetpack_active_modules`, where wpcomsh |
| 639 | * hides `sitemaps` and `verification-tools` on private and coming-soon sites. |
| 640 | * That filtered state cannot tell whether a saved preference is already off: |
| 641 | * it reports false even when the module remains in the site's stored list. |
| 642 | * Deactivation also reads that list through the filter, so lifting the callback |
| 643 | * only for the initial check or final read-back would still leave the switch broken. |
| 644 | * |
| 645 | * Lift only wpcomsh's callback for the entire read-modify-write. Other filters |
| 646 | * still count, so a module genuinely held off remains an error. An isolated hook |
| 647 | * copy and the finally block restore the original suppression even after a failure; |
| 648 | * saving a module preference does not change the site's visibility. |
| 649 | * |
| 650 | * @param string $module Module slug. |
| 651 | * @param bool $enabled Whether the setting is on. |
| 652 | * @return bool Whether the module now matches the setting. |
| 653 | */ |
| 654 | private static function switch_module( $module, $enabled ) { |
| 655 | global $wp_filter; |
| 656 | |
| 657 | $modules = new Modules(); |
| 658 | $hook = 'jetpack_active_modules'; |
| 659 | $callback = '\Private_Site\filter_jetpack_active_modules'; |
| 660 | $priority = has_filter( $hook, $callback ); |
| 661 | $original = null; |
| 662 | |
| 663 | if ( false !== $priority ) { |
| 664 | $original = $wp_filter[ $hook ]; |
| 665 | |
| 666 | // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the original hook after the switch. |
| 667 | $wp_filter[ $hook ] = clone $original; |
| 668 | remove_filter( $hook, $callback, $priority ); |
| 669 | } |
| 670 | |
| 671 | try { |
| 672 | if ( $modules->is_active( $module ) === $enabled ) { |
| 673 | return true; |
| 674 | } |
| 675 | |
| 676 | $modules->update_status( $module, $enabled, false, false ); |
| 677 | |
| 678 | return $modules->is_active( $module ) === $enabled; |
| 679 | } finally { |
| 680 | if ( null !== $original ) { |
| 681 | // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the untouched original hook. |
| 682 | $wp_filter[ $hook ] = $original; |
| 683 | } |
| 684 | } |
| 685 | } |
| 686 | |
| 687 | /** |
| 688 | * Whether the site is private or still in coming-soon, which WordPress.com records |
| 689 | * as a negative `blog_public`. Always false on self-hosted, where the option is a |
| 690 | * plain 0/1. |
| 691 | * |
| 692 | * @return bool |
| 693 | */ |
| 694 | private static function is_site_private() { |
| 695 | return (int) get_option( 'blog_public', 1 ) < 0; |
| 696 | } |
| 697 | |
| 698 | /** |
| 699 | * Keep a WordPress.com site's private or coming-soon state out of reach of an SEO |
| 700 | * toggle. |
| 701 | * |
| 702 | * `blog_public` is a plain 0/1 on self-hosted, but WordPress.com also stores `-1` |
| 703 | * for a private site and `-2` for one still in coming-soon. This dashboard only |
| 704 | * offers "allow search engines to index this site", which writes 1 or 0 — so |
| 705 | * without this, the owner of an unfinished site who flipped that toggle on |
| 706 | * published it. Publishing a site is not a search-engine setting and isn't a |
| 707 | * decision this surface asks for, so a negative stored value is left alone. |
| 708 | * |
| 709 | * The site's own visibility control is what changes it — and still can, because |
| 710 | * this hangs off core's settings route rather than the option's sanitizer. |
| 711 | * {@see self::get_settings_data()} reports `site_is_private` so the toggle can say |
| 712 | * why it's disabled rather than silently doing nothing. |
| 713 | * |
| 714 | * @param bool $handled Whether another handler already wrote the setting. |
| 715 | * @param string $name Setting name. |
| 716 | * @param mixed $value Submitted value. |
| 717 | * @return bool True to report the write as handled, which skips it. |
| 718 | */ |
| 719 | public static function block_publishing_a_private_site( $handled, $name, $value ) { |
| 720 | if ( $handled || 'blog_public' !== $name ) { |
| 721 | return $handled; |
| 722 | } |
| 723 | |
| 724 | // Leave a write that keeps the site unpublished alone; only a move to a public |
| 725 | // value is refused. |
| 726 | return self::is_site_private() && (int) $value >= 0; |
| 727 | } |
| 728 | |
| 729 | /** |
| 730 | * Clamp the front page meta description to plain text of a bounded length. |
| 731 | * |
| 732 | * @param string $value Submitted description. |
| 733 | * @return string |
| 734 | */ |
| 735 | public static function sanitize_front_page_description( $value ) { |
| 736 | $value = wp_strip_all_tags( (string) $value ); |
| 737 | |
| 738 | /** This filter is documented in projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-utils.php */ |
| 739 | $max_length = (int) apply_filters( 'jetpack_seo_front_page_description_max_length', 300 ); |
| 740 | |
| 741 | return function_exists( 'mb_substr' ) |
| 742 | ? mb_substr( $value, 0, $max_length ) |
| 743 | : substr( $value, 0, $max_length ); |
| 744 | } |
| 745 | |
| 746 | /** |
| 747 | * Strip markup out of the literal text a title structure joins its tokens with. |
| 748 | * |
| 749 | * Deliberately not `wp_strip_all_tags()`: that trims, and the spacing around a |
| 750 | * separator ("Post Title | Site Name") is exactly what the user typed and has to |
| 751 | * survive. Token values are left alone — the schema already limits a token to a |
| 752 | * string, and an unrecognized one simply renders as itself. |
| 753 | * |
| 754 | * @param array $value Submitted title structures, keyed by page type. |
| 755 | * @return array |
| 756 | */ |
| 757 | public static function sanitize_title_formats( $value ) { |
| 758 | if ( ! is_array( $value ) ) { |
| 759 | return array(); |
| 760 | } |
| 761 | |
| 762 | foreach ( $value as $page_type => $tokens ) { |
| 763 | if ( ! is_array( $tokens ) ) { |
| 764 | unset( $value[ $page_type ] ); |
| 765 | continue; |
| 766 | } |
| 767 | |
| 768 | foreach ( $tokens as $index => $token ) { |
| 769 | if ( ! isset( $token['value'] ) || ! isset( $token['type'] ) || 'string' !== $token['type'] ) { |
| 770 | continue; |
| 771 | } |
| 772 | |
| 773 | $text = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', (string) $token['value'] ); |
| 774 | $text = strip_tags( $text ); // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- wp_strip_all_tags() trims, and leading/trailing spacing is meaningful here. |
| 775 | |
| 776 | $value[ $page_type ][ $index ]['value'] = preg_replace( '/[\r\n\t ]+/', ' ', $text ); |
| 777 | } |
| 778 | } |
| 779 | |
| 780 | return $value; |
| 781 | } |
| 782 | |
| 783 | /** |
| 784 | * Reduce each submitted verification entry to the bare code, merged over the |
| 785 | * codes already stored. |
| 786 | * |
| 787 | * Services hand out a whole `<meta name="…" content="…">` tag and site owners |
| 788 | * paste it verbatim, so keep only the content attribute — the same thing the |
| 789 | * verification-tools module does when it renders the tag. Merging rather than |
| 790 | * replacing keeps any service the dashboard doesn't offer intact. |
| 791 | * |
| 792 | * @param array $value Submitted codes, keyed by service. |
| 793 | * @return array |
| 794 | */ |
| 795 | public static function sanitize_verification_codes( $value ) { |
| 796 | $stored = get_option( self::VERIFICATION_CODES_OPTION, array() ); |
| 797 | if ( ! is_array( $stored ) ) { |
| 798 | $stored = array(); |
| 799 | } |
| 800 | |
| 801 | if ( ! is_array( $value ) ) { |
| 802 | return $stored; |
| 803 | } |
| 804 | |
| 805 | foreach ( $value as $service => $code ) { |
| 806 | $service = sanitize_key( $service ); |
| 807 | $code = (string) $code; |
| 808 | |
| 809 | // Anything that isn't already a bare code has to yield one, by unwrapping the |
| 810 | // whole `<meta …>` tag a service hands out. A value that yields no code at |
| 811 | // all is not a verification code, and the stored one is kept rather than |
| 812 | // overwritten: the old `/jetpack/v4/settings` route rejected that input with |
| 813 | // a 400 and left the saved code intact, and silently destroying it is the |
| 814 | // worse of the two failures. (Keeping it also survives the verification-tools |
| 815 | // module's own sanitizer running after ours, which sees a valid bare code.) |
| 816 | // An empty string is a deliberate clear, not a failed parse, so it passes. |
| 817 | if ( '' !== $code && ! preg_match( '/^[a-z0-9_-]+$/i', $code ) ) { |
| 818 | if ( ! preg_match( '/content=["\']?([^"\' ]*)["\' ]/i', $code, $matches ) ) { |
| 819 | continue; |
| 820 | } |
| 821 | $code = urldecode( $matches[1] ); |
| 822 | } |
| 823 | |
| 824 | // 100 chars is the cap the verification-tools module stores by. |
| 825 | $stored[ $service ] = substr( sanitize_text_field( trim( $code ) ), 0, 100 ); |
| 826 | } |
| 827 | |
| 828 | return $stored; |
| 829 | } |
| 830 | |
| 831 | /** |
| 832 | * Normalize the AI crawler override map to `slug => bool`. |
| 833 | * |
| 834 | * @param array $value Submitted overrides. |
| 835 | * @return array |
| 836 | */ |
| 837 | public static function sanitize_ai_crawler_overrides( $value ) { |
| 838 | if ( ! is_array( $value ) ) { |
| 839 | return array(); |
| 840 | } |
| 841 | |
| 842 | $sanitized = array(); |
| 843 | foreach ( $value as $slug => $blocked ) { |
| 844 | $sanitized[ sanitize_key( $slug ) ] = (bool) $blocked; |
| 845 | } |
| 846 | |
| 847 | return $sanitized; |
| 848 | } |
| 849 | |
| 850 | /** |
| 851 | * Map the dashboard's module-backed fields to module slugs. |
| 852 | * |
| 853 | * These are the settings core's `/wp/v2/settings` cannot own. Switching a module |
| 854 | * can fail, and that failure must reach the user rather than return a successful |
| 855 | * response with an unchanged stored value. A `register_setting()` sanitizer is |
| 856 | * also the wrong place for the switch: it runs before the write and can run on |
| 857 | * paths where nothing is persisted. The module route switches the module, checks |
| 858 | * the result, and returns a real error when the requested state was not reached. |
| 859 | * |
| 860 | * @return array<string, string> |
| 861 | */ |
| 862 | private static function module_settings() { |
| 863 | return array( |
| 864 | 'sitemap_active' => 'sitemaps', |
| 865 | 'canonical_active' => 'canonical-urls', |
| 866 | 'verification_tools_active' => 'verification-tools', |
| 867 | ); |
| 868 | } |
| 869 | |
| 870 | /** |
| 871 | * Register the write route for the dashboard settings that switch a Jetpack module. |
| 872 | * |
| 873 | * Write-only: the current state is already served by `/jetpack/v4/seo/settings`, |
| 874 | * which the dashboard reads on load. |
| 875 | * |
| 876 | * @return void |
| 877 | */ |
| 878 | public static function register_module_routes() { |
| 879 | $args = array(); |
| 880 | foreach ( array_keys( self::module_settings() ) as $field ) { |
| 881 | // All optional: the dashboard sends only what changed. |
| 882 | $args[ $field ] = array( 'type' => 'boolean' ); |
| 883 | } |
| 884 | |
| 885 | register_rest_route( |
| 886 | self::REST_NAMESPACE, |
| 887 | self::MODULES_REST_BASE, |
| 888 | array( |
| 889 | 'methods' => \WP_REST_Server::EDITABLE, |
| 890 | 'callback' => array( __CLASS__, 'update_modules' ), |
| 891 | 'permission_callback' => array( __CLASS__, 'permission_check' ), |
| 892 | 'args' => $args, |
| 893 | ) |
| 894 | ); |
| 895 | } |
| 896 | |
| 897 | /** |
| 898 | * Apply the submitted module-backed settings, and report what was applied. |
| 899 | * |
| 900 | * @param WP_REST_Request $request The REST request. |
| 901 | * @return \WP_REST_Response|WP_Error |
| 902 | */ |
| 903 | public static function update_modules( WP_REST_Request $request ) { |
| 904 | $applied = array(); |
| 905 | |
| 906 | foreach ( self::module_settings() as $field => $module ) { |
| 907 | if ( null === $request[ $field ] ) { |
| 908 | continue; |
| 909 | } |
| 910 | |
| 911 | $enabled = (bool) $request[ $field ]; |
| 912 | |
| 913 | $error = self::apply_module_setting( $module, $enabled ); |
| 914 | if ( is_wp_error( $error ) ) { |
| 915 | // No rollback across fields — module activation fires hooks that other |
| 916 | // code has already reacted to, and undoing those is riskier than the |
| 917 | // inconsistency. Report which field failed and what did land instead, so |
| 918 | // the client can be specific about it. |
| 919 | $error->add_data( |
| 920 | array_merge( |
| 921 | (array) $error->get_error_data(), |
| 922 | array( |
| 923 | 'field' => $field, |
| 924 | 'applied' => $applied, |
| 925 | ) |
| 926 | ) |
| 927 | ); |
| 928 | return $error; |
| 929 | } |
| 930 | |
| 931 | $applied[ $field ] = $enabled; |
| 932 | } |
| 933 | |
| 934 | return rest_ensure_response( $applied ); |
| 935 | } |
| 936 | |
| 937 | /** |
| 938 | * Switch a module and report a failure instead of saving a duplicate option. |
| 939 | * |
| 940 | * Module state is the source of truth; there is no second option to write or roll |
| 941 | * back. Simple has no modules, so its canonical switch is {@see Canonical_Urls::OPTION}. |
| 942 | * |
| 943 | * @param string $module Module slug. |
| 944 | * @param bool $enabled Requested state. |
| 945 | * @return true|WP_Error |
| 946 | */ |
| 947 | private static function apply_module_setting( $module, $enabled ) { |
| 948 | if ( 'canonical-urls' === $module && ( new Host() )->is_wpcom_simple() ) { |
| 949 | update_option( Canonical_Urls::OPTION, $enabled ? 1 : 0 ); |
| 950 | return true; |
| 951 | } |
| 952 | |
| 953 | if ( ! self::has_module( $module ) ) { |
| 954 | return new WP_Error( |
| 955 | 'jetpack_seo_module_unavailable', |
| 956 | /* translators: %s: name of a Jetpack module, e.g. "sitemaps". */ |
| 957 | sprintf( __( 'This site has no %s module to switch.', 'jetpack-seo' ), $module ), |
| 958 | array( 'status' => 400 ) |
| 959 | ); |
| 960 | } |
| 961 | |
| 962 | if ( self::switch_module( $module, $enabled ) ) { |
| 963 | return true; |
| 964 | } |
| 965 | |
| 966 | return new WP_Error( |
| 967 | 'jetpack_seo_module_toggle_failed', |
| 968 | /* translators: %s: name of a Jetpack module, e.g. "sitemaps". */ |
| 969 | sprintf( __( 'The %s module could not be switched.', 'jetpack-seo' ), $module ), |
| 970 | array( 'status' => 500 ) |
| 971 | ); |
| 972 | } |
| 973 | |
| 974 | /** |
| 975 | * Build the aggregated Overview state the dashboard renders. |
| 976 | * |
| 977 | * @return array |
| 978 | */ |
| 979 | public static function get_overview_data() { |
| 980 | $modules = new Modules(); |
| 981 | $seo_enabled = self::is_jetpack_seo_enabled(); |
| 982 | |
| 983 | $codes = get_option( 'verification_services_codes', array() ); |
| 984 | if ( ! is_array( $codes ) ) { |
| 985 | $codes = array(); |
| 986 | } |
| 987 | |
| 988 | return array( |
| 989 | 'site_visibility' => array( |
| 990 | 'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1, |
| 991 | 'site_is_private' => self::is_site_private(), |
| 992 | 'sitemap_active' => $modules->is_active( 'sitemaps' ), |
| 993 | 'seo_tools_active' => $modules->is_active( 'seo-tools' ), |
| 994 | ), |
| 995 | // Per-service booleans (a code is set or not) for the Overview's |
| 996 | // Site verification card. |
| 997 | 'site_verification' => array( |
| 998 | 'google' => ! empty( $codes['google'] ), |
| 999 | 'bing' => ! empty( $codes['bing'] ), |
| 1000 | 'pinterest' => ! empty( $codes['pinterest'] ), |
| 1001 | 'yandex' => ! empty( $codes['yandex'] ), |
| 1002 | 'facebook' => ! empty( $codes['facebook'] ), |
| 1003 | ), |
| 1004 | 'content_coverage' => Content_Coverage::get(), |
| 1005 | 'plan' => array( |
| 1006 | 'seo_enabled_for_site' => $seo_enabled, |
| 1007 | ), |
| 1008 | // Whether the site-verification toggle can do anything here. False where |
| 1009 | // that module isn't present — WordPress.com Simple ships no Jetpack modules |
| 1010 | // and reports every one of them active — so the dashboard hides the control |
| 1011 | // rather than offering a toggle the route would refuse. |
| 1012 | 'verification_switchable' => self::has_module( 'verification-tools' ), |
| 1013 | ); |
| 1014 | } |
| 1015 | |
| 1016 | /** |
| 1017 | * Coerce the stored title formats into one `{ type, value }` token list per page type. |
| 1018 | * |
| 1019 | * The site-settings API stores a cleared page type as `''` (see JETPACK-2284), so the |
| 1020 | * option is looser than the Settings tab's type; anything not a token list becomes `array()`. |
| 1021 | * |
| 1022 | * @since 0.9.5 |
| 1023 | * |
| 1024 | * @param mixed $stored Raw option value. |
| 1025 | * @return array<string, array<int, array{type: string, value: string}>> |
| 1026 | */ |
| 1027 | public static function normalize_title_formats( $stored ) { |
| 1028 | if ( ! is_array( $stored ) ) { |
| 1029 | return array(); |
| 1030 | } |
| 1031 | |
| 1032 | $normalized = array(); |
| 1033 | foreach ( $stored as $page_type => $format ) { |
| 1034 | $tokens = array(); |
| 1035 | if ( is_array( $format ) ) { |
| 1036 | foreach ( $format as $item ) { |
| 1037 | if ( is_array( $item ) && isset( $item['type'] ) && isset( $item['value'] ) && is_string( $item['type'] ) && is_string( $item['value'] ) ) { |
| 1038 | $tokens[] = array( |
| 1039 | 'type' => $item['type'], |
| 1040 | 'value' => $item['value'], |
| 1041 | ); |
| 1042 | } |
| 1043 | } |
| 1044 | } |
| 1045 | $normalized[ (string) $page_type ] = $tokens; |
| 1046 | } |
| 1047 | |
| 1048 | return $normalized; |
| 1049 | } |
| 1050 | |
| 1051 | /** |
| 1052 | * Build the editable Settings state the Settings tab hydrates from. |
| 1053 | * |
| 1054 | * Read-only bootstrap only. Writes go to core's `/wp/v2/settings` for every field |
| 1055 | * backed by an option alone ({@see self::register_rest_settings()}), and to the |
| 1056 | * package's own routes for the three settings whose write switches a Jetpack module |
| 1057 | * — sitemap, canonical URLs and site verification ({@see self::module_settings()}) |
| 1058 | * — and for the nested Schema container; bootstrapping them all here keeps the |
| 1059 | * Settings UI hydrated without a second request. |
| 1060 | * |
| 1061 | * @return array |
| 1062 | */ |
| 1063 | public static function get_settings_data() { |
| 1064 | $modules = new Modules(); |
| 1065 | |
| 1066 | // Read the stored values directly: Jetpack_SEO_Titles::get_custom_title_formats() |
| 1067 | // intentionally hides them while another SEO plugin controls output, but the |
| 1068 | // dashboard must still show the saved values without allowing edits. |
| 1069 | $title_formats = self::normalize_title_formats( get_option( 'advanced_seo_title_formats', array() ) ); |
| 1070 | $title_formats_editable = self::is_jetpack_seo_enabled(); |
| 1071 | $front_page_desc = self::get_front_page_meta_description(); |
| 1072 | |
| 1073 | // A site that set a front-page description back when it was free for all |
| 1074 | // WordPress.com Simple sites keeps editing it, even when otherwise plan-gated: |
| 1075 | // the value stays live in the legacy option. The gated Settings uses this to keep |
| 1076 | // that one field editable, and it's the same rule the save writes by. |
| 1077 | $has_legacy_front_page_meta = self::has_legacy_front_page_meta(); |
| 1078 | |
| 1079 | $codes = get_option( 'verification_services_codes', array() ); |
| 1080 | if ( ! is_array( $codes ) ) { |
| 1081 | $codes = array(); |
| 1082 | } |
| 1083 | |
| 1084 | $sitemap_active = $modules->is_active( 'sitemaps' ); |
| 1085 | $simple = ( new Host() )->is_wpcom_simple(); |
| 1086 | |
| 1087 | return array( |
| 1088 | 'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1, |
| 1089 | // A private or coming-soon WordPress.com site isn't hidden from search by an |
| 1090 | // SEO setting and can't be unhidden by one — see {@see self::block_publishing_a_private_site()}. |
| 1091 | 'site_is_private' => self::is_site_private(), |
| 1092 | 'sitemap_active' => $sitemap_active, |
| 1093 | 'sitemap_switchable' => self::has_module( 'sitemaps' ), |
| 1094 | // The reachable sitemap URL (Jetpack serves a valid sitemap here as soon as |
| 1095 | // it's on + the site is public), or '' when sitemaps are off, so the Settings |
| 1096 | // tab shows the "View sitemap" link exactly when there's a sitemap to view. |
| 1097 | 'sitemap_url' => self::get_reachable_sitemap_url( $sitemap_active ), |
| 1098 | // Simple reports every module active, so read its own switch ({@see Canonical_Urls}). |
| 1099 | 'canonical_active' => $simple ? Canonical_Urls::is_enabled() : $modules->is_active( 'canonical-urls' ), |
| 1100 | 'canonical_switchable' => $simple || self::has_module( 'canonical-urls' ), |
| 1101 | // Cast to object so an empty format set serializes as `{}`, not `[]`. |
| 1102 | 'title_formats' => (object) $title_formats, |
| 1103 | // Separator WordPress joins default document-title parts with. A page type |
| 1104 | // with no stored format keeps the default title: `get_custom_title()` returns |
| 1105 | // the incoming value untouched, so core composes the title itself and the |
| 1106 | // Settings tab replays that composition to preview it. |
| 1107 | 'title_separator' => self::get_default_title_separator(), |
| 1108 | 'title_formats_editable' => $title_formats_editable, |
| 1109 | 'front_page_description' => (string) $front_page_desc, |
| 1110 | 'has_legacy_front_page_meta' => $has_legacy_front_page_meta, |
| 1111 | 'verification_tools_active' => $modules->is_active( 'verification-tools' ), |
| 1112 | 'verification' => array( |
| 1113 | 'google' => isset( $codes['google'] ) ? (string) $codes['google'] : '', |
| 1114 | 'bing' => isset( $codes['bing'] ) ? (string) $codes['bing'] : '', |
| 1115 | 'pinterest' => isset( $codes['pinterest'] ) ? (string) $codes['pinterest'] : '', |
| 1116 | 'yandex' => isset( $codes['yandex'] ) ? (string) $codes['yandex'] : '', |
| 1117 | 'facebook' => isset( $codes['facebook'] ) ? (string) $codes['facebook'] : '', |
| 1118 | ), |
| 1119 | 'schema' => Schema_Settings::get_editable(), |
| 1120 | ); |
| 1121 | } |
| 1122 | |
| 1123 | /** |
| 1124 | * Build the Google site-verification state for the Settings tab. |
| 1125 | * |
| 1126 | * The Settings verification card lets a connected user verify with Google via a |
| 1127 | * WordPress.com keyring OAuth popup (in addition to pasting a meta-tag code). This |
| 1128 | * bootstraps the keyring connect URL and whether the current user is connected — |
| 1129 | * the live verified status is fetched client-side from `/jetpack/v4/verify-site/google` |
| 1130 | * (a wpcom round-trip we don't want to make on every page load). |
| 1131 | * |
| 1132 | * Both `Keyring_Helper` (Publicize package) and the connection `Manager` are provided |
| 1133 | * by the host Jetpack plugin, so they're guarded with `class_exists` like the |
| 1134 | * `Jetpack_SEO_*` helpers. On a disconnected self-hosted site `is_connected` is false |
| 1135 | * and the UI falls back to manual code entry only. |
| 1136 | * |
| 1137 | * @return array |
| 1138 | */ |
| 1139 | public static function get_google_verify_data() { |
| 1140 | $connect_url = ''; |
| 1141 | if ( class_exists( 'Automattic\\Jetpack\\Publicize\\Keyring_Helper' ) ) { |
| 1142 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- guarded; Publicize package is provided by the host plugin. |
| 1143 | $connect_url = (string) \Automattic\Jetpack\Publicize\Keyring_Helper::connect_url( 'google_site_verification', 'other' ); |
| 1144 | } |
| 1145 | |
| 1146 | $is_connected = false; |
| 1147 | if ( class_exists( 'Automattic\\Jetpack\\Connection\\Manager' ) ) { |
| 1148 | $is_connected = ( new \Automattic\Jetpack\Connection\Manager() )->is_user_connected(); |
| 1149 | } |
| 1150 | |
| 1151 | return array( |
| 1152 | 'connect_url' => $connect_url, |
| 1153 | 'is_connected' => (bool) $is_connected, |
| 1154 | ); |
| 1155 | } |
| 1156 | |
| 1157 | /** |
| 1158 | * Build the GEO tab's initial state. |
| 1159 | * |
| 1160 | * The AI SEO Enhancer auto-generates SEO titles/descriptions/alt-text in the |
| 1161 | * editor (the generation itself is wpcom/AI-Assistant side); this exposes only |
| 1162 | * its persisted on/off toggle, whether it's available, and whether the AI SEO |
| 1163 | * control it sits under is on. Availability mirrors the legacy Traffic page: |
| 1164 | * the `ai_seo_enhancer_enabled` feature filter must be on (it still depends on |
| 1165 | * AI being available) AND the site's plan must support the `ai-seo-enhancer` |
| 1166 | * feature. The toggle writes the `ai_seo_enhancer_enabled` option through core's |
| 1167 | * `/wp/v2/settings` endpoint. |
| 1168 | * |
| 1169 | * `aiSeoEnabled` is reported separately rather than folded into availability: |
| 1170 | * with the control off the card is disabled, not hidden, so the saved choice |
| 1171 | * stays visible — the same treatment the Traffic page gives it. |
| 1172 | * |
| 1173 | * @return array |
| 1174 | */ |
| 1175 | public static function get_geo_data() { |
| 1176 | $filter_on = (bool) apply_filters( 'ai_seo_enhancer_enabled', true ); |
| 1177 | |
| 1178 | // Jetpack_AI_Settings lives in plugins/jetpack, which bundles this package; guarded |
| 1179 | // like the other host-plugin classes here. Without the method the AI SEO control |
| 1180 | // does not exist, so the enhancer keeps its pre-control behavior. |
| 1181 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_AI_Settings lives in plugins/jetpack and is guarded by is_callable. |
| 1182 | $ai_seo_on = ! is_callable( array( 'Jetpack_AI_Settings', 'is_ai_seo_enabled' ) ) || \Jetpack_AI_Settings::is_ai_seo_enabled(); |
| 1183 | |
| 1184 | // Current_Plan comes from the jetpack-plans package (a dependency of this |
| 1185 | // package since the plan-gating work), so it's always available here; the |
| 1186 | // class_exists guard is kept as belt-and-suspenders for older bundled snapshots. |
| 1187 | $plan_supports = class_exists( 'Automattic\\Jetpack\\Current_Plan' ) |
| 1188 | && \Automattic\Jetpack\Current_Plan::supports( 'ai-seo-enhancer' ); |
| 1189 | |
| 1190 | return array( |
| 1191 | 'enhancer' => array( |
| 1192 | 'available' => $filter_on && $plan_supports, |
| 1193 | 'enabled' => (bool) get_option( 'ai_seo_enhancer_enabled', false ), |
| 1194 | 'aiSeoEnabled' => $ai_seo_on, |
| 1195 | ), |
| 1196 | 'llmsTxt' => array( |
| 1197 | 'enabled' => Llms_Txt::is_enabled(), |
| 1198 | 'url' => home_url( '/llms.txt' ), |
| 1199 | 'canServe' => Llms_Txt::can_serve(), |
| 1200 | ), |
| 1201 | 'crawlers' => Ai_Crawlers::get_bootstrap_data(), |
| 1202 | ); |
| 1203 | } |
| 1204 | |
| 1205 | /** |
| 1206 | * Build the supported post type options for the Content tab. |
| 1207 | * |
| 1208 | * @return array{post_types:array<int,array{slug:string,label:string}>} |
| 1209 | */ |
| 1210 | public static function get_content_data() { |
| 1211 | return array( |
| 1212 | 'post_types' => Post_Types::get_supported_content_type_options(), |
| 1213 | ); |
| 1214 | } |
| 1215 | |
| 1216 | /** |
| 1217 | * Site identity used to render the homepage search/social previews on the |
| 1218 | * Settings tab: title, tagline, URL, and representative images. The front-page |
| 1219 | * description that completes the preview is read from the Settings form |
| 1220 | * (it's editable there), not bootstrapped here. |
| 1221 | * |
| 1222 | * @return array |
| 1223 | */ |
| 1224 | public static function get_site_data() { |
| 1225 | $icon_url = (string) get_site_icon_url(); |
| 1226 | |
| 1227 | $logo_id = (int) get_theme_mod( 'custom_logo' ); |
| 1228 | $logo_url = $logo_id ? (string) wp_get_attachment_image_url( $logo_id, 'full' ) : ''; |
| 1229 | if ( class_exists( 'Jetpack_Redux_State_Helper' ) ) { |
| 1230 | // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_Redux_State_Helper lives in plugins/jetpack and is guarded by class_exists. |
| 1231 | $image_url = (string) \Jetpack_Redux_State_Helper::get_site_image(); |
| 1232 | } else { |
| 1233 | $image_url = $logo_url ? $logo_url : $icon_url; |
| 1234 | } |
| 1235 | |
| 1236 | return array( |
| 1237 | 'title' => (string) get_bloginfo( 'name' ), |
| 1238 | 'tagline' => (string) get_bloginfo( 'description' ), |
| 1239 | 'url' => (string) home_url(), |
| 1240 | 'icon' => $icon_url, |
| 1241 | 'image' => $image_url, |
| 1242 | ); |
| 1243 | } |
| 1244 | |
| 1245 | /** |
| 1246 | * The separator, as rendered, that WordPress joins default document-title parts |
| 1247 | * with — for previewing the title a page type with no stored format produces. |
| 1248 | * |
| 1249 | * `document_title_separator` alone is not what a visitor sees. `wp_get_document_title()` |
| 1250 | * composes the parts, then passes the whole title through the `document_title` |
| 1251 | * filter, which WordPress texturizes by default — turning the default spaced |
| 1252 | * hyphen into an en dash. Previewing the raw filter value would show `-` on a site |
| 1253 | * that renders `–`, which is every site running core's defaults. |
| 1254 | * |
| 1255 | * This applies only to the default title. A stored format short-circuits |
| 1256 | * `pre_get_document_title`, which returns before the `document_title` filter, so a |
| 1257 | * custom format keeps the separator the user typed verbatim — the front end renders |
| 1258 | * `Site - MARKER - Page` for a custom format while producing `Page – Site` for the |
| 1259 | * default one. |
| 1260 | * |
| 1261 | * @return string The rendered separator. |
| 1262 | */ |
| 1263 | private static function get_default_title_separator() { |
| 1264 | $separator = (string) apply_filters( 'document_title_separator', '-' ); |
| 1265 | |
| 1266 | // Only texturize when the title itself would be: a site that unhooks |
| 1267 | // `wptexturize` renders the raw separator, and the preview should match. |
| 1268 | if ( has_filter( 'document_title', 'wptexturize' ) ) { |
| 1269 | $separator = trim( |
| 1270 | html_entity_decode( wptexturize( ' ' . $separator . ' ' ), ENT_QUOTES, 'UTF-8' ) |
| 1271 | ); |
| 1272 | } |
| 1273 | |
| 1274 | return $separator; |
| 1275 | } |
| 1276 | |
| 1277 | /** |
| 1278 | * The public URL of the XML sitemap, or an empty string when none is reachable. |
| 1279 | * |
| 1280 | * A sitemap is reachable as soon as generation is enabled and the site is public: |
| 1281 | * Jetpack serves a valid (empty-until-built) sitemap at a stable URL — never a 404 — |
| 1282 | * so the link is safe to surface immediately, without waiting on (or gating against) |
| 1283 | * the cron build. A prior gate looked the master sitemap up by a mis-built filename |
| 1284 | * and so never matched, which is what left the Settings tab stuck on "Generating…". |
| 1285 | * |
| 1286 | * `jetpack_sitemap_uri()` / `jp_sitemap_filename()` and the JP_MASTER_SITEMAP_TYPE |
| 1287 | * constant live in the Jetpack plugin's Sitemaps module (loaded only for an active |
| 1288 | * module on a public site), so they are guarded; in the package-only context they |
| 1289 | * are absent and the sitemap is reported as not reachable. |
| 1290 | * |
| 1291 | * @param bool $sitemap_active Whether sitemap generation is enabled. |
| 1292 | * @return string The sitemap URL, or '' when not reachable. |
| 1293 | */ |
| 1294 | private static function get_reachable_sitemap_url( $sitemap_active ) { |
| 1295 | // Jetpack only serves sitemaps when generation is on and the site is public. |
| 1296 | if ( ! $sitemap_active || (int) get_option( 'blog_public', 1 ) !== 1 ) { |
| 1297 | return ''; |
| 1298 | } |
| 1299 | |
| 1300 | // The `JP_MASTER_SITEMAP_TYPE` constant and the `jp_sitemap_filename()` / |
| 1301 | // `jetpack_sitemap_uri()` helpers all live together in plugins/jetpack and load |
| 1302 | // as a unit, so this single guard covers every symbol used below. |
| 1303 | if ( |
| 1304 | ! defined( 'JP_MASTER_SITEMAP_TYPE' ) |
| 1305 | || ! function_exists( 'jp_sitemap_filename' ) |
| 1306 | || ! function_exists( 'jetpack_sitemap_uri' ) |
| 1307 | ) { |
| 1308 | return ''; |
| 1309 | } |
| 1310 | |
| 1311 | // `jp_sitemap_filename()` returns an error string ("error-not-int-…") unless a |
| 1312 | // non-null number is passed; the master ignores the number, so pass 0 (matching |
| 1313 | // Jetpack's own call sites). Fail safe: the master file is always 'sitemap.xml', |
| 1314 | // so if a bundled Jetpack ever returns something else, report not-reachable |
| 1315 | // rather than surface a broken URL — the exact failure this method shipped with |
| 1316 | // before (the missing number silently produced an "error-not-int-…" link). |
| 1317 | // @phan-suppress-next-line PhanUndeclaredFunction -- guarded above; symbols live in plugins/jetpack. |
| 1318 | $filename = (string) jp_sitemap_filename( JP_MASTER_SITEMAP_TYPE, 0 ); |
| 1319 | if ( 'sitemap.xml' !== $filename ) { |
| 1320 | return ''; |
| 1321 | } |
| 1322 | |
| 1323 | // esc_url_raw (not esc_url): transported via script data and rendered by React, |
| 1324 | // so it must not be HTML-entity-encoded (e.g. the plain-permalink |
| 1325 | // `?jetpack-sitemap=` form keeps its raw `&`). |
| 1326 | // @phan-suppress-next-line PhanUndeclaredFunction -- guarded above; symbols live in plugins/jetpack. |
| 1327 | return esc_url_raw( (string) jetpack_sitemap_uri( $filename ) ); |
| 1328 | } |
| 1329 | } |