99add_filter ( 'rest_pre_insert_ ' . POST_TYPE , __NAMESPACE__ . '\validate_content ' , 10 , 2 );
1010add_filter ( 'rest_pre_insert_ ' . POST_TYPE , __NAMESPACE__ . '\validate_title ' , 11 , 2 );
1111add_filter ( 'rest_pre_insert_ ' . POST_TYPE , __NAMESPACE__ . '\validate_status ' , 11 , 2 );
12+ add_filter ( 'rest_pre_insert_ ' . POST_TYPE , __NAMESPACE__ . '\validate_parent ' , 11 , 2 );
1213add_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 */
215221function 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