PluginProbe
Gutenberg / 23.1.1
Gutenberg v23.1.1
23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 12.6.0 7.4.0 All 402 releases
gutenberg / lib / experimental / content-types / index.php

index.php in Gutenberg 23.1.1, at lib/experimental/content-types/index.php

326 lines 11.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Registers the private CPTs that store user-defined content types:
4 * - wp_user_taxonomy (user-defined taxonomies)
5 *
6 * Each record holds the registration intent for one taxonomy. On `init`,
7 * this file also reads each published record and calls
8 * `register_taxonomy()` for it.
9 *
10 * @package gutenberg
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 require_once __DIR__ . '/class-wp-rest-user-taxonomies-controller-gutenberg.php';
18
19 /**
20 * Post meta key that stores the post types attached to a user-defined
21 * taxonomy. Stored as meta (rather than inside the `post_content` JSON
22 * with the rest of the config) so listings can filter on it via
23 * `meta_query`. Underscore-prefixed so it's treated as protected meta.
24 * Surfaced in REST as the typed top-level `object_type` field.
25 */
26 const GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY = '_wp_user_taxonomy_object_type';
27
28 /**
29 * Self-identifying key embedded in stored `post_content` JSON. Mirrors
30 * core's `isGlobalStylesUserThemeJSON` for `wp_global_styles`.
31 *
32 * Not load-bearing today: writes are sanitized via `wp_insert_post_data`,
33 * which carries `post_type` context, so payload identification doesn't
34 * need a marker. The marker is preserved as a forward-compat anchor for
35 * a content-only fallback sanitizer — e.g., if a future write path turns
36 * out to bypass `wp_insert_post_data`, or a kses-ordering issue forces a
37 * fallback to `content_save_pre`. In those scenarios a content-only
38 * sanitizer can't safely identify our payloads without a marker, and
39 * retroactively migrating stored records across WP installs is not
40 * practical, so the marker is present from day one.
41 *
42 * Storage-only: kept out of the REST schema and stripped on read so it
43 * never reaches clients.
44 */
45 const GUTENBERG_USER_TAXONOMY_CONFIG_MARKER = 'isUserTaxonomyConfigJSON';
46
47 /**
48 * Regex for a valid taxonomy slug. 32 chars matches the `wp_terms.slug`
49 * column width.
50 */
51 const GUTENBERG_USER_TAXONOMY_SLUG_PATTERN = '/^[a-z0-9_-]{1,32}$/';
52
53 /**
54 * Registers the wp_user_taxonomy CPT.
55 */
56 function gutenberg_register_user_taxonomy_cpt() {
57 register_post_type(
58 'wp_user_taxonomy',
59 array(
60 'labels' => array(
61 'name' => __( 'User taxonomies', 'gutenberg' ),
62 'singular_name' => __( 'User taxonomy', 'gutenberg' ),
63 'add_new_item' => __( 'Add taxonomy', 'gutenberg' ),
64 ),
65 'public' => false,
66 'publicly_queryable' => false,
67 'show_ui' => false,
68 'show_in_menu' => false,
69 'show_in_rest' => true,
70 'rest_base' => 'user-taxonomies',
71 'rest_controller_class' => 'WP_REST_User_Taxonomies_Controller_Gutenberg',
72 'capability_type' => 'post',
73 'capabilities' => array(
74 /**
75 * Capability map: every write operation requires `manage_options`.
76 * Read is allowed for any authenticated user that can `edit_posts` so the
77 * REST endpoint can be consumed by the Settings pages without exposing the
78 * records to unauthenticated visitors.
79 */
80 'read' => 'edit_posts',
81 'create_posts' => 'manage_options',
82 'edit_posts' => 'manage_options',
83 'edit_published_posts' => 'manage_options',
84 'delete_posts' => 'manage_options',
85 'delete_published_posts' => 'manage_options',
86 'edit_others_posts' => 'manage_options',
87 'delete_others_posts' => 'manage_options',
88 'publish_posts' => 'manage_options',
89 ),
90 'map_meta_cap' => true,
91 'supports' => array( 'title', 'editor' ),
92 'hierarchical' => false,
93 'has_archive' => false,
94 'rewrite' => false,
95 'query_var' => false,
96 )
97 );
98
99 register_post_meta(
100 'wp_user_taxonomy',
101 GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY,
102 array(
103 // One row per attached post type, so `meta_query IN` can filter
104 // listings by individual slug.
105 'single' => false,
106 'type' => 'string',
107 // Surfaced via the REST controller as a top-level `object_type` array.
108 // Setting `show_in_rest => false` keeps the raw meta key out of the
109 // REST response so clients only see the typed field.
110 'show_in_rest' => false,
111 'sanitize_callback' => 'sanitize_key',
112 )
113 );
114 }
115
116 add_action( 'init', 'gutenberg_register_user_taxonomy_cpt' );
117
118 /**
119 * Sanitizes a decoded taxonomy config to the canonical shape declared by
120 * the REST controller's config schema. Single sanitization site for
121 * taxonomy records — called from {@see gutenberg_filter_user_taxonomy_post_content}
122 * on `wp_insert_post_data`.
123 *
124 * @param array $config Raw decoded config.
125 * @return array Sanitized config.
126 */
127 function gutenberg_user_taxonomy_sanitize_config( $config ) {
128 if ( ! is_array( $config ) ) {
129 return array();
130 }
131
132 $clean = rest_sanitize_value_from_schema(
133 $config,
134 WP_REST_User_Taxonomies_Controller_Gutenberg::get_config_schema()
135 );
136 if ( ! is_array( $clean ) ) {
137 return array();
138 }
139
140 // `rest_sanitize_value_from_schema()` casts strings to their declared
141 // type but doesn't strip HTML or control characters, so layer that on.
142 if ( isset( $clean['description'] ) ) {
143 $clean['description'] = sanitize_textarea_field( (string) $clean['description'] );
144 }
145 if ( isset( $clean['labels'] ) && is_array( $clean['labels'] ) ) {
146 foreach ( $clean['labels'] as $key => $value ) {
147 $clean['labels'][ $key ] = sanitize_text_field( (string) $value );
148 }
149 }
150
151 return $clean;
152 }
153
154 /**
155 * Sanitizes wp_user_taxonomy JSON `post_content` during `wp_insert_post`.
156 *
157 * Acts on posts of type `wp_user_taxonomy`. Returns input unchanged for
158 * any other post type. Invalid JSON is normalized to the canonical
159 * marker-only payload rather than passed through. The filter is
160 * unconditional — taxonomy config isn't HTML and shouldn't carry scripts
161 * even for users with `unfiltered_html`.
162 *
163 * @param array $data Slashed post data being inserted/updated.
164 * @return array Filtered data.
165 */
166 function gutenberg_filter_user_taxonomy_post_content( $data ) {
167 if ( ! isset( $data['post_type'], $data['post_content'] ) ) {
168 return $data;
169 }
170
171 if ( 'wp_user_taxonomy' !== $data['post_type'] ) {
172 return $data;
173 }
174
175 $decoded = json_decode( wp_unslash( (string) $data['post_content'] ), true );
176 if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
177 // Hedge: invalid JSON falls through to a canonical empty payload so
178 // a stray read path can't surface arbitrary bytes. The marker is
179 // added below, keeping the stored shape uniform.
180 $decoded = array();
181 }
182
183 $clean = gutenberg_user_taxonomy_sanitize_config( $decoded );
184
185 // Storage-only marker: deliberately not in the REST schema so it can
186 // never reach clients. Kept as a forward-compat anchor for a
187 // content-only fallback sanitizer; full rationale on the const.
188 $clean[ GUTENBERG_USER_TAXONOMY_CONFIG_MARKER ] = true;
189
190 // `wp_insert_post_data` is the last filter before the row is written,
191 // so the re-encode here is what lands in the database.
192 // `JSON_HEX_TAG | JSON_HEX_AMP` guarantee the stored bytes carry no
193 // live `<`, `>`, or `&`, so any subsequent pass through kses (on
194 // later updates or on display) sees an inert string. kses on
195 // `content_save_pre` already ran earlier in `wp_insert_post()`; for
196 // REST writes that input was pre-escaped by
197 // `prepare_item_for_database`, so that earlier pass was also a no-op.
198 $data['post_content'] = wp_slash(
199 wp_json_encode(
200 WP_REST_User_Taxonomies_Controller_Gutenberg::normalize_config_for_encode( $clean ),
201 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
202 )
203 );
204
205 return $data;
206 }
207 add_filter( 'wp_insert_post_data', 'gutenberg_filter_user_taxonomy_post_content' );
208
209 /**
210 * Reads the stored object_type meta values for a record, filtering down to
211 * post types that currently exist.
212 *
213 * @param int $post_id Record ID.
214 * @return string[]
215 */
216 function gutenberg_user_taxonomy_read_object_type( $post_id ) {
217 $values = get_post_meta( $post_id, GUTENBERG_USER_TAXONOMY_OBJECT_TYPE_META_KEY );
218 if ( ! is_array( $values ) ) {
219 return array();
220 }
221 $out = array();
222 foreach ( $values as $value ) {
223 if ( is_string( $value ) && post_type_exists( $value ) ) {
224 $out[] = $value;
225 }
226 }
227 return array_values( array_unique( $out ) );
228 }
229
230 /**
231 * Builds register_taxonomy() arguments from a wp_user_taxonomy record.
232 * Returns null for invalid records so callers can skip them uniformly.
233 *
234 * @param WP_Post $record Stored taxonomy record.
235 * @return array{0: string, 1: string[], 2: array}|null [ $slug, $object_type, $args ].
236 */
237 function gutenberg_build_user_taxonomy_args( WP_Post $record ) {
238 $slug = $record->post_name;
239 if ( ! is_string( $slug ) || ! preg_match( GUTENBERG_USER_TAXONOMY_SLUG_PATTERN, $slug ) ) {
240 return null;
241 }
242
243 $decoded = json_decode( (string) $record->post_content, true, 8 );
244 if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
245 return null;
246 }
247 unset( $decoded[ GUTENBERG_USER_TAXONOMY_CONFIG_MARKER ] );
248 // Storage is sanitized at write-time by the filter on
249 // `wp_insert_post_data`, so we trust the decoded shape here.
250 $config = $decoded;
251
252 $object_type = gutenberg_user_taxonomy_read_object_type( $record->ID );
253
254 $title = sanitize_text_field( $record->post_title );
255 $singular = isset( $config['labels']['singular_name'] )
256 ? (string) $config['labels']['singular_name']
257 : '';
258 $labels = array(
259 'name' => $title,
260 'singular_name' => '' !== $singular ? $singular : $title,
261 );
262
263 // Merge optional label overrides. The sanitizer has already pruned
264 // unknown keys against the schema, so we can trust whatever the stored
265 // labels object contains. Empty strings fall through to the
266 // WordPress-generated defaults.
267 $stored_labels = isset( $config['labels'] ) && is_array( $config['labels'] )
268 ? $config['labels']
269 : array();
270 foreach ( array_keys( $stored_labels ) as $label_key ) {
271 if ( 'singular_name' === $label_key ) {
272 continue;
273 }
274 if ( ! empty( $stored_labels[ $label_key ] ) ) {
275 $labels[ $label_key ] = (string) $stored_labels[ $label_key ];
276 }
277 }
278
279 $args = array(
280 'labels' => $labels,
281 'public' => ! empty( $config['public'] ),
282 'hierarchical' => ! empty( $config['hierarchical'] ),
283 'show_in_rest' => true,
284 );
285
286 if ( ! empty( $config['description'] ) ) {
287 $args['description'] = (string) $config['description'];
288 }
289
290 return array( $slug, $object_type, $args );
291 }
292
293 /**
294 * Reads each published wp_user_taxonomy record and calls register_taxonomy()
295 * with a tightly-validated subset of its stored config.
296 */
297 function gutenberg_register_user_defined_taxonomies() {
298 $records = get_posts(
299 array(
300 'post_type' => 'wp_user_taxonomy',
301 // Drafts are skipped so the Edit "Active" toggle gates registration.
302 'post_status' => 'publish',
303 'posts_per_page' => -1,
304 'no_found_rows' => true,
305 'suppress_filters' => true,
306 )
307 );
308
309 foreach ( $records as $record ) {
310 $built = gutenberg_build_user_taxonomy_args( $record );
311 if ( null === $built ) {
312 continue;
313 }
314 list( $slug, $object_type, $args ) = $built;
315
316 // Defense-in-depth: never overwrite an existing taxonomy registration,
317 // even if a bad record slipped past server-side slug validation.
318 if ( taxonomy_exists( $slug ) ) {
319 continue;
320 }
321
322 register_taxonomy( $slug, $object_type, $args );
323 }
324 }
325 add_action( 'init', 'gutenberg_register_user_defined_taxonomies', 20 );
326