Skip to content

Commit eb13aa0

Browse files
obenlandclaude
andauthored
Pattern directory: align REST permission checks and status handling (#761)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e84ff78 commit eb13aa0

13 files changed

Lines changed: 1857 additions & 54 deletions

‎public_html/wp-content/plugins/pattern-directory/includes/class-rest-flags-controller.php‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ public function get_endpoint_args_for_item_schema( $method = WP_REST_Server::CRE
7878
public function get_items_permissions_check( $request ) {
7979
$parent_post_type = get_post_type_object( PATTERN );
8080

81-
if ( ! current_user_can( $parent_post_type->cap->edit_posts ) ) {
81+
// Flags name their reporter, so only moderators may list them.
82+
if ( ! current_user_can( $parent_post_type->cap->edit_others_posts ) ) {
8283
return new WP_Error(
8384
'rest_forbidden_context',
8485
__( 'Sorry, you are not allowed to view pattern flags.', 'wporg-patterns' ),
@@ -107,7 +108,10 @@ public function get_item_permissions_check( $request ) {
107108
return $parent;
108109
}
109110

110-
if ( ! current_user_can( 'edit_post', $parent->ID ) ) {
111+
$parent_post_type = get_post_type_object( PATTERN );
112+
113+
// Flags name their reporter, so only moderators may read them.
114+
if ( ! current_user_can( $parent_post_type->cap->edit_others_posts ) ) {
111115
return new WP_Error(
112116
'rest_cannot_read',
113117
__( 'Sorry, you are not allowed to view flags for this pattern.', 'wporg-patterns' ),

‎public_html/wp-content/plugins/pattern-directory/includes/pattern-post-type.php‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,9 +103,10 @@ function register_post_type_data() {
103103
'rewrite' => array(
104104
'slug' => 'pattern-keywords',
105105
),
106+
// Keywords are moderator-only (the `core` term feeds Core's pattern distribution), unlike categories.
106107
'capabilities' => array(
107-
'assign_terms' => 'edit_patterns',
108-
'edit_terms' => 'edit_patterns',
108+
'assign_terms' => 'edit_others_patterns',
109+
'edit_terms' => 'edit_others_patterns',
109110
),
110111

111112
'labels' => array(

‎public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php‎

Lines changed: 188 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_content', 10, 2 );
1010
add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_title', 11, 2 );
1111
add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_status', 11, 2 );
12+
add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_parent', 11, 2 );
1213
add_filter( 'rest_pre_insert_' . POST_TYPE, __NAMESPACE__ . '\validate_against_spam', 20, 2 );
14+
add_action( 'transition_post_status', __NAMESPACE__ . '\note_spam_status', 10, 3 );
1315

1416
/**
1517
* Strip out basic HTML to get at the manually-entered content in block content.
@@ -178,13 +180,15 @@ function validate_title( $prepared_post, $request ) {
178180
return $prepared_post;
179181
}
180182

181-
$status = isset( $request['status'] ) ? $request['status'] : get_post_status( $prepared_post->ID );
183+
$post = isset( $prepared_post->ID ) ? get_post( $prepared_post->ID ) : null;
184+
$status = isset( $request['status'] ) ? $request['status'] : ( $post ? $post->post_status : '' );
185+
182186
// Bypass this validation for drafts.
183187
if ( 'draft' === $status || 'auto-draft' === $status ) {
184188
return $prepared_post;
185189
}
186190

187-
$title = isset( $request['title'] ) ? $request['title'] : get_the_title( $prepared_post->ID );
191+
$title = isset( $request['title'] ) ? $request['title'] : ( $post ? $post->post_title : '' );
188192

189193
// A title exists, but is empty -- invalid.
190194
if ( isset( $title ) && empty( trim( $title ) ) ) {
@@ -209,8 +213,10 @@ function validate_title( $prepared_post, $request ) {
209213
/**
210214
* Validate the pattern status.
211215
*
212-
* Ensures patterns created via the API have either a non-public status (draft, unlisted),
213-
* or they use the chosen status set in /wp-admin/options-general.php?page=wporg-pattern-creator.
216+
* Ensures patterns created via the API are either drafts, or use the chosen status set in
217+
* /wp-admin/options-general.php?page=wporg-pattern-creator. The `unlisted` and spam statuses
218+
* are moderator-only, both as a target and as a source, so an author can neither self-unlist
219+
* nor undo a moderator's removal.
214220
*/
215221
function validate_status( $prepared_post, $request ) {
216222
if ( is_wp_error( $prepared_post ) ) {
@@ -219,10 +225,24 @@ function validate_status( $prepared_post, $request ) {
219225

220226
$post_type = get_post_type_object( POST_TYPE );
221227
$target_status = isset( $request['status'] ) ? $request['status'] : '';
222-
$current_status = get_post_status( $prepared_post->ID );
228+
$current_status = isset( $prepared_post->ID ) ? get_post_status( $prepared_post->ID ) : '';
229+
230+
// `unlisted` and spam are moderator-set; authors can't leave them. Must stay above the early returns below.
231+
if (
232+
in_array( $current_status, array( SPAM_STATUS, UNLISTED_STATUS ), true ) &&
233+
'' !== $target_status &&
234+
$current_status !== $target_status &&
235+
! current_user_can( $post_type->cap->edit_others_posts )
236+
) {
237+
return new \WP_Error(
238+
'rest_pattern_cannot_change_status',
239+
__( 'Only a directory moderator can change the status of this pattern.', 'wporg-patterns' ),
240+
array( 'status' => 403 )
241+
);
242+
}
223243

224-
// Drafts or unlisted patterns are OK.
225-
if ( in_array( $target_status, array( 'draft', 'auto-draft', UNLISTED_STATUS ) ) ) {
244+
// Drafts are OK.
245+
if ( in_array( $target_status, array( 'draft', 'auto-draft' ), true ) ) {
226246
return $prepared_post;
227247
}
228248

@@ -251,14 +271,54 @@ function validate_status( $prepared_post, $request ) {
251271
);
252272
}
253273

254-
// Do not allow for non-privledged users to move a spam post to another status.
255-
if ( SPAM_STATUS === $current_status && SPAM_STATUS !== $target_status ) {
274+
return $prepared_post;
275+
}
276+
277+
/**
278+
* Validate the pattern's parent.
279+
*
280+
* `parent` links a translated pattern to its English original and is written only by the translation cron,
281+
* never by a submitter. Core accepts it over REST because the field is in the schema, and validates only that
282+
* the id names an existing post — not its type, and not the caller's relationship to it. Left open, an author
283+
* can point their own submission at any published pattern and have the translation job adopt it.
284+
*
285+
* @param object $prepared_post The post object about to be inserted.
286+
* @param \WP_REST_Request $request The request.
287+
*
288+
* @return object|\WP_Error The post object, or an error if the parent is not the caller's to set.
289+
*/
290+
function validate_parent( $prepared_post, $request ) {
291+
if ( is_wp_error( $prepared_post ) ) {
292+
return $prepared_post;
293+
}
294+
295+
if ( ! isset( $request['parent'] ) ) {
296+
return $prepared_post;
297+
}
298+
299+
$existing = isset( $prepared_post->ID ) ? get_post( $prepared_post->ID ) : null;
300+
$current_parent = $existing ? (int) $existing->post_parent : 0;
301+
$target_parent = (int) $request['parent'];
302+
303+
// Re-sending the stored value isn't a write.
304+
if ( $target_parent === $current_parent ) {
305+
return $prepared_post;
306+
}
307+
308+
$post_type = get_post_type_object( POST_TYPE );
309+
if ( ! current_user_can( $post_type->cap->edit_others_posts ) ) {
310+
return new \WP_Error(
311+
'rest_pattern_cannot_set_parent',
312+
__( 'Only a directory moderator can set the parent of a pattern.', 'wporg-patterns' ),
313+
array( 'status' => 403 )
314+
);
315+
}
316+
317+
// A moderator's value still has to name another pattern.
318+
if ( $target_parent && POST_TYPE !== get_post_type( $target_parent ) ) {
256319
return new \WP_Error(
257-
'rest_pattern_invalid_status',
258-
sprintf(
259-
__( 'Invalid post status. Status must be %s.', 'wporg-patterns' ),
260-
SPAM_STATUS
261-
),
320+
'rest_pattern_invalid_parent',
321+
__( 'The parent of a pattern must be another pattern.', 'wporg-patterns' ),
262322
array( 'status' => 400 )
263323
);
264324
}
@@ -274,46 +334,139 @@ function validate_against_spam( $prepared_post, $request ) {
274334
return $prepared_post;
275335
}
276336

277-
$target_status = isset( $request['status'] ) ? $request['status'] : '';
337+
/*
338+
* `ID` is only set on an update: `WP_REST_Posts_Controller::prepare_item_for_database()` adds it when the
339+
* request names an existing post, so on a create there is no stored post to read a status from.
340+
*/
341+
$post = isset( $prepared_post->ID ) ? get_post( $prepared_post->ID ) : null;
342+
$current_status = $post ? $post->post_status : '';
278343

279-
// Run spam checks for publish & pending patterns.
344+
/*
345+
* An update that omits `status` leaves the pattern at the status it already has, so resolve to that
346+
* rather than to nothing. Reading it as "no status" is what let an author publish clean content and then
347+
* swap in the real payload with a status-less edit that never reached Akismet.
348+
*/
349+
$target_status = isset( $request['status'] ) ? $request['status'] : $current_status;
350+
351+
// Only patterns that are, or are becoming, publicly visible are worth the check.
280352
if ( 'publish' !== $target_status && 'pending' !== $target_status ) {
281353
return $prepared_post;
282354
}
283355

284-
$post = get_post( $prepared_post->ID );
356+
/*
357+
* An autosave that names no status can't make anything public: the controller either files it as a
358+
* revision, throwing the verdict away, or updates the author's own draft while leaving its status alone.
359+
* One that does name a status can publish a draft in place, so it still gets checked.
360+
*/
361+
if ( ! isset( $request['status'] ) && '/autosaves' === substr( (string) $request->get_route(), -10 ) ) {
362+
return $prepared_post;
363+
}
364+
365+
// Moderators are trusted, the same way `validate_status()` trusts them.
366+
if ( current_user_can( get_post_type_object( POST_TYPE )->cap->edit_others_posts ) ) {
367+
return $prepared_post;
368+
}
285369

286370
$pattern = array(
287-
'ID' => $post->ID,
288-
'post_name' => $post->post_name,
289-
'post_author' => $post->post_author,
290-
'title' => $prepared_post->post_title ?? $post->post_title,
291-
'content' => $prepared_post->post_content ?? $post->post_content,
292-
'description' => $request['meta']['wpop_description'] ?? ( $post->wpop_description ?: '' ),
293-
'keywords' => $request['meta']['wpop_keywords'] ?? ( $post->wpop_keywords ?: '' ),
371+
'ID' => $post->ID ?? 0,
372+
'post_name' => $post->post_name ?? '',
373+
'post_author' => $post->post_author ?? get_current_user_id(),
374+
'title' => $prepared_post->post_title ?? ( $post->post_title ?? '' ),
375+
'content' => $prepared_post->post_content ?? ( $post->post_content ?? '' ),
376+
'description' => $request['meta']['wpop_description'] ?? ( $post ? ( $post->wpop_description ?: '' ) : '' ),
377+
'keywords' => $request['meta']['wpop_keywords'] ?? ( $post ? ( $post->wpop_keywords ?: '' ) : '' ),
294378
);
295379

296380
list( $is_spam, $spam_reason ) = check_for_spam( $pattern );
297381

298-
// If it's been detected as spam, flag it as pending-review.
299382
if ( $is_spam ) {
300-
$prepared_post->post_status = SPAM_STATUS;
301-
302-
// Add a note explaining why this post is in pending, if it's due to spam.
303-
if ( function_exists( '\WordPressdotorg\InternalNotes\create_note' ) ) {
304-
\WordPressdotorg\InternalNotes\create_note(
305-
$prepared_post->ID,
306-
array(
307-
'post_author' => get_user_by( 'login', 'wordpressdotorg' )->ID ?? 0,
308-
'post_excerpt' => $spam_reason,
309-
)
383+
// Demoting an existing pattern on a heuristic is unrecoverable for its author, so refuse the edit.
384+
if ( in_array( $current_status, array( 'publish', 'pending' ), true ) ) {
385+
return new \WP_Error(
386+
'rest_pattern_spam_detected',
387+
__( 'These changes were caught by the spam filter, so they have not been saved. Your pattern is unchanged.', 'wporg-patterns' ),
388+
array( 'status' => 400 )
310389
);
311390
}
391+
392+
// Anything not yet public goes to the moderation queue as before.
393+
$prepared_post->post_status = SPAM_STATUS;
394+
spam_reason( $prepared_post->ID ?? 0, $spam_reason );
312395
}
313396

314397
return $prepared_post;
315398
}
316399

400+
/**
401+
* Hold the reason a pattern was flagged as spam, until its status is actually saved.
402+
*
403+
* `validate_against_spam()` decides before anything is written, and that decision can be discarded, so the
404+
* note has to wait. Keyed by pattern because a single request can write more than one -- a `batch/v1`
405+
* envelope, a WP-CLI import loop -- and one pattern's reason must not be noted against another. Reading
406+
* consumes, so an unconsumed reason can't leak into a later write.
407+
*
408+
* @param int $pattern_id The pattern the reason belongs to, 0 while it is still being created.
409+
* @param string|null $reason Reason to store, or null to read and consume the stored one.
410+
*
411+
* @return string The stored reason, or '' if there isn't one for this pattern.
412+
*/
413+
function spam_reason( $pattern_id, $reason = null ) {
414+
static $reasons = array();
415+
416+
$key = (int) $pattern_id;
417+
418+
if ( null !== $reason ) {
419+
$reasons[ $key ] = $reason;
420+
421+
return $reason;
422+
}
423+
424+
if ( ! isset( $reasons[ $key ] ) ) {
425+
return '';
426+
}
427+
428+
$stored = $reasons[ $key ];
429+
unset( $reasons[ $key ] );
430+
431+
return $stored;
432+
}
433+
434+
/**
435+
* Record why a pattern was quarantined, once that status has actually been saved.
436+
*
437+
* @param string $new_status The status the pattern moved to.
438+
* @param string $old_status The status it moved from.
439+
* @param \WP_Post $post The pattern.
440+
*
441+
* @return void
442+
*/
443+
function note_spam_status( $new_status, $old_status, $post ) {
444+
if ( POST_TYPE !== $post->post_type || SPAM_STATUS !== $new_status || $new_status === $old_status ) {
445+
return;
446+
}
447+
448+
$reason = spam_reason( $post->ID );
449+
450+
// A pattern flagged as it was created had no ID to record against.
451+
if ( ! $reason && in_array( $old_status, array( 'new', 'auto-draft' ), true ) ) {
452+
$reason = spam_reason( 0 );
453+
}
454+
455+
if ( ! $reason ) {
456+
return;
457+
}
458+
459+
if ( function_exists( '\WordPressdotorg\InternalNotes\create_note' ) ) {
460+
\WordPressdotorg\InternalNotes\create_note(
461+
$post->ID,
462+
array(
463+
'post_author' => get_user_by( 'login', 'wordpressdotorg' )->ID ?? 0,
464+
'post_excerpt' => $reason,
465+
)
466+
);
467+
}
468+
}
469+
317470
/**
318471
* Helper function to check for spam.
319472
*

0 commit comments

Comments
 (0)