diff --git a/public_html/wp-content/plugins/pattern-directory/includes/notifications.php b/public_html/wp-content/plugins/pattern-directory/includes/notifications.php index 0875aeaa..5a929f3d 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/notifications.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/notifications.php @@ -13,10 +13,43 @@ defined( 'WPINC' ) || die(); +/** + * The post meta key holding the moderator's message to the author, which is + * included in the "pattern unlisted" email. + */ +const UNLISTED_DETAIL_META = '_wporg_unlist_reason_detail'; + /** * Actions and filters. */ add_action( 'wp_after_insert_post', __NAMESPACE__ . '\trigger_notifications', 20, 4 ); +add_action( 'init', __NAMESPACE__ . '\register_unlisted_meta' ); + +/** + * Register the post meta used to store the moderator's message to the author. + * + * Only moderators can write it, from the Unlist modal in the block editor. It is + * read in the edit context only, so the public API never exposes it, and it is + * deleted as soon as the pattern is unlisted. + * + * @return void + */ +function register_unlisted_meta() { + register_post_meta( + PATTERN, + UNLISTED_DETAIL_META, + array( + 'type' => 'string', + 'description' => 'A message from the moderator, included in the email sent to the author when a pattern is unlisted.', + 'single' => true, + 'show_in_rest' => array( 'schema' => array( 'context' => array( 'edit' ) ) ), + 'sanitize_callback' => 'sanitize_textarea_field', + 'auth_callback' => function () { + return current_user_can( get_post_type_object( PATTERN )->cap->edit_others_posts ); + }, + ) + ); +} /** * Fire off relevant notification when a post is finished updating. @@ -196,6 +229,11 @@ function ( \WP_Term $reason ) { * @return void */ function notify_pattern_unlisted( $post ) { + // The message is single-use: consume it before anything can bail out, so a + // later unlisting doesn't resend it. + $detail = get_post_meta( $post->ID, UNLISTED_DETAIL_META, true ); + delete_post_meta( $post->ID, UNLISTED_DETAIL_META ); + $author = get_user_by( 'id', $post->post_author ); if ( ! $author ) { return; @@ -221,6 +259,11 @@ function notify_pattern_unlisted( $post ) { $reason = get_default_reason_description(); } + // Append the moderator's message to the author, if one was provided. + if ( $detail ) { + $reason .= "\n\n" . $detail; + } + $subject = esc_html__( 'Pattern unlisted', 'wporg-patterns' ); $message = sprintf( diff --git a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php index 20a7c345..d39da6e3 100644 --- a/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php +++ b/public_html/wp-content/plugins/pattern-directory/includes/pattern-validation.php @@ -15,6 +15,7 @@ use function WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\has_reached_flag_threshold; use const WordPressdotorg\Pattern_Directory\Pattern_Post_Type\{ POST_TYPE, UNLISTED_STATUS, SPAM_STATUS }; use const WordPressdotorg\Pattern_Directory\Pattern_Flag_Post_Type\TAX_TYPE as FLAG_REASON; +use const WordPressdotorg\Pattern_Directory\Notifications\UNLISTED_DETAIL_META; /** * The rendered text fields, the ones the directory outputs and so has to check. @@ -57,7 +58,7 @@ function reject_control_characters( $prepared_post, $request ) { return $prepared_post; } - $values = array( $request['meta'] ?? null ); + $values = array( get_rendered_meta( $request ) ); foreach ( RENDERED_FIELDS as $field ) { $values[] = $prepared_post->$field ?? null; } @@ -73,6 +74,24 @@ function reject_control_characters( $prepared_post, $request ) { return $prepared_post; } +/** + * The submitted meta that the directory may render, and so has to check. + * + * The moderator's unlisting message is left out: it is only ever sent to the author as a plain-text + * email, so checking it would just block the unlisting it travels with. + * + * @param \WP_REST_Request $request Request being validated. + * @return mixed The submitted meta, without the unlisting message. + */ +function get_rendered_meta( $request ) { + $meta = $request['meta'] ?? null; + if ( is_array( $meta ) ) { + unset( $meta[ UNLISTED_DETAIL_META ] ); + } + + return $meta; +} + /** * Whether a value, or anything nested in one, carries a control character. * @@ -580,8 +599,9 @@ function validate_block_directives( $prepared_post, $request ) { * Meta never reaches `$prepared_post`, and `render_block_core_footnotes()` emits `footnotes` through * `wp_kses_post()`, which keeps `data-*`. */ - if ( ! $has_directive && is_array( $request['meta'] ?? null ) ) { - $has_directive = attribute_has_directive( $request['meta'] ); + $meta = get_rendered_meta( $request ); + if ( ! $has_directive && is_array( $meta ) ) { + $has_directive = attribute_has_directive( $meta ); } if ( $has_directive ) { diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js index 7c9a923f..834dda85 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/details.js @@ -2,10 +2,9 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { PluginDocumentSettingPanel } from '@wordpress/edit-post'; import { ComboboxControl, FormTokenField, TextControl, TextareaControl } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -import { store as editorStore } from '@wordpress/editor'; +import { PluginDocumentSettingPanel, store as editorStore } from '@wordpress/editor'; const KEYWORD_SLUG = 'wpop_keywords'; const DESCRIPTION_SLUG = 'wpop_description'; diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js index 5ab72c2a..e457fcd9 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/index.js @@ -2,9 +2,8 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { PluginPostStatusInfo } from '@wordpress/edit-post'; import { Button } from '@wordpress/components'; -import { store as editorStore } from '@wordpress/editor'; +import { PluginPostStatusInfo, store as editorStore } from '@wordpress/editor'; import { useDispatch, useSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; @@ -20,15 +19,21 @@ export const UnlistButton = () => { const _post = select( editorStore ).getCurrentPost(); return _post.status; } ); + const { didPostSaveRequestFail } = useSelect( editorStore ); const { editPost, savePost } = useDispatch( editorStore ); const [ showModal, setShowModal ] = useState( false ); - const onSubmit = ( reasonId ) => { + const onSubmit = async ( reasonId, details = '' ) => { editPost( { status: UNLISTED_STATUS, 'wporg-pattern-flag-reason': [ reasonId ], + meta: { _wporg_unlist_reason_detail: details }, } ); - savePost(); + // `savePost` resolves even when the request fails, so check the result. + await savePost(); + if ( didPostSaveRequestFail() ) { + throw new Error( __( 'The pattern could not be unlisted. Please try again.', 'wporg-patterns' ) ); + } }; const className = status === UNLISTED_STATUS ? 'wporg-patterns-unlist-notice' : 'wporg-patterns-unlist-button'; diff --git a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js index da2e1228..1e3e8152 100644 --- a/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js +++ b/public_html/wp-content/plugins/pattern-directory/src/pattern-post-type/unlist-button/modal.js @@ -73,10 +73,22 @@ const UnlistModal = ( { onClose, onSubmit } ) => { } ); }, [] ); - const submittedText = __( - 'The pattern has been unlisted, and your internal note has been saved.', - 'wporg-patterns' - ); + const submittedText = __( 'The pattern has been unlisted.', 'wporg-patterns' ); + + const handleError = ( err ) => { + dispatch( { + status: 'ERROR', + message: err.message, + } ); + + speak( + sprintf( + /* translators: %s: Error message. */ + __( 'Error: %s', 'wporg-patterns' ), + err.message + ) + ); + }; const handleSubmit = ( event ) => { event.preventDefault(); @@ -99,28 +111,20 @@ const UnlistModal = ( { onClose, onSubmit } ) => { sendUnlistedNote( { url: apiUrl, note: details ? `UNLISTED: ${ reason.label } — ${ details }` : `UNLISTED: ${ reason.label }`, - onSuccess: () => { - if ( 'function' === typeof onSubmit ) { - onSubmit( selectedOption ); + onSuccess: async () => { + try { + if ( 'function' === typeof onSubmit ) { + await onSubmit( selectedOption, details ); + } + } catch ( err ) { + handleError( err ); + return; } dispatch( { status: 'NOTE_RECIEVED' } ); speak( submittedText ); container.current.closest( '[role="dialog"]' ).focus(); }, - onFailure: ( err ) => { - dispatch( { - status: 'ERROR', - message: err.message, - } ); - - speak( - sprintf( - /* translators: %s: Error message. */ - __( 'Error: %s', 'wporg-patterns' ), - err.message - ) - ); - }, + onFailure: handleError, } ); }; const handleClose = () => { @@ -155,9 +159,9 @@ const UnlistModal = ( { onClose, onSubmit } ) => { ) } user->create( array( 'role' => 'editor' ) ); + self::$member = $factory->user->create( array( 'role' => 'subscriber' ) ); + + self::$pattern_id = $factory->post->create( + array( + 'post_type' => POST_TYPE, + 'post_author' => self::$member, + 'post_title' => 'Stylized Quote and Citation', + 'post_content' => + "

A curated layout

\n\n" . + "

Some descriptive copy for the pattern.

\n\n" . + '
', + 'post_status' => 'publish', + ) + ); + } + + /** + * Clean up shared fixtures. + */ + public static function tear_down_after_class(): void { + wp_delete_post( self::$pattern_id, true ); + wp_delete_user( self::$moderator ); + wp_delete_user( self::$member ); + + parent::tear_down_after_class(); + } + + /** + * Register the meta, capture email without delivering it, and start each test from a published pattern. + */ + public function set_up(): void { + parent::set_up(); + + /* + * The test case resets post types between classes, which drops their registered meta. Register it + * again, and drop the cached controller and server so the REST schema picks it up. + */ + register_unlisted_meta(); + get_post_type_object( POST_TYPE )->rest_controller = null; + $GLOBALS['wp_rest_server'] = null; + + wp_update_post( + array( + 'ID' => self::$pattern_id, + 'post_status' => 'publish', + ) + ); + delete_post_meta( self::$pattern_id, UNLISTED_DETAIL_META ); + + $this->messages = array(); + add_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10, 2 ); + } + + /** + * Stop capturing email and reset the current user. + */ + public function tear_down(): void { + remove_filter( 'pre_wp_mail', array( $this, 'capture_mail' ), 10 ); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Record an email body instead of sending it. + * + * @param bool|null $result Short-circuit result. + * @param array $atts Mail arguments. + * @return bool + */ + public function capture_mail( ?bool $result, array $atts ): bool { + $this->messages[] = $atts['message']; + return true; + } + + /** + * Dispatch a pattern update with the given body. + * + * @param array $body Request body. + * @return \WP_REST_Response + */ + protected function update_pattern( array $body ): \WP_REST_Response { + $request = new WP_REST_Request( 'POST', '/wp/v2/' . POST_TYPE . '/' . self::$pattern_id ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + + return rest_do_request( $request ); + } + + /** + * The message reaches the author, even one that mentions a directive, and is consumed by it. + * + * @covers \WordPressdotorg\Pattern_Directory\Notifications\notify_pattern_unlisted + * @covers \WordPressdotorg\Pattern_Directory\Pattern_Validation\get_rendered_meta + */ + public function test_message_is_emailed_once(): void { + wp_set_current_user( self::$moderator ); + $detail = 'Please remove the data-wp-interactive attribute and resubmit.'; + + $response = $this->update_pattern( + array( + 'status' => UNLISTED_STATUS, + 'meta' => array( UNLISTED_DETAIL_META => $detail ), + ) + ); + + $this->assertFalse( $response->is_error() ); + $this->assertSame( UNLISTED_STATUS, get_post_status( self::$pattern_id ) ); + $this->assertCount( 1, $this->messages ); + $this->assertStringContainsString( $detail, $this->messages[0] ); + $this->assertSame( '', get_post_meta( self::$pattern_id, UNLISTED_DETAIL_META, true ) ); + + // Relisted, then unlisted again from the list screen: the earlier message is not resent. + wp_update_post( + array( + 'ID' => self::$pattern_id, + 'post_status' => 'publish', + ) + ); + wp_update_post( + array( + 'ID' => self::$pattern_id, + 'post_status' => UNLISTED_STATUS, + ) + ); + + $this->assertCount( 3, $this->messages ); + $this->assertStringNotContainsString( $detail, $this->messages[2] ); + } + + /** + * The author cannot set the message themselves. + * + * @covers \WordPressdotorg\Pattern_Directory\Notifications\register_unlisted_meta + */ + public function test_member_cannot_set_message(): void { + wp_set_current_user( self::$member ); + + $response = $this->update_pattern( + array( 'meta' => array( UNLISTED_DETAIL_META => 'Written by the author.' ) ) + ); + + $this->assertTrue( $response->is_error() ); + $this->assertSame( 403, $response->get_status() ); + $this->assertSame( '', get_post_meta( self::$pattern_id, UNLISTED_DETAIL_META, true ) ); + } + + /** + * The message is never in the public API. + * + * @covers \WordPressdotorg\Pattern_Directory\Notifications\register_unlisted_meta + */ + public function test_message_is_not_public(): void { + update_post_meta( self::$pattern_id, UNLISTED_DETAIL_META, 'For the author only.' ); + + $response = rest_do_request( new WP_REST_Request( 'GET', '/wp/v2/' . POST_TYPE . '/' . self::$pattern_id ) ); + + $this->assertFalse( $response->is_error() ); + $this->assertArrayNotHasKey( UNLISTED_DETAIL_META, $response->get_data()['meta'] ); + } +}