-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Suggest mode 2/9: suggestion storage, REST controller, and provider #80428
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Open
Changes from all commits
Commits
Show all changes
60 commits
Select commit
Hold shift + click to select a range
f9baca5
Suggest mode 2/7: suggestion storage, REST controller, and provider
adamsilverstein 5d67f02
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 0c3c9bc
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 548a812
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 60d20a1
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 93e5cad
Drop dependency-group comment blocks in suggestion mode data files
adamsilverstein 568e8d6
Suggest mode: cancel pending auto-saves when leaving Suggest intent
adamsilverstein ff96c0f
Suggest mode: persist a structural decision before mutating the block…
adamsilverstein 31d5e28
Merge branch 'suggest/intent' into suggest/data
adamsilverstein b50655b
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 7b51646
Convert suggestion-mode data layer to TypeScript
adamsilverstein 24e1a2e
Merge remote-tracking branch 'origin/trunk' into suggest/data
adamsilverstein 788481e
Merge branch 'suggest/intent' into suggest/data
adamsilverstein ef43f1c
Merge remote-tracking branch 'origin/trunk' into suggest/data
adamsilverstein 8aec65e
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 72c3bb9
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 3304c95
Repoint the suggestion-mode imports at the store constants
adamsilverstein eabf601
Merge branch 'relocate/intent' into relocate/data
adamsilverstein c778241
Move the storage-layer review fixes into the storage layer
adamsilverstein bf4e03e
Merge suggest/intent into suggest/data
adamsilverstein c05f47a
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein 6b52cd1
Merge branch 'suggest/intent' into suggest/data
adamsilverstein e09d869
Merge branch 'suggest/intent' into suggest/data
adamsilverstein ec0f118
Name DOM unit tests for the jsdom Jest project
adamsilverstein 79cb562
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 1545656
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 15093ba
Type the interface store import and the plugin global without directives
adamsilverstein fb2285f
Run the data layer's unit tests under Vitest
adamsilverstein 6f1b6fc
Merge branch 'suggest/intent' into suggest/data
adamsilverstein e9d82f9
Merge branch 'suggest/intent' into suggest/data
adamsilverstein a8ea6eb
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 18a38c8
Merge branch 'suggest/intent' into suggest/data
adamsilverstein bb2db06
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 70d3146
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 5487e49
Suggest mode: drop the `@wordpress/interface` typings shim
adamsilverstein 2807883
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 445c519
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein d8b1597
Suggest mode: reformat for Prettier 3.9.6
adamsilverstein a32a57d
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein b0c7248
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein e1998ee
Suggest mode: clear the overlay when rejecting an attribute suggestion
adamsilverstein 723f771
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein ceb3997
Suggest mode: resolve the live parent when rejecting a move
adamsilverstein e4fdd2f
Suggest mode: flush pending suggestions when leaving Suggesting
adamsilverstein 429da7a
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein 34431b4
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein ce42365
Suggestions: KSES every string leaf of applied payload values
adamsilverstein 92707f2
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein 9e649a9
Suggestion mode: Let blocks subscribe to their own overlay entry
adamsilverstein 325717e
Suggestion mode: Debounce auto-save per changed block, flush on unmount
adamsilverstein 9108e5a
Suggestion mode: Expose whether an interceptor bypass is pending
adamsilverstein 1d70d0f
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein 3219749
Suggestion mode: Pass a valid status to createNotice
adamsilverstein 872f58d
Merge remote-tracking branch 'origin/suggest/intent' into suggest/data
adamsilverstein 4402200
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 9fdfbbd
Merge branch 'suggest/intent' into suggest/data
adamsilverstein 84f62d3
Suggest mode: Unlink a withdrawn suggestion from its block
adamsilverstein 489b8ea
Suggest mode: Keep the overlay entry when an apply fails to save
adamsilverstein b33ba84
Suggest mode: Drop the redundant note update permission shortcut
adamsilverstein 4cb645c
Merge branch 'suggest/intent' into suggest/data
adamsilverstein File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,209 @@ | ||
| <?php | ||
| /** | ||
| * Suggestion data support for suggest mode. | ||
| * | ||
| * Registers the comment meta that backs a suggested edit. A note-type comment | ||
| * becomes a suggestion when it carries a `_wp_suggestion` payload; | ||
| * `_wp_suggestion_status` tracks its apply/reject lifecycle. The base note | ||
| * infrastructure graduated to WordPress 6.9 core, so only the | ||
| * suggestion-specific additions live here in the 7.1 compat layer. | ||
| * | ||
| * @package gutenberg | ||
| */ | ||
|
|
||
| /** | ||
| * Maximum byte length of a `_wp_suggestion` payload. Mirrored on the client | ||
| * (`PAYLOAD_MAX_BYTES` in suggestion-mode/provider.js) so the editor refuses | ||
| * to submit anything the server will reject. | ||
| */ | ||
| if ( ! defined( 'GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES' ) ) { | ||
| define( 'GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES', 65536 ); | ||
| } | ||
|
|
||
| /** | ||
| * Applies `wp_kses_post()` to the HTML-bearing string fields of a serialized | ||
| * block snapshot carried inside a suggestion operation (`op.block` on | ||
| * `block-remove` / `block-insert-after` ops), recursing into `innerBlocks`. | ||
| * | ||
| * `innerHTML` and `originalContent` are the fields a consumer turns back | ||
| * into markup when the block is re-inserted. `attributes` get the same | ||
| * string-leaf walk core applies to parsed blocks, so no nested value | ||
| * escapes the filter. | ||
| * | ||
| * @param array $block Serialized block snapshot (decoded from JSON). | ||
| * @return array Snapshot with HTML-bearing fields filtered. | ||
| */ | ||
| function gutenberg_kses_suggestion_block_snapshot( $block ) { | ||
| foreach ( array( 'innerHTML', 'originalContent' ) as $key ) { | ||
| if ( isset( $block[ $key ] ) && is_string( $block[ $key ] ) ) { | ||
| $block[ $key ] = wp_kses_post( $block[ $key ] ); | ||
| } | ||
| } | ||
| if ( isset( $block['attributes'] ) && is_array( $block['attributes'] ) ) { | ||
| $block['attributes'] = filter_block_kses_value( $block['attributes'], 'post' ); | ||
| } | ||
| if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { | ||
| foreach ( $block['innerBlocks'] as $index => $inner_block ) { | ||
| if ( is_array( $inner_block ) ) { | ||
| $block['innerBlocks'][ $index ] = gutenberg_kses_suggestion_block_snapshot( $inner_block ); | ||
| } | ||
| } | ||
| } | ||
| return $block; | ||
| } | ||
|
|
||
| /** | ||
| * Sanitizes a `_wp_suggestion` payload to match what the writing user could | ||
| * publish directly in post content. | ||
| * | ||
| * The suggestion payload is applied verbatim to block attributes when a | ||
| * reviewer accepts it. Without write-time filtering, a low-capability | ||
| * suggester could smuggle markup (script tags, event handlers) that a | ||
| * reviewer with `unfiltered_html` would then persist under their own KSES | ||
| * exemption. To close that hole while keeping parity with regular editing: | ||
| * | ||
| * - Users with `unfiltered_html` store the payload as-is — the same | ||
| * freedom they already have in post content. | ||
| * - Everyone else has `wp_kses_post()` applied to every string leaf of | ||
| * the values that get APPLIED to content on accept/reject: `after`, | ||
| * `afterHTML`, `beforeHTML`, and the serialized block snapshot in `block`. | ||
| * | ||
| * `before` is intentionally NOT filtered: it is only compared against live | ||
| * content for conflict detection, never applied. Filtering it would produce | ||
| * false staleness warnings whenever the real content contains markup that | ||
| * KSES would strip. | ||
| * | ||
| * Note: apply-time sanitization scope is still under discussion; this | ||
| * write-time capability-matched filter is the baseline. | ||
| * | ||
| * @param string $value Raw JSON payload. | ||
| * @return string Sanitized JSON payload, or '' when the payload is invalid. | ||
| */ | ||
| function gutenberg_sanitize_suggestion_payload( $value ) { | ||
| if ( current_user_can( 'unfiltered_html' ) ) { | ||
| return $value; | ||
| } | ||
|
|
||
| $decoded = json_decode( $value, true ); | ||
| // The REST controller rejects invalid-JSON payloads with a 400 before | ||
| // this callback runs; treat any non-REST garbage the same way the size | ||
| // cap does — reject rather than store something the client can't parse. | ||
| if ( ! is_array( $decoded ) ) { | ||
| return ''; | ||
| } | ||
|
|
||
| if ( isset( $decoded['operations'] ) && is_array( $decoded['operations'] ) ) { | ||
| foreach ( $decoded['operations'] as $index => $operation ) { | ||
| if ( ! is_array( $operation ) ) { | ||
| continue; | ||
| } | ||
| // `after` can be structured (a table's `body` rows, a gallery's | ||
| // `images`), so every string leaf is filtered, not only strings. | ||
| foreach ( array( 'after', 'afterHTML', 'beforeHTML' ) as $key ) { | ||
| if ( isset( $operation[ $key ] ) ) { | ||
| $operation[ $key ] = filter_block_kses_value( $operation[ $key ], 'post' ); | ||
| } | ||
| } | ||
| if ( isset( $operation['block'] ) && is_array( $operation['block'] ) ) { | ||
| $operation['block'] = gutenberg_kses_suggestion_block_snapshot( $operation['block'] ); | ||
| } | ||
| $decoded['operations'][ $index ] = $operation; | ||
| } | ||
| } | ||
|
|
||
| $encoded = wp_json_encode( $decoded ); | ||
| return false === $encoded ? '' : $encoded; | ||
| } | ||
|
|
||
| /** | ||
| * Registers the comment meta used by suggested edits. | ||
| * | ||
| * Notes ship in WordPress 6.9 core, which registers the base note meta | ||
| * (`_wp_note_status`). Suggestions are a Gutenberg 7.1 feature layered on top, | ||
| * so the suggestion-specific meta is registered here: | ||
| * | ||
| * - `_wp_suggestion` — proposed edit, JSON payload. Presence of this | ||
| * meta is what makes a note a suggestion. | ||
| * - `_wp_suggestion_status` — suggestion lifecycle (`pending` / `applied` | ||
| * / `rejected`). Set on apply or reject so the | ||
| * comment thread persists as evidence even after | ||
| * the suggestion is resolved. | ||
| * | ||
| * The suggestion is stored as comment meta rather than `comment_content` so a | ||
| * note can carry both a discussion (content) and a proposed edit (meta), and so | ||
| * per-meta `auth_callback`/`sanitize_callback` give strict per-field control | ||
| * independent of comment-text moderation. Size validation is strict: oversized | ||
| * payloads are rejected rather than truncated, since truncating JSON corrupts | ||
| * the payload. | ||
| */ | ||
| function gutenberg_register_suggestion_meta() { | ||
| $max_suggestion_payload_bytes = GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES; | ||
|
|
||
| register_meta( | ||
| 'comment', | ||
| '_wp_suggestion', | ||
| array( | ||
| 'type' => 'string', | ||
| 'description' => __( 'Suggested edit payload (JSON).', 'gutenberg' ), | ||
| 'single' => true, | ||
| 'show_in_rest' => array( | ||
| 'schema' => array( | ||
| 'type' => 'string', | ||
| 'maxLength' => $max_suggestion_payload_bytes, | ||
| ), | ||
| ), | ||
| 'sanitize_callback' => function ( $value ) use ( $max_suggestion_payload_bytes ) { | ||
| if ( ! is_string( $value ) ) { | ||
| return ''; | ||
| } | ||
| // Reject rather than truncate. Truncating mid-string produces | ||
| // invalid JSON; `parseSuggestionPayload` would then return | ||
| // null and the suggestion would silently disappear. | ||
| if ( strlen( $value ) > $max_suggestion_payload_bytes ) { | ||
| return ''; | ||
| } | ||
| // Capability-matched KSES filtering; runs as the writing user | ||
| // (the suggester) on create/update. | ||
| return gutenberg_sanitize_suggestion_payload( $value ); | ||
| }, | ||
| 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { | ||
| // During comment creation the comment does not yet exist, so | ||
| // `object_id` is 0. Defer to the comment controller's own | ||
| // create permission — if the request can create the | ||
| // comment at all, it can set the suggestion meta on it. | ||
| if ( ! $object_id ) { | ||
| return current_user_can( 'edit_posts' ); | ||
| } | ||
| $comment = get_comment( $object_id ); | ||
| if ( $comment && 'note' === $comment->comment_type ) { | ||
| return current_user_can( 'edit_post', $comment->comment_post_ID ); | ||
| } | ||
| return current_user_can( 'edit_comment', $object_id ); | ||
| }, | ||
| ) | ||
| ); | ||
|
|
||
| register_meta( | ||
| 'comment', | ||
| '_wp_suggestion_status', | ||
| array( | ||
| 'type' => 'string', | ||
| 'description' => __( 'Suggestion lifecycle status.', 'gutenberg' ), | ||
| 'single' => true, | ||
| 'show_in_rest' => array( | ||
| 'schema' => array( | ||
| 'type' => 'string', | ||
| 'enum' => array( 'pending', 'applied', 'rejected' ), | ||
| ), | ||
| ), | ||
| 'auth_callback' => function ( $allowed, $meta_key, $object_id ) { | ||
| $comment = get_comment( $object_id ); | ||
| if ( $comment && 'note' === $comment->comment_type ) { | ||
| return current_user_can( 'edit_post', $comment->comment_post_ID ); | ||
| } | ||
| return current_user_can( 'edit_comment', $object_id ); | ||
| }, | ||
| ) | ||
| ); | ||
| } | ||
| add_action( 'init', 'gutenberg_register_suggestion_meta' ); |
153 changes: 153 additions & 0 deletions
153
lib/compat/wordpress-7.1/class-gutenberg-rest-comment-controller-7-1.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,153 @@ | ||
| <?php | ||
| /** | ||
| * REST controller overrides for suggestion edits on `note`-type comments. | ||
| * | ||
| * Block notes (and the base note REST controller) graduated to WordPress 6.9 | ||
| * core. Suggestions - a note that carries a `_wp_suggestion` payload describing | ||
| * a proposed edit - are a Gutenberg 7.1 feature layered on top, so only the | ||
| * suggestion-specific behavior lives here as a thin subclass of the core | ||
| * comments controller. | ||
| * | ||
| * Permissions are core's: `update_item` requires `edit_comment`, which | ||
| * `map_meta_cap()` resolves to `edit_post` on the note's parent post, so a post | ||
| * editor can already apply or reject a suggestion left on their post. This | ||
| * subclass only adds the suggestion-specific storage rules: | ||
| * | ||
| * - `prepare_item_for_database`: enforces server-side payload validation | ||
| * (a `_wp_suggestion` larger than `GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES` | ||
| * is rejected with HTTP 413, and a payload that isn't a valid JSON object | ||
| * is rejected with HTTP 400, before any storage happens) and surfaces the | ||
| * suggestion payload to the content-allowed check below. | ||
| * - `check_is_comment_content_allowed`: a note may have empty | ||
| * `comment_content` when it carries only a proposed edit. | ||
| * | ||
| * @package gutenberg | ||
| * @since 7.1.0 | ||
| */ | ||
|
|
||
| if ( class_exists( 'WP_REST_Comments_Controller' ) && ! class_exists( 'Gutenberg_REST_Comment_Controller_7_1' ) ) { | ||
| /** | ||
| * Core class to manage suggestion edits on note comments via the REST API. | ||
| */ | ||
| class Gutenberg_REST_Comment_Controller_7_1 extends WP_REST_Comments_Controller { | ||
|
|
||
| /** | ||
| * Validates an incoming request's `_wp_suggestion` meta before any | ||
| * storage happens: | ||
| * | ||
| * - The payload must be within the allowed byte budget. Truncating | ||
| * arbitrary JSON corrupts the payload, so we reject with a 413. | ||
| * - The payload must decode to a JSON object. Storing garbage would | ||
| * make `parseSuggestionPayload` return null on the client and the | ||
| * suggestion would silently disappear, so we reject with a 400. | ||
| * | ||
| * @param WP_REST_Request $request Full details about the request. | ||
| * @return true|WP_Error True if no payload or valid, WP_Error otherwise. | ||
| */ | ||
| protected static function validate_suggestion_payload( $request ) { | ||
| $meta = $request['meta'] ?? null; | ||
| if ( ! is_array( $meta ) || ! isset( $meta['_wp_suggestion'] ) ) { | ||
| return true; | ||
| } | ||
| $value = $meta['_wp_suggestion']; | ||
| if ( ! is_string( $value ) ) { | ||
| return true; | ||
| } | ||
| if ( strlen( $value ) > GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES ) { | ||
| return new WP_Error( | ||
| 'rest_suggestion_too_large', | ||
| sprintf( | ||
| /* translators: %d: maximum allowed byte length. */ | ||
| __( 'Suggestion payload exceeds the %d-byte limit.', 'gutenberg' ), | ||
| GUTENBERG_SUGGESTION_PAYLOAD_MAX_BYTES | ||
| ), | ||
| array( 'status' => 413 ) | ||
| ); | ||
| } | ||
| // An empty string is the documented "no suggestion" value; anything | ||
| // else must decode to a JSON object carrying the payload fields. | ||
| if ( '' !== $value ) { | ||
| $decoded = json_decode( $value, true ); | ||
| if ( ! is_array( $decoded ) ) { | ||
| return new WP_Error( | ||
| 'rest_suggestion_invalid_json', | ||
| __( 'Suggestion payload must be a valid JSON object.', 'gutenberg' ), | ||
| array( 'status' => 400 ) | ||
| ); | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Prepares a single comment for create or update. | ||
| * | ||
| * Wraps core's preparation with two suggestion-specific concerns: | ||
| * | ||
| * - Rejects oversized `_wp_suggestion` payloads with a clean 413, and | ||
| * payloads that aren't valid JSON objects with a 400, before any | ||
| * storage happens (both create and update call this and return | ||
| * its WP_Error). | ||
| * - Surfaces the `_wp_suggestion` payload in the prepared `meta` so the | ||
| * content-allowed check can recognize a payload-only note, mirroring | ||
| * how core copies `_wp_note_status` for the same check. | ||
| * | ||
| * @param WP_REST_Request $request Request object. | ||
| * @return array|WP_Error Prepared comment, or WP_Error. | ||
| */ | ||
| protected function prepare_item_for_database( $request ) { | ||
| $payload_check = self::validate_suggestion_payload( $request ); | ||
| if ( is_wp_error( $payload_check ) ) { | ||
| return $payload_check; | ||
| } | ||
|
|
||
| $prepared_comment = parent::prepare_item_for_database( $request ); | ||
| if ( is_wp_error( $prepared_comment ) ) { | ||
| return $prepared_comment; | ||
| } | ||
|
|
||
| if ( isset( $request['meta']['_wp_suggestion'] ) ) { | ||
| if ( ! isset( $prepared_comment['meta'] ) || ! is_array( $prepared_comment['meta'] ) ) { | ||
| $prepared_comment['meta'] = array(); | ||
| } | ||
| $prepared_comment['meta']['_wp_suggestion'] = $request['meta']['_wp_suggestion']; | ||
| } | ||
|
|
||
| return $prepared_comment; | ||
| } | ||
|
|
||
| /** | ||
| * Allows a note comment to have empty content when it carries a | ||
| * suggestion payload. | ||
| * | ||
| * A pure suggestion (a proposed edit with no discussion text) has empty | ||
| * `comment_content`; core would otherwise reject it. Everything else | ||
| * defers to core's check. | ||
| * | ||
| * @param array $prepared_comment Prepared comment data. | ||
| * @return bool | ||
| */ | ||
| protected function check_is_comment_content_allowed( $prepared_comment ) { | ||
| if ( | ||
| isset( $prepared_comment['comment_type'] ) && | ||
| 'note' === $prepared_comment['comment_type'] && | ||
| ! empty( $prepared_comment['meta']['_wp_suggestion'] ) | ||
| ) { | ||
| return true; | ||
| } | ||
|
|
||
| return parent::check_is_comment_content_allowed( $prepared_comment ); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| add_action( | ||
| 'rest_api_init', | ||
| function () { | ||
| // Core registers its routes on `rest_api_init` at priority 99, so this | ||
| // subclass registers first and its handlers are matched before core's. | ||
| $controller = new Gutenberg_REST_Comment_Controller_7_1(); | ||
| $controller->register_routes(); | ||
| }, | ||
| 11 | ||
| ); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.