lib/experimental/content-types
class-wp-rest-user-post-types-controller-gutenberg.php
25.0 KB
3 months ago
class-wp-rest-user-taxonomies-controller-gutenberg.php
20.1 KB
3 months ago
index.php
16.1 KB
3 months ago
load.php
654 B
3 months ago
post-types.php
10.8 KB
3 months ago
index.php in Gutenberg 23.2.0, at lib/experimental/content-types/index.php
| 1 | <?php |
| 2 | /** |
| 3 | * Registers the private CPTs that store user-defined content types: |
| 4 | * - wp_user_taxonomy (user-defined taxonomies) |
| 5 | * - wp_user_post_type (user-defined post types) |
| 6 | * |
| 7 | * Each record holds the registration intent for one taxonomy or post type. |
| 8 | * On `init`, the corresponding files read each published record and call |
| 9 | * `register_taxonomy()` / `register_post_type()` for it. |
| 10 | * |
| 11 | * @package gutenberg |
| 12 | */ |
| 13 | |
| 14 | if ( ! defined( 'ABSPATH' ) ) { |
| 15 | exit; |
| 16 | } |
| 17 | |
| 18 | require_once __DIR__ . '/class-wp-rest-user-taxonomies-controller-gutenberg.php'; |
| 19 | require_once __DIR__ . '/class-wp-rest-user-post-types-controller-gutenberg.php'; |
| 20 | |
| 21 | /** |
| 22 | * Post meta key that stores the post types attached to a user-defined |
| 23 | * taxonomy. Stored as meta (rather than inside the `post_content` JSON |
| 24 | * with the rest of the config) so listings can filter on it via |
| 25 | * `meta_query`. Underscore-prefixed so it's treated as protected meta. |
| 26 | * Surfaced in REST as the typed top-level `object_type` field. |
| 27 | */ |
| 28 | const GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY = '_wp_user_taxonomy_object_type'; |
| 29 | |
| 30 | /** |
| 31 | * Self-identifying key embedded in stored `post_content` JSON. Mirrors |
| 32 | * core's `isGlobalStylesUserThemeJSON` for `wp_global_styles`. |
| 33 | * |
| 34 | * Not load-bearing today: writes are sanitized via `wp_insert_post_data`, |
| 35 | * which carries `post_type` context, so payload identification doesn't |
| 36 | * need a marker. The marker is preserved as a forward-compat anchor for |
| 37 | * a content-only fallback sanitizer — e.g., if a future write path turns |
| 38 | * out to bypass `wp_insert_post_data`, or a kses-ordering issue forces a |
| 39 | * fallback to `content_save_pre`. In those scenarios a content-only |
| 40 | * sanitizer can't safely identify our payloads without a marker, and |
| 41 | * retroactively migrating stored records across WP installs is not |
| 42 | * practical, so the marker is present from day one. |
| 43 | * |
| 44 | * Storage-only: kept out of the REST schema and stripped on read so it |
| 45 | * never reaches clients. |
| 46 | */ |
| 47 | const GUTENBERG_USER_TAXONOMY_CONFIG_MARKER = 'isUserTaxonomyConfigJSON'; |
| 48 | |
| 49 | /** |
| 50 | * Regex for a valid taxonomy slug. 32 chars matches the `wp_terms.slug` |
| 51 | * column width. |
| 52 | */ |
| 53 | const GUTENBERG_USER_TAXONOMY_SLUG_PATTERN = '/^[a-z0-9_-]{1,32}$/'; |
| 54 | |
| 55 | /** |
| 56 | * Option key flagging that the next `init` should regenerate rewrite rules. |
| 57 | * Set by the REST controllers when a wp_user_post_type or wp_user_taxonomy |
| 58 | * write changes a field that affects rewrite rules (slug, has_archive, |
| 59 | * public) or toggles whether the record is registered. |
| 60 | */ |
| 61 | const GUTENBERG_USER_CONTENT_TYPES_FLUSH_OPTION = '_gutenberg_user_content_types_flush_rewrite_rules'; |
| 62 | |
| 63 | /** |
| 64 | * Flags the next `init` to regenerate rewrite rules. Flushing inline in the |
| 65 | * REST handler would regenerate against the pre-update registration — the |
| 66 | * post type / taxonomy registers at `init` priority 20, before the REST |
| 67 | * request handler runs, so by the time we'd flush, the registered state |
| 68 | * still reflects the old record. Deferring to the next request's |
| 69 | * `init` (priority 30, after registration runs against the new record) |
| 70 | * picks up the new rewrite rules. |
| 71 | */ |
| 72 | function gutenberg_user_content_types_schedule_flush_rewrite_rules() { |
| 73 | // Autoload off — the option is short-lived (cleared on next init), and |
| 74 | // keeping it out of the alloptions cache means a stray uncleared row |
| 75 | // can't bloat every page load. |
| 76 | update_option( GUTENBERG_USER_CONTENT_TYPES_FLUSH_OPTION, '1', false ); |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * Regenerates the rewrite rules option if a recent write scheduled a flush. |
| 81 | * |
| 82 | * Runs at `init` priority 30 — after `register_post_type()` and |
| 83 | * `register_taxonomy()` have been called for user-defined records at |
| 84 | * priority 20, so the regenerated rules see the new registration state. |
| 85 | * |
| 86 | * Soft flush only — the standard `.htaccess` block doesn't depend on |
| 87 | * individual post type or taxonomy registrations, so there's nothing for |
| 88 | * a hard flush to update there, and avoiding the file write keeps this |
| 89 | * cheap. |
| 90 | */ |
| 91 | function gutenberg_user_content_types_maybe_flush_rewrite_rules() { |
| 92 | if ( ! get_option( GUTENBERG_USER_CONTENT_TYPES_FLUSH_OPTION ) ) { |
| 93 | return; |
| 94 | } |
| 95 | delete_option( GUTENBERG_USER_CONTENT_TYPES_FLUSH_OPTION ); |
| 96 | flush_rewrite_rules( false ); |
| 97 | } |
| 98 | add_action( 'init', 'gutenberg_user_content_types_maybe_flush_rewrite_rules', 30 ); |
| 99 | |
| 100 | /** |
| 101 | * Registers the wp_user_taxonomy CPT. |
| 102 | */ |
| 103 | function gutenberg_register_user_taxonomy_cpt() { |
| 104 | register_post_type( |
| 105 | 'wp_user_taxonomy', |
| 106 | array( |
| 107 | 'labels' => array( |
| 108 | 'name' => __( 'User taxonomies', 'gutenberg' ), |
| 109 | 'singular_name' => __( 'User taxonomy', 'gutenberg' ), |
| 110 | 'add_new_item' => __( 'Add taxonomy', 'gutenberg' ), |
| 111 | ), |
| 112 | 'public' => false, |
| 113 | 'publicly_queryable' => false, |
| 114 | 'show_ui' => false, |
| 115 | 'show_in_menu' => false, |
| 116 | 'show_in_rest' => true, |
| 117 | 'rest_base' => 'user-taxonomies', |
| 118 | 'rest_controller_class' => 'WP_REST_User_Taxonomies_Controller_Gutenberg', |
| 119 | 'capability_type' => 'post', |
| 120 | 'capabilities' => array( |
| 121 | /** |
| 122 | * Capability map: every write operation requires `manage_options`. |
| 123 | * Read is allowed for any authenticated user that can `edit_posts` so the |
| 124 | * REST endpoint can be consumed by the Settings pages without exposing the |
| 125 | * records to unauthenticated visitors. |
| 126 | */ |
| 127 | 'read' => 'edit_posts', |
| 128 | 'create_posts' => 'manage_options', |
| 129 | 'edit_posts' => 'manage_options', |
| 130 | 'edit_published_posts' => 'manage_options', |
| 131 | 'delete_posts' => 'manage_options', |
| 132 | 'delete_published_posts' => 'manage_options', |
| 133 | 'edit_others_posts' => 'manage_options', |
| 134 | 'delete_others_posts' => 'manage_options', |
| 135 | 'publish_posts' => 'manage_options', |
| 136 | ), |
| 137 | 'map_meta_cap' => true, |
| 138 | 'supports' => array( 'title', 'editor' ), |
| 139 | 'hierarchical' => false, |
| 140 | 'has_archive' => false, |
| 141 | 'rewrite' => false, |
| 142 | 'query_var' => false, |
| 143 | '_builtin' => true, /* internal use only. don't use this when registering your own post type. */ |
| 144 | ) |
| 145 | ); |
| 146 | |
| 147 | register_post_meta( |
| 148 | 'wp_user_taxonomy', |
| 149 | GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY, |
| 150 | array( |
| 151 | // One row per attached post type, so `meta_query IN` can filter |
| 152 | // listings by individual slug. |
| 153 | 'single' => false, |
| 154 | 'type' => 'string', |
| 155 | // Surfaced via the REST controller as a top-level `object_type` array. |
| 156 | // Setting `show_in_rest => false` keeps the raw meta key out of the |
| 157 | // REST response so clients only see the typed field. |
| 158 | 'show_in_rest' => false, |
| 159 | 'sanitize_callback' => 'sanitize_key', |
| 160 | ) |
| 161 | ); |
| 162 | } |
| 163 | |
| 164 | add_action( 'init', 'gutenberg_register_user_taxonomy_cpt' ); |
| 165 | |
| 166 | /** |
| 167 | * Sanitizes a decoded taxonomy config to the canonical shape declared by |
| 168 | * the REST controller's config schema. Single sanitization site for |
| 169 | * taxonomy records — called from {@see gutenberg_filter_user_taxonomy_post_content} |
| 170 | * on `wp_insert_post_data`. |
| 171 | * |
| 172 | * @param array $config Raw decoded config. |
| 173 | * @return array Sanitized config. |
| 174 | */ |
| 175 | function gutenberg_user_taxonomy_sanitize_config( $config ) { |
| 176 | if ( ! is_array( $config ) ) { |
| 177 | return array(); |
| 178 | } |
| 179 | |
| 180 | $clean = rest_sanitize_value_from_schema( |
| 181 | $config, |
| 182 | WP_REST_User_Taxonomies_Controller_Gutenberg::get_config_schema() |
| 183 | ); |
| 184 | if ( ! is_array( $clean ) ) { |
| 185 | return array(); |
| 186 | } |
| 187 | |
| 188 | // `rest_sanitize_value_from_schema()` casts strings to their declared |
| 189 | // type but doesn't strip HTML or control characters, so layer that on. |
| 190 | if ( isset( $clean['description'] ) ) { |
| 191 | $clean['description'] = sanitize_textarea_field( (string) $clean['description'] ); |
| 192 | } |
| 193 | if ( isset( $clean['labels'] ) && is_array( $clean['labels'] ) ) { |
| 194 | foreach ( $clean['labels'] as $key => $value ) { |
| 195 | $clean['labels'][ $key ] = sanitize_text_field( (string) $value ); |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | return $clean; |
| 200 | } |
| 201 | |
| 202 | /** |
| 203 | * Sanitizes wp_user_taxonomy JSON `post_content` during `wp_insert_post`. |
| 204 | * |
| 205 | * Acts on posts of type `wp_user_taxonomy`. Returns input unchanged for |
| 206 | * any other post type. Invalid JSON is normalized to the canonical |
| 207 | * marker-only payload rather than passed through. The filter is |
| 208 | * unconditional — taxonomy config isn't HTML and shouldn't carry scripts |
| 209 | * even for users with `unfiltered_html`. |
| 210 | * |
| 211 | * @param array $data Slashed post data being inserted/updated. |
| 212 | * @return array Filtered data. |
| 213 | */ |
| 214 | function gutenberg_filter_user_taxonomy_post_content( $data ) { |
| 215 | if ( ! isset( $data['post_type'], $data['post_content'] ) ) { |
| 216 | return $data; |
| 217 | } |
| 218 | |
| 219 | if ( 'wp_user_taxonomy' !== $data['post_type'] ) { |
| 220 | return $data; |
| 221 | } |
| 222 | |
| 223 | $decoded = json_decode( wp_unslash( (string) $data['post_content'] ), true ); |
| 224 | if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { |
| 225 | // Hedge: invalid JSON falls through to a canonical empty payload so |
| 226 | // a stray read path can't surface arbitrary bytes. The marker is |
| 227 | // added below, keeping the stored shape uniform. |
| 228 | $decoded = array(); |
| 229 | } |
| 230 | |
| 231 | $clean = gutenberg_user_taxonomy_sanitize_config( $decoded ); |
| 232 | |
| 233 | // Storage-only marker: deliberately not in the REST schema so it can |
| 234 | // never reach clients. Kept as a forward-compat anchor for a |
| 235 | // content-only fallback sanitizer; full rationale on the const. |
| 236 | $clean[ GUTENBERG_USER_TAXONOMY_CONFIG_MARKER ] = true; |
| 237 | |
| 238 | // `wp_insert_post_data` is the last filter before the row is written, |
| 239 | // so the re-encode here is what lands in the database. |
| 240 | // `JSON_HEX_TAG | JSON_HEX_AMP` guarantee the stored bytes carry no |
| 241 | // live `<`, `>`, or `&`, so any subsequent pass through kses (on |
| 242 | // later updates or on display) sees an inert string. kses on |
| 243 | // `content_save_pre` already ran earlier in `wp_insert_post()`; for |
| 244 | // REST writes that input was pre-escaped by |
| 245 | // `prepare_item_for_database`, so that earlier pass was also a no-op. |
| 246 | $data['post_content'] = wp_slash( |
| 247 | wp_json_encode( |
| 248 | WP_REST_User_Taxonomies_Controller_Gutenberg::normalize_config_for_encode( $clean ), |
| 249 | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP |
| 250 | ) |
| 251 | ); |
| 252 | |
| 253 | return $data; |
| 254 | } |
| 255 | add_filter( 'wp_insert_post_data', 'gutenberg_filter_user_taxonomy_post_content' ); |
| 256 | |
| 257 | /** |
| 258 | * Reads the stored object_type meta values for a record, filtering down to |
| 259 | * post types that currently exist. |
| 260 | * |
| 261 | * @param int $post_id Record ID. |
| 262 | * @return string[] |
| 263 | */ |
| 264 | function gutenberg_user_taxonomy_read_object_type( $post_id ) { |
| 265 | $values = get_post_meta( $post_id, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY ); |
| 266 | if ( ! is_array( $values ) ) { |
| 267 | return array(); |
| 268 | } |
| 269 | $out = array(); |
| 270 | foreach ( $values as $value ) { |
| 271 | if ( is_string( $value ) && post_type_exists( $value ) ) { |
| 272 | $out[] = $value; |
| 273 | } |
| 274 | } |
| 275 | return array_values( array_unique( $out ) ); |
| 276 | } |
| 277 | |
| 278 | /** |
| 279 | * Appends a post-type slug to a wp_user_taxonomy record's |
| 280 | * `_wp_user_taxonomy_object_type` meta if not already present. No-op if |
| 281 | * the slug is already attached. |
| 282 | * |
| 283 | * @param int $tax_post_id wp_user_taxonomy record ID. |
| 284 | * @param string $object_type Post type slug to attach. |
| 285 | */ |
| 286 | function gutenberg_user_taxonomy_attach_object_type( $tax_post_id, $object_type ) { |
| 287 | if ( 'wp_user_taxonomy' !== get_post_type( $tax_post_id ) ) { |
| 288 | return; |
| 289 | } |
| 290 | $clean = is_string( $object_type ) ? sanitize_key( $object_type ) : ''; |
| 291 | if ( '' === $clean ) { |
| 292 | return; |
| 293 | } |
| 294 | $existing = (array) get_post_meta( $tax_post_id, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY ); |
| 295 | if ( in_array( $clean, $existing, true ) ) { |
| 296 | return; |
| 297 | } |
| 298 | add_post_meta( $tax_post_id, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY, $clean ); |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Removes a single post-type slug from a wp_user_taxonomy record's |
| 303 | * `_wp_user_taxonomy_object_type` meta. Other slugs remain. |
| 304 | * |
| 305 | * @param int $tax_post_id wp_user_taxonomy record ID. |
| 306 | * @param string $object_type Post type slug to detach. |
| 307 | */ |
| 308 | function gutenberg_user_taxonomy_detach_object_type( $tax_post_id, $object_type ) { |
| 309 | if ( 'wp_user_taxonomy' !== get_post_type( $tax_post_id ) ) { |
| 310 | return; |
| 311 | } |
| 312 | $clean = is_string( $object_type ) ? sanitize_key( $object_type ) : ''; |
| 313 | if ( '' === $clean ) { |
| 314 | return; |
| 315 | } |
| 316 | delete_post_meta( $tax_post_id, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY, $clean ); |
| 317 | } |
| 318 | |
| 319 | /** |
| 320 | * Replaces every `_wp_user_taxonomy_object_type` meta row on a record |
| 321 | * with the given list. Each value is sanitized via `sanitize_key()` and |
| 322 | * filtered against `post_type_exists()` so only registered post types |
| 323 | * land in storage; duplicates are collapsed. |
| 324 | * |
| 325 | * @param int $tax_post_id wp_user_taxonomy record ID. |
| 326 | * @param array<int, string> $object_types Post type slugs to store. |
| 327 | */ |
| 328 | function gutenberg_user_taxonomy_replace_object_types( $tax_post_id, array $object_types ) { |
| 329 | $values = array(); |
| 330 | foreach ( $object_types as $slug ) { |
| 331 | if ( ! is_string( $slug ) ) { |
| 332 | continue; |
| 333 | } |
| 334 | $clean = sanitize_key( $slug ); |
| 335 | if ( '' !== $clean && post_type_exists( $clean ) ) { |
| 336 | $values[] = $clean; |
| 337 | } |
| 338 | } |
| 339 | $values = array_values( array_unique( $values ) ); |
| 340 | |
| 341 | delete_post_meta( $tax_post_id, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY ); |
| 342 | foreach ( $values as $slug ) { |
| 343 | add_post_meta( $tax_post_id, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY, $slug ); |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | /** |
| 348 | * Builds register_taxonomy() arguments from a wp_user_taxonomy record. |
| 349 | * Returns null for invalid records so callers can skip them uniformly. |
| 350 | * |
| 351 | * @param WP_Post $record Stored taxonomy record. |
| 352 | * @return array{0: string, 1: string[], 2: array}|null [ $slug, $object_type, $args ]. |
| 353 | */ |
| 354 | function gutenberg_build_user_taxonomy_args( WP_Post $record ) { |
| 355 | $slug = $record->post_name; |
| 356 | if ( ! is_string( $slug ) || ! preg_match( GUTENBERG_USER_TAXONOMY_SLUG_PATTERN, $slug ) ) { |
| 357 | return null; |
| 358 | } |
| 359 | |
| 360 | $decoded = json_decode( (string) $record->post_content, true, 8 ); |
| 361 | if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) { |
| 362 | return null; |
| 363 | } |
| 364 | unset( $decoded[ GUTENBERG_USER_TAXONOMY_CONFIG_MARKER ] ); |
| 365 | // Storage is sanitized at write-time by the filter on |
| 366 | // `wp_insert_post_data`, so we trust the decoded shape here. |
| 367 | $config = $decoded; |
| 368 | |
| 369 | $object_type = gutenberg_user_taxonomy_read_object_type( $record->ID ); |
| 370 | |
| 371 | $title = sanitize_text_field( $record->post_title ); |
| 372 | $singular = isset( $config['labels']['singular_name'] ) |
| 373 | ? (string) $config['labels']['singular_name'] |
| 374 | : ''; |
| 375 | $labels = array( |
| 376 | 'name' => $title, |
| 377 | 'singular_name' => '' !== $singular ? $singular : $title, |
| 378 | ); |
| 379 | |
| 380 | // Merge optional label overrides. The sanitizer has already pruned |
| 381 | // unknown keys against the schema, so we can trust whatever the stored |
| 382 | // labels object contains. Empty strings fall through to the |
| 383 | // WordPress-generated defaults. |
| 384 | $stored_labels = isset( $config['labels'] ) && is_array( $config['labels'] ) |
| 385 | ? $config['labels'] |
| 386 | : array(); |
| 387 | foreach ( array_keys( $stored_labels ) as $label_key ) { |
| 388 | if ( 'singular_name' === $label_key ) { |
| 389 | continue; |
| 390 | } |
| 391 | if ( ! empty( $stored_labels[ $label_key ] ) ) { |
| 392 | $labels[ $label_key ] = (string) $stored_labels[ $label_key ]; |
| 393 | } |
| 394 | } |
| 395 | |
| 396 | $args = array( |
| 397 | 'labels' => $labels, |
| 398 | ); |
| 399 | |
| 400 | if ( ! empty( $config['description'] ) ) { |
| 401 | $args['description'] = (string) $config['description']; |
| 402 | } |
| 403 | |
| 404 | $bool_keys = array( |
| 405 | 'public', |
| 406 | 'hierarchical', |
| 407 | 'publicly_queryable', |
| 408 | 'show_ui', |
| 409 | 'show_in_menu', |
| 410 | 'show_in_nav_menus', |
| 411 | 'show_tagcloud', |
| 412 | 'show_in_quick_edit', |
| 413 | 'show_admin_column', |
| 414 | ); |
| 415 | foreach ( $bool_keys as $key ) { |
| 416 | if ( array_key_exists( $key, $config ) ) { |
| 417 | $args[ $key ] = (bool) $config[ $key ]; |
| 418 | } |
| 419 | } |
| 420 | $args['show_in_rest'] = isset( $config['show_in_rest'] ) ? (bool) $config['show_in_rest'] : true; |
| 421 | |
| 422 | return array( $slug, $object_type, $args ); |
| 423 | } |
| 424 | |
| 425 | /** |
| 426 | * Reads each published wp_user_taxonomy record and calls register_taxonomy() |
| 427 | * with a tightly-validated subset of its stored config. |
| 428 | */ |
| 429 | function gutenberg_register_user_defined_taxonomies() { |
| 430 | $records = get_posts( |
| 431 | array( |
| 432 | 'post_type' => 'wp_user_taxonomy', |
| 433 | // Drafts are skipped so the Edit "Active" toggle gates registration. |
| 434 | 'post_status' => 'publish', |
| 435 | 'posts_per_page' => -1, |
| 436 | 'no_found_rows' => true, |
| 437 | 'suppress_filters' => true, |
| 438 | ) |
| 439 | ); |
| 440 | |
| 441 | foreach ( $records as $record ) { |
| 442 | $built = gutenberg_build_user_taxonomy_args( $record ); |
| 443 | if ( null === $built ) { |
| 444 | continue; |
| 445 | } |
| 446 | list( $slug, $object_type, $args ) = $built; |
| 447 | |
| 448 | // Defense-in-depth: never overwrite an existing taxonomy registration, |
| 449 | // even if a bad record slipped past server-side slug validation. |
| 450 | if ( taxonomy_exists( $slug ) ) { |
| 451 | continue; |
| 452 | } |
| 453 | |
| 454 | register_taxonomy( $slug, $object_type, $args ); |
| 455 | } |
| 456 | } |
| 457 | add_action( 'init', 'gutenberg_register_user_defined_taxonomies', 20 ); |
| 458 |