@@ -81,6 +81,17 @@ class WP_Content_Abilities {
8181 */
8282 const MAX_PER_PAGE = 100 ;
8383
84+ /**
85+ * Post types exposed through the Abilities API, computed once at registration.
86+ *
87+ * Cached so the input schema and the permission/execute callbacks derive from the exact
88+ * same set, and the post type list is only walked once per request.
89+ *
90+ * @since 7.1.0
91+ * @var array<string, WP_Post_Type>|null
92+ */
93+ private static ?array $ exposed_post_types = null ;
94+
8495 /**
8596 * Registers all content abilities.
8697 *
@@ -105,7 +116,10 @@ public static function register(): void {
105116 * @since 7.1.0
106117 */
107118 public static function register_get_content (): void {
108- $ post_types = array_keys ( self ::get_exposed_post_types () );
119+ // Compute once; check_permission()/execute_get_content() reuse this set.
120+ self ::$ exposed_post_types = self ::get_exposed_post_types ();
121+
122+ $ post_types = array_keys ( self ::$ exposed_post_types );
109123 $ statuses = self ::get_available_statuses ();
110124
111125 wp_register_ability (
@@ -149,11 +163,11 @@ public static function register_get_content(): void {
149163 */
150164 public static function check_permission ( $ input = array () ): bool {
151165 $ input = is_array ( $ input ) ? $ input : array ();
152- $ exposed = self ::get_exposed_post_types ();
166+ $ exposed = self ::$ exposed_post_types ?? self :: get_exposed_post_types ();
153167
154168 // Single-post mode (by ID).
155169 if ( ! empty ( $ input ['id ' ] ) ) {
156- $ post = get_post ( ( int ) $ input ['id ' ] );
170+ $ post = get_post ( self :: input_int ( $ input ['id ' ] ) );
157171
158172 /*
159173 * For a missing post, an unexposed post type, or a post type that does not
@@ -173,15 +187,15 @@ public static function check_permission( $input = array() ): bool {
173187 }
174188
175189 // Query / slug mode requires an exposed post type.
176- $ post_type = isset ( $ input ['post_type ' ] ) ? ( string ) $ input ['post_type ' ] : '' ;
190+ $ post_type = isset ( $ input ['post_type ' ] ) && is_string ( $ input [ ' post_type ' ] ) ? $ input ['post_type ' ] : '' ;
177191 if ( '' === $ post_type || ! isset ( $ exposed [ $ post_type ] ) ) {
178192 return false ;
179193 }
180194
181195 $ post_type_object = $ exposed [ $ post_type ];
182196
183197 // Base gate: must be able to read this post type at all.
184- if ( ! current_user_can ( $ post_type_object-> cap -> read ?? 'read ' ) ) {
198+ if ( ! current_user_can ( self :: capability ( $ post_type_object, ' read ' , 'read ' ) ) ) {
185199 return false ;
186200 }
187201
@@ -193,12 +207,12 @@ public static function check_permission( $input = array() ): bool {
193207 }
194208
195209 // Editors/authors of this post type may request any status set.
196- if ( current_user_can ( $ post_type_object-> cap -> edit_posts ?? 'edit_posts ' ) ) {
210+ if ( current_user_can ( self :: capability ( $ post_type_object, ' edit_posts ' , 'edit_posts ' ) ) ) {
197211 return true ;
198212 }
199213
200214 // Otherwise, private posts are allowed only with read_private_posts.
201- if ( current_user_can ( $ post_type_object-> cap -> read_private_posts ?? 'read_private_posts ' ) ) {
215+ if ( current_user_can ( self :: capability ( $ post_type_object, ' read_private_posts ' , 'read_private_posts ' ) ) ) {
202216 foreach ( $ statuses as $ status ) {
203217 if ( 'private ' !== $ status && 'publish ' !== $ status ) {
204218 return false ;
@@ -210,6 +224,34 @@ public static function check_permission( $input = array() ): bool {
210224 return false ;
211225 }
212226
227+ /**
228+ * Resolves a capability name from a post type's capability object, with a fallback.
229+ *
230+ * @since 7.1.0
231+ *
232+ * @param WP_Post_Type $post_type_object The post type object.
233+ * @param string $name Capability key on the post type's `cap` object.
234+ * @param string $fallback Fallback capability name if unset or non-string.
235+ * @return string The resolved capability name.
236+ */
237+ protected static function capability ( WP_Post_Type $ post_type_object , string $ name , string $ fallback ): string {
238+ $ capability = $ post_type_object ->cap ->$ name ?? $ fallback ;
239+
240+ return is_string ( $ capability ) ? $ capability : $ fallback ;
241+ }
242+
243+ /**
244+ * Casts a raw input value to a non-negative integer.
245+ *
246+ * @since 7.1.0
247+ *
248+ * @param mixed $value The raw input value.
249+ * @return int The value as a non-negative integer, or 0 when not scalar.
250+ */
251+ protected static function input_int ( $ value ): int {
252+ return is_scalar ( $ value ) ? absint ( $ value ) : 0 ;
253+ }
254+
213255 /**
214256 * Executes the `core/content` ability.
215257 *
@@ -220,12 +262,12 @@ public static function check_permission( $input = array() ): bool {
220262 */
221263 public static function execute_get_content ( $ input = array () ) {
222264 $ input = is_array ( $ input ) ? $ input : array ();
223- $ exposed = self ::get_exposed_post_types ();
265+ $ exposed = self ::$ exposed_post_types ?? self :: get_exposed_post_types ();
224266 $ fields = self ::normalize_fields ( $ input );
225267
226268 // Single-post mode (by ID).
227269 if ( ! empty ( $ input ['id ' ] ) ) {
228- $ post = get_post ( ( int ) $ input ['id ' ] );
270+ $ post = get_post ( self :: input_int ( $ input ['id ' ] ) );
229271
230272 if ( ! $ post
231273 || ! isset ( $ exposed [ $ post ->post_type ] )
@@ -243,13 +285,13 @@ public static function execute_get_content( $input = array() ) {
243285 }
244286
245287 // Query / slug mode.
246- $ post_type = isset ( $ input ['post_type ' ] ) ? ( string ) $ input ['post_type ' ] : '' ;
288+ $ post_type = isset ( $ input ['post_type ' ] ) && is_string ( $ input [ ' post_type ' ] ) ? $ input ['post_type ' ] : '' ;
247289 if ( '' === $ post_type || ! isset ( $ exposed [ $ post_type ] ) ) {
248290 return self ::not_found_error ();
249291 }
250292
251293 $ per_page = self ::normalize_per_page ( $ input );
252- $ page = isset ( $ input ['page ' ] ) ? max ( 1 , ( int ) $ input ['page ' ] ) : 1 ;
294+ $ page = isset ( $ input ['page ' ] ) ? max ( 1 , self :: input_int ( $ input ['page ' ] ) ) : 1 ;
253295
254296 $ query_args = array (
255297 'post_type ' => $ post_type ,
@@ -259,22 +301,25 @@ public static function execute_get_content( $input = array() ) {
259301 'ignore_sticky_posts ' => true ,
260302 );
261303
262- if ( ! empty ( $ input ['slug ' ] ) ) {
263- $ query_args ['name ' ] = sanitize_title ( ( string ) $ input ['slug ' ] );
304+ if ( ! empty ( $ input ['slug ' ] ) && is_string ( $ input [ ' slug ' ] ) ) {
305+ $ query_args ['name ' ] = sanitize_title ( $ input ['slug ' ] );
264306 }
265307
266308 if ( ! empty ( $ input ['author ' ] ) ) {
267- $ query_args ['author ' ] = ( int ) $ input ['author ' ];
309+ $ query_args ['author ' ] = self :: input_int ( $ input ['author ' ] ) ;
268310 }
269311
270312 if ( isset ( $ input ['parent ' ] ) ) {
271- $ query_args ['post_parent ' ] = ( int ) $ input ['parent ' ];
313+ $ query_args ['post_parent ' ] = self :: input_int ( $ input ['parent ' ] ) ;
272314 }
273315
274316 $ query = new WP_Query ( $ query_args );
275317
276318 $ posts = array ();
277319 foreach ( $ query ->posts as $ post ) {
320+ if ( ! $ post instanceof WP_Post ) {
321+ continue ;
322+ }
278323 // Authoritative, row-level visibility check (author/status scoped).
279324 if ( ! current_user_can ( 'read_post ' , $ post ->ID ) ) {
280325 continue ;
@@ -294,11 +339,11 @@ public static function execute_get_content( $input = array() ) {
294339 *
295340 * @since 7.1.0
296341 *
297- * @param array<string, mixed> $input The ability input.
342+ * @param array<mixed> $input The ability input.
298343 * @return int The clamped per-page value.
299344 */
300345 protected static function normalize_per_page ( array $ input ): int {
301- $ per_page = isset ( $ input ['per_page ' ] ) ? ( int ) $ input ['per_page ' ] : self ::DEFAULT_PER_PAGE ;
346+ $ per_page = isset ( $ input ['per_page ' ] ) ? self :: input_int ( $ input ['per_page ' ] ) : self ::DEFAULT_PER_PAGE ;
302347
303348 return max ( 1 , min ( self ::MAX_PER_PAGE , $ per_page ) );
304349 }
@@ -343,16 +388,18 @@ protected static function get_available_statuses(): array {
343388 *
344389 * @since 7.1.0
345390 *
346- * @param array<string, mixed> $input The ability input.
391+ * @param array<mixed> $input The ability input.
347392 * @return string[] Normalized list of post status slugs.
348393 */
349394 protected static function normalize_statuses ( array $ input ): array {
350395 $ statuses = $ input ['status ' ] ?? array ( 'publish ' );
351- if ( ! is_array ( $ statuses ) || array () === $ statuses ) {
396+ if ( ! is_array ( $ statuses ) ) {
352397 return array ( 'publish ' );
353398 }
354399
355- return array_map ( 'sanitize_key ' , $ statuses );
400+ $ statuses = array_values ( array_filter ( $ statuses , 'is_string ' ) );
401+
402+ return array () === $ statuses ? array ( 'publish ' ) : array_map ( 'sanitize_key ' , $ statuses );
356403 }
357404
358405 /**
@@ -362,15 +409,16 @@ protected static function normalize_statuses( array $input ): array {
362409 *
363410 * @since 7.1.0
364411 *
365- * @param array<string, mixed> $input The ability input.
412+ * @param array<mixed> $input The ability input.
366413 * @return string[] List of requested field names.
367414 */
368415 protected static function normalize_fields ( array $ input ): array {
369416 if ( empty ( $ input ['fields ' ] ) || ! is_array ( $ input ['fields ' ] ) ) {
370417 return self ::FIELDS ;
371418 }
372419
373- $ fields = array_intersect ( self ::FIELDS , array_map ( 'strval ' , $ input ['fields ' ] ) );
420+ $ requested = array_filter ( $ input ['fields ' ], 'is_string ' );
421+ $ fields = array_intersect ( self ::FIELDS , $ requested );
374422
375423 return array () === $ fields ? self ::FIELDS : array_values ( $ fields );
376424 }
@@ -387,7 +435,8 @@ protected static function normalize_fields( array $input ): array {
387435 protected static function get_content_input_schema ( array $ post_types , array $ statuses ): array {
388436 return array (
389437 'type ' => 'object ' ,
390- 'default ' => array (),
438+ // Object (not array()) so the serialized schema default is {}, consistent with type:object.
439+ 'default ' => (object ) array (),
391440 // `post_type` is required unless a single post is requested by `id`.
392441 'anyOf ' => array (
393442 array ( 'required ' => array ( 'id ' ) ),
@@ -670,6 +719,7 @@ public static function return_raw_title_format(): string {
670719 * @return string The ISO 8601 date, or an empty string if unavailable.
671720 */
672721 protected static function format_gmt_date ( WP_Post $ post , string $ field ): string {
722+ $ field = 'modified ' === $ field ? 'modified ' : 'date ' ;
673723 $ datetime = get_post_datetime ( $ post , $ field , 'gmt ' );
674724 if ( $ datetime ) {
675725 return $ datetime ->format ( 'c ' );
0 commit comments