PluginProbe
Gutenberg / 23.6.2
Gutenberg v23.6.2
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 / post-types.php

post-types.php in Gutenberg 23.6.2, at lib/experimental/content-types/post-types.php

310 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Registers the wp_user_post_type CPT that stores user-defined post types,
4 * and materializes published records into live `register_post_type()` calls.
5 *
6 * Each record holds the registration intent for one post type. Drafts are
7 * skipped at materialization time, so the Active toggle gates whether a
8 * record is actually registered.
9 *
10 * @package gutenberg
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit;
15 }
16
17 /**
18 * Self-identifying key embedded in stored `post_content` JSON. Mirrors
19 * core's `isGlobalStylesUserThemeJSON` for `wp_global_styles`.
20 *
21 * Storage-only: kept out of the REST schema and stripped on read so it
22 * never reaches clients. See the equivalent constant on the user-taxonomy
23 * side for the full forward-compat rationale.
24 */
25 const GUTENBERG_USER_POST_TYPE_CONFIG_MARKER = 'isUserPostTypeConfigJSON';
26
27 /**
28 * Regex for a valid post type slug. 20 chars matches the `wp_posts.post_type`
29 * column width that `register_post_type()` enforces.
30 */
31 const GUTENBERG_USER_POST_TYPE_SLUG_PATTERN = '/^[a-z0-9_-]{1,20}$/';
32
33 /**
34 * Registers the wp_user_post_type CPT.
35 */
36 function gutenberg_register_user_post_type_cpt() {
37 register_post_type(
38 'wp_user_post_type',
39 array(
40 'labels' => array(
41 'name' => __( 'User post types', 'gutenberg' ),
42 'singular_name' => __( 'User post type', 'gutenberg' ),
43 'add_new_item' => __( 'Add post type', 'gutenberg' ),
44 ),
45 'public' => false,
46 'publicly_queryable' => false,
47 'show_ui' => false,
48 'show_in_menu' => false,
49 'show_in_rest' => true,
50 'rest_base' => 'user-post-types',
51 'rest_controller_class' => 'WP_REST_User_Post_Types_Controller_Gutenberg',
52 'capability_type' => 'post',
53 'capabilities' => array(
54 /**
55 * Capability map: every write operation requires `manage_options`.
56 * Read is allowed for any authenticated user that can `edit_posts` so the
57 * REST endpoint can be consumed by the Settings pages without exposing the
58 * records to unauthenticated visitors.
59 */
60 'read' => 'edit_posts',
61 'create_posts' => 'manage_options',
62 'edit_posts' => 'manage_options',
63 'edit_published_posts' => 'manage_options',
64 'delete_posts' => 'manage_options',
65 'delete_published_posts' => 'manage_options',
66 'edit_others_posts' => 'manage_options',
67 'delete_others_posts' => 'manage_options',
68 'publish_posts' => 'manage_options',
69 ),
70 'map_meta_cap' => true,
71 'supports' => array( 'title', 'editor' ),
72 'hierarchical' => false,
73 'has_archive' => false,
74 'rewrite' => false,
75 'query_var' => false,
76 '_builtin' => true, /* internal use only. don't use this when registering your own post type. */
77 )
78 );
79 }
80
81 add_action( 'init', 'gutenberg_register_user_post_type_cpt' );
82
83 /**
84 * Sanitizes a decoded post type config to the canonical shape declared by
85 * the REST controller's config schema. Single sanitization site for
86 * post type records — called from {@see gutenberg_filter_user_post_type_post_content}
87 * on `wp_insert_post_data`.
88 *
89 * @param array $config Raw decoded config.
90 * @return array Sanitized config.
91 */
92 function gutenberg_user_post_type_sanitize_config( $config ) {
93 if ( ! is_array( $config ) ) {
94 return array();
95 }
96
97 $clean = rest_sanitize_value_from_schema(
98 $config,
99 WP_REST_User_Post_Types_Controller_Gutenberg::get_config_schema()
100 );
101 if ( ! is_array( $clean ) ) {
102 return array();
103 }
104
105 // `rest_sanitize_value_from_schema()` casts strings to their declared
106 // type but doesn't strip HTML or control characters, so layer that on.
107 if ( isset( $clean['description'] ) ) {
108 $clean['description'] = sanitize_textarea_field( (string) $clean['description'] );
109 }
110 if ( isset( $clean['labels'] ) && is_array( $clean['labels'] ) ) {
111 foreach ( $clean['labels'] as $key => $value ) {
112 $clean['labels'][ $key ] = sanitize_text_field( (string) $value );
113 }
114 }
115
116 return $clean;
117 }
118
119 /**
120 * Sanitizes wp_user_post_type JSON `post_content` during `wp_insert_post`.
121 *
122 * Acts on posts of type `wp_user_post_type`. Returns input unchanged for
123 * any other post type. Invalid JSON is normalized to the canonical
124 * marker-only payload rather than passed through. The filter is
125 * unconditional — post type config isn't HTML and shouldn't carry scripts
126 * even for users with `unfiltered_html`.
127 *
128 * @param array $data Slashed post data being inserted/updated.
129 * @return array Filtered data.
130 */
131 function gutenberg_filter_user_post_type_post_content( $data ) {
132 if ( ! isset( $data['post_type'], $data['post_content'] ) ) {
133 return $data;
134 }
135
136 if ( 'wp_user_post_type' !== $data['post_type'] ) {
137 return $data;
138 }
139
140 $decoded = json_decode( wp_unslash( (string) $data['post_content'] ), true );
141 if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
142 // Hedge: invalid JSON falls through to a canonical empty payload so
143 // a stray read path can't surface arbitrary bytes. The marker is
144 // added below, keeping the stored shape uniform.
145 $decoded = array();
146 }
147
148 $clean = gutenberg_user_post_type_sanitize_config( $decoded );
149
150 // Storage-only marker: deliberately not in the REST schema so it can
151 // never reach clients. Kept as a forward-compat anchor for a
152 // content-only fallback sanitizer; full rationale on the const.
153 $clean[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] = true;
154
155 // `wp_insert_post_data` is the last filter before the row is written,
156 // so the re-encode here is what lands in the database.
157 // `JSON_HEX_TAG | JSON_HEX_AMP` guarantee the stored bytes carry no
158 // live `<`, `>`, or `&`, so any subsequent pass through kses (on
159 // later updates or on display) sees an inert string. kses on
160 // `content_save_pre` already ran earlier in `wp_insert_post()`; for
161 // REST writes that input was pre-escaped by
162 // `prepare_item_for_database`, so that earlier pass was also a no-op.
163 $data['post_content'] = wp_slash(
164 wp_json_encode(
165 WP_REST_User_Post_Types_Controller_Gutenberg::normalize_config_for_encode( $clean ),
166 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
167 )
168 );
169
170 return $data;
171 }
172 add_filter( 'wp_insert_post_data', 'gutenberg_filter_user_post_type_post_content' );
173
174 /**
175 * Builds register_post_type() arguments from a wp_user_post_type record.
176 * Returns null for invalid records so callers can skip them uniformly.
177 *
178 * @param WP_Post $record Stored post type record.
179 * @return array{0: string, 1: array}|null [ $slug, $args ].
180 */
181 function gutenberg_build_user_post_type_args( WP_Post $record ) {
182 $slug = $record->post_name;
183 if ( ! is_string( $slug ) || ! preg_match( GUTENBERG_USER_POST_TYPE_SLUG_PATTERN, $slug ) ) {
184 return null;
185 }
186
187 $decoded = json_decode( (string) $record->post_content, true, 8 );
188 if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $decoded ) ) {
189 return null;
190 }
191 unset( $decoded[ GUTENBERG_USER_POST_TYPE_CONFIG_MARKER ] );
192 // Storage is sanitized at write-time by the filter on
193 // `wp_insert_post_data`, so we trust the decoded shape here.
194 $config = $decoded;
195
196 $title = sanitize_text_field( $record->post_title );
197 $singular = isset( $config['labels']['singular_name'] )
198 ? (string) $config['labels']['singular_name']
199 : '';
200 $labels = array(
201 'name' => $title,
202 'singular_name' => '' !== $singular ? $singular : $title,
203 );
204
205 // Merge optional label overrides. The sanitizer has already pruned
206 // unknown keys against the schema, so we can trust whatever the stored
207 // labels object contains. Empty strings fall through to the
208 // WordPress-generated defaults.
209 $stored_labels = isset( $config['labels'] ) && is_array( $config['labels'] )
210 ? $config['labels']
211 : array();
212 foreach ( array_keys( $stored_labels ) as $label_key ) {
213 if ( 'singular_name' === $label_key ) {
214 continue;
215 }
216 if ( ! empty( $stored_labels[ $label_key ] ) ) {
217 $labels[ $label_key ] = (string) $stored_labels[ $label_key ];
218 }
219 }
220
221 $supports = isset( $config['supports'] ) && is_array( $config['supports'] )
222 ? array_values( array_filter( $config['supports'], 'is_string' ) )
223 : array();
224 if ( empty( $supports ) ) {
225 // register_post_type() defaults to title+editor when supports is empty;
226 // preserve that intent rather than disabling all features.
227 $supports = array( 'title', 'editor' );
228 }
229
230 $is_hierarchical = ! empty( $config['hierarchical'] );
231
232 // Hierarchical post types need `page-attributes` for the parent picker
233 // (and menu order) to render in the block editor — `hierarchical` alone
234 // flips a flag in the registry but exposes no UI. Adding it implicitly
235 // here so the toggle "just works" without forcing users to also remember
236 // to check `page-attributes` in supports.
237 if ( $is_hierarchical && ! in_array( 'page-attributes', $supports, true ) ) {
238 $supports[] = 'page-attributes';
239 }
240
241 $args = array(
242 'labels' => $labels,
243 'public' => ! empty( $config['public'] ),
244 'hierarchical' => $is_hierarchical,
245 'has_archive' => ! empty( $config['has_archive'] ),
246 'show_in_rest' => isset( $config['show_in_rest'] ) ? (bool) $config['show_in_rest'] : true,
247 'supports' => $supports,
248 );
249
250 if ( ! empty( $config['description'] ) ) {
251 $args['description'] = (string) $config['description'];
252 }
253
254 // `taxonomies` here is the inverse of the taxonomy record's `object_type`:
255 // it lists the taxonomies attached to this post type. Only existing
256 // taxonomies are passed through so we never reference unregistered slugs.
257 if ( isset( $config['taxonomies'] ) && is_array( $config['taxonomies'] ) ) {
258 $taxonomies = array();
259 foreach ( $config['taxonomies'] as $tax_slug ) {
260 if ( is_string( $tax_slug ) && taxonomy_exists( $tax_slug ) ) {
261 $taxonomies[] = $tax_slug;
262 }
263 }
264 if ( ! empty( $taxonomies ) ) {
265 $args['taxonomies'] = $taxonomies;
266 }
267 }
268
269 return array( $slug, $args );
270 }
271
272 /**
273 * Materializes stored wp_user_post_type records into live registered
274 * post types by reading each published record and calling register_post_type()
275 * with a tightly-validated subset of its stored config.
276 *
277 * Drafts (post_status != 'publish') are skipped, so Edit's Active toggle
278 * gates whether a record is actually registered.
279 */
280 function gutenberg_register_user_defined_post_types() {
281 $records = get_posts(
282 array(
283 'post_type' => 'wp_user_post_type',
284 'post_status' => 'publish',
285 'posts_per_page' => -1,
286 'no_found_rows' => true,
287 'suppress_filters' => true,
288 )
289 );
290
291 foreach ( $records as $record ) {
292 $built = gutenberg_build_user_post_type_args( $record );
293 if ( null === $built ) {
294 continue;
295 }
296 list( $slug, $args ) = $built;
297
298 // Defense-in-depth: never overwrite an existing post type registration,
299 // even if a bad record slipped past server-side slug validation.
300 if ( post_type_exists( $slug ) ) {
301 continue;
302 }
303
304 register_post_type( $slug, $args );
305 }
306 }
307 // Priority 20 — must run before gutenberg_register_user_defined_taxonomies() (priority 25)
308 // so user CPT slugs exist when register_taxonomy() records the object_type association.
309 add_action( 'init', 'gutenberg_register_user_defined_post_types', 20 );
310