diff --git a/lib/class-wp-annotation-utils.php b/lib/class-wp-annotation-utils.php new file mode 100644 index 00000000000000..06d0172eca9785 --- /dev/null +++ b/lib/class-wp-annotation-utils.php @@ -0,0 +1,1398 @@ +name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If creating a front-end annotation in a post having a public or private status, and the + * user can read & comment on the post, then they can create front-end annotations. Note: + * Callers should also check if {@see post_password_required()} before allowing access. + */ + if ( 'annotation' === $comment_type // Front-end. + && ( $post_status->public || $post_status->private ) + && post_type_supports( $post_type->name, 'comments' ) && comments_open( $post ) + && ( $user_id || ! get_option( 'comment_registration' ) ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->read_post, $user_id, $post->ID ) ); + + if ( $post_status->public && ! get_option( 'comment_registration' ) && array( 'read' ) === $caps ) { + // If post is public, comment registration is off, and post only requires 'read' access. + $caps = array(); // Allow anonymous public access. + } + return $caps; + } + + /* + * If creating a back-end annotation in a post authored by this user, and the user can edit + * a post of type, then they can create back-end annotations. For example, a contributor + * can create back-end annotations in any post they authored, even if they can no longer + * *edit* the post itself; e.g., after it's approved/published. + */ + if ( 'annotation' !== $comment_type && $user_id && (int) $post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_posts, $user_id ) ); + } + + /* + * Otherwise, requires ability to edit post containing the annotation. + */ + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_post, $user_id, $post->ID ) ); + + /* + * Requires $args[0] with the annotation's comment ID. + */ + case 'read_annotation': // An annotation's 'read_post' meta-cap. + $caps = array_diff( $caps, array( $cap ) ); + + if ( ! isset( $args[0] ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $user_id = absint( $user_id ); + $comment_id = absint( $args[0] ); + $comment_info = self::get_comment_info( $comment_id ); + + if ( ! $comment_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $comment = $comment_info['comment']; + $comment_type = $comment_info['comment_type']; + $comment_status = $comment_info['comment_status']; + + $post = $comment_info['post']; + $post_type = $comment_info['post_type']; + $post_status = $comment_info['post_status']; + + /* + * Check if annotation comment type is valid. + */ + if ( ! self::is_valid_type( $comment_type ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Cannot read annotation if post is in the trash. + */ + if ( 'trash' === $post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If it's an unapproved front-end annotation in a public or private post, and the user can + * read the post and moderate comments, they can read the annotation. Note: Callers should + * also check if {@see post_password_required()}. + */ + if ( 'annotation' === $comment_type && 'approved' !== $comment_status + && ( $post_status->public || $post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->read_post, $user_id, $post->ID ) ); + $caps[] = 'moderate_comments'; + + return $caps; + } + + /* + * If it's an approved front-end annotation in a public or private post, and the user can + * read the post, then they can read the approved front-end annotation. Note: Callers + * should also check if {@see post_password_required()} before allowing access. + */ + if ( 'annotation' === $comment_type && 'approved' === $comment_status + && ( $post_status->public || $post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->read_post, $user_id, $post->ID ) ); + + if ( $post_status->public && array( 'read' ) === $caps ) { + // If both are public and the post only requires 'read' access. + $caps = array(); // Allow anonymous public access. + } + return $caps; + } + + /* + * Add required caps based on the comment's status. + */ + $actual_comment_status = $comment_status; // Before meta status. + + if ( in_array( $comment_status, array( 'spam', 'trash' ), true ) ) { + // When in spam or trash, test the would-be restoration status. + $wp_trash_meta_status = get_comment_meta( $comment->comment_ID, '_wp_trash_meta_status', true ); + $wp_trash_meta_status = $wp_trash_meta_status ? $wp_trash_meta_status : '0'; + $comment_status = self::translate_comment_status( $wp_trash_meta_status ); + } + if ( in_array( $actual_comment_status, array( 'unapproved', 'spam' ), true ) + || in_array( $comment_status, array( 'unapproved', 'spam' ), true ) ) { + // Requires an adminstrator who can edit others posts, of the specific post type. + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->edit_others_posts, $user_id, $post->ID ) ); + } + + /* + * If it's a back-end annotation in a post authored by this user, and the user can edit a + * post of type, then they can read back-end annotations. For example, a contributor can + * read back-end annotations in any post they authored, even if they can no longer *edit* + * the post itself; e.g., after it's approved/published. + */ + if ( 'annotation' !== $comment_type && $user_id && (int) $post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_posts, $user_id ) ); + } + + /* + * Otherwise, requires ability to edit post containing the annotation. + */ + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_post, $user_id, $post->ID ) ); + + /* + * Optionally supports $args[0], $args[1] with post ID and comment type. + */ + case 'read_annotations': // An annotation's 'read' pseudo-cap. + $caps = array_diff( $caps, array( $cap ) ); + + if ( ! isset( $args[0], $args[1] ) ) { + $caps[] = 'edit_posts'; + return $caps; + } + $user_id = absint( $user_id ); + $post_id = absint( $args[0] ); + + $comment_type = (string) $args[1]; + $post_info = self::get_post_info( $post_id ); + + if ( ! $comment_type || ! $post_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $post = $post_info['post']; + $post_type = $post_info['post_type']; + $post_status = $post_info['post_status']; + + /* + * Check if annotation comment type is valid. + */ + if ( ! self::is_valid_type( $comment_type ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Cannot read annotations if post is in the trash. + */ + if ( 'trash' === $post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If reading front-end annotations in a public or private post, and the user can read the + * post, then they can read annotations. Note: Callers should also check if {@see + * post_password_required()}. + */ + if ( 'annotation' === $comment_type // Front-end. + && ( $post_status->public || $post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->read_post, $user_id, $post->ID ) ); + + if ( $post_status->public && array( 'read' ) === $caps ) { + // If post is public and only requires 'read' access. + $caps = array(); // Allow anonymous public access. + } + return $caps; + } + + /* + * If reading back-end annotations in a post authored by this user, and the user can edit a + * post of type, then they can read back-end annotations. For example, a contributor can + * read back-end annotations in any post they authored, even if they can no longer *edit* + * the post itself; e.g., after it's approved/published. + */ + if ( 'annotation' !== $comment_type && $user_id && (int) $post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_posts, $user_id ) ); + } + + /* + * Otherwise, requires ability to edit post containing the annotations. + */ + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_post, $user_id, $post->ID ) ); + + /* + * Requires $args[0] with the annotation's post ID. + */ + case 'edit_annotation': // An annotation's 'edit_post' meta-cap. + $caps = array_diff( $caps, array( $cap ) ); + + if ( ! isset( $args[0] ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $user_id = absint( $user_id ); + $comment_id = absint( $args[0] ); + $comment_info = self::get_comment_info( $comment_id ); + + if ( ! $comment_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $comment = $comment_info['comment']; + $comment_type = $comment_info['comment_type']; + $comment_status = $comment_info['comment_status']; + + $post = $comment_info['post']; + $post_type = $comment_info['post_type']; + $post_status = $comment_info['post_status']; + + /* + * Check if annotation comment type is valid. + */ + if ( ! self::is_valid_type( $comment_type ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Cannot edit annotation if post is in the trash. + */ + if ( 'trash' === $post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If it's a front-end annotation (with any status) in a public or private post, and the + * user can read the post and moderate comments, they can edit. Note: Callers should also + * check if {@see post_password_required()}. + */ + if ( 'annotation' === $comment_type // Front-end. + && ( $post_status->public || $post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->read_post, $user_id, $post->ID ) ); + + /* + * If the front-end annotation was authored by the specific user, and the user can edit the + * post, then they can edit the annotation without the `moderate_comments` cap. + */ + if ( $user_id && (int) $comment->user_id === $user_id + && user_can( $user_id, $post_type->cap->edit_post, $post->ID ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->edit_post, $user_id, $post->ID ) ); + } else { + $caps[] = 'moderate_comments'; + } + + return $caps; + } + + /* + * Add required caps based on the comment's status. + */ + $actual_comment_status = $comment_status; // Before meta status. + + if ( in_array( $comment_status, array( 'spam', 'trash' ), true ) ) { + // When in spam or trash, test the would-be restoration status. + $wp_trash_meta_status = get_comment_meta( $comment->comment_ID, '_wp_trash_meta_status', true ); + $wp_trash_meta_status = $wp_trash_meta_status ? $wp_trash_meta_status : '0'; + $comment_status = self::translate_comment_status( $wp_trash_meta_status ); + } + if ( in_array( $actual_comment_status, array( 'unapproved', 'spam' ), true ) + || in_array( $comment_status, array( 'unapproved', 'spam' ), true ) ) { + // Requires an adminstrator who can edit others posts, of the specific post type. + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->edit_others_posts, $user_id, $post->ID ) ); + } + + /* + * If it's an annotation authored by this user. + */ + if ( $user_id && (int) $comment->user_id === $user_id ) { + /* + * If it's a back-end annotation, and it's also in a post authored by this user, and the user + * can edit a post of type, then they can edit their own back-end annotation. For example, a + * contributor can edit their own back-end annotation in any post they authored, even if they + * can no longer *edit* the post itself; e.g., after it's approved/published. + */ + if ( 'annotation' !== $comment_type && (int) $post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_posts, $user_id ) ); + } + + /* + * Otherwise, requires ability to edit post containing the annotation. + */ + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_post, $user_id, $post->ID ) ); + } + + /* + * Otherwise, requires ability to edit post containing the annotation. Also requires an + * adminstrator with the ability to edit others posts, of the specific post type. + */ + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->edit_post, $user_id, $post->ID ) ); + return array_merge( $caps, map_meta_cap( $post_type->cap->edit_others_posts, $user_id ) ); + + /* + * Requires $args[0] with the annotation's post ID. + */ + case 'delete_annotation': // An annotation's 'delete_post' meta-cap. + $caps = array_diff( $caps, array( $cap ) ); + + if ( ! isset( $args[0] ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $user_id = absint( $user_id ); + $comment_id = absint( $args[0] ); + $comment_info = self::get_comment_info( $comment_id ); + + if ( ! $comment_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $comment = $comment_info['comment']; + $comment_type = $comment_info['comment_type']; + $comment_status = $comment_info['comment_status']; + + $post = $comment_info['post']; + $post_type = $comment_info['post_type']; + $post_status = $comment_info['post_status']; + + /* + * Check if annotation comment type is valid. + */ + if ( ! self::is_valid_type( $comment_type ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Cannot delete annotation if post is in the trash. + */ + if ( 'trash' === $post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If it's a front-end annotation (with any status) in a public or private post, and the + * user can read the post and moderate comments, they can delete. Note: Callers should also + * check if {@see post_password_required()}. + */ + if ( 'annotation' === $comment_type // Front-end. + && ( $post_status->public || $post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->read_post, $user_id, $post->ID ) ); + + /* + * If the front-end annotation was authored by the specific user, and the user can delete the + * post, then they can delete the annotation without the `moderate_comments` cap. + */ + if ( $user_id && (int) $comment->user_id === $user_id + && user_can( $user_id, $post_type->cap->delete_post, $post->ID ) ) { + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->delete_post, $user_id, $post->ID ) ); + } else { + $caps[] = 'moderate_comments'; + } + + return $caps; + } + + /* + * Add required caps based on the comment's status. + */ + $actual_comment_status = $comment_status; // Before meta status. + + if ( in_array( $comment_status, array( 'spam', 'trash' ), true ) ) { + // When in spam or trash, test the would-be restoration status. + $wp_trash_meta_status = get_comment_meta( $comment->comment_ID, '_wp_trash_meta_status', true ); + $wp_trash_meta_status = $wp_trash_meta_status ? $wp_trash_meta_status : '0'; + $comment_status = self::translate_comment_status( $wp_trash_meta_status ); + } + if ( in_array( $actual_comment_status, array( 'unapproved', 'spam' ), true ) + || in_array( $comment_status, array( 'unapproved', 'spam' ), true ) ) { + // Requires an adminstrator who can delete others posts, of the specific post type. + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->delete_others_posts, $user_id, $post->ID ) ); + } + + /* + * If it's an annotation authored by this user. + */ + if ( $user_id && (int) $comment->user_id === $user_id ) { + /* + * If it's a back-end annotation, and it's also in a post authored by this user, and the user + * can delete a post of type, then they can delete their own back-end annotation. For + * example, a contributor can delete their own back-end annotation in any post they authored, + * even if they can no longer *delete* the post itself; e.g., after it's approved/published. + */ + if ( 'annotation' !== $comment_type && (int) $post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $post_type->cap->delete_posts, $user_id ) ); + } + + /* + * Otherwise, requires ability to delete post containing the annotation. + */ + return array_merge( $caps, map_meta_cap( $post_type->cap->delete_post, $user_id, $post->ID ) ); + } + + /* + * Otherwise, requires ability to delete post containing the annotation. Also requires an + * adminstrator with the ability to delete others posts, of the specific post type. + */ + $caps = array_merge( $caps, map_meta_cap( $post_type->cap->delete_post, $user_id, $post->ID ) ); + return array_merge( $caps, map_meta_cap( $post_type->cap->delete_others_posts, $user_id ) ); + + /* + * All other pseudo-caps are handled dynamically. These are simply mapped to the + * equivalent *_posts cap. Optionally supports $args[0] with an annotation comment type. + */ + case 'create_annotations': + case 'delete_annotations': + case 'delete_others_annotations': + case 'delete_private_annotations': + case 'delete_published_annotations': + case 'edit_annotations': + case 'edit_others_annotations': + case 'edit_private_annotations': + case 'edit_published_annotations': + case 'publish_annotations': + case 'read_private_annotations': + $caps = array_diff( $caps, array( $cap ) ); + + if ( isset( $args[0] ) ) { + $comment_type = (string) $args[0]; + + /* + * Check if annotation comment type is valid. + */ + if ( ! self::is_valid_type( $comment_type ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If checking front-end annotations, and the user can moderate comments, they can do + * anything with front-end annotations; e.g., create, read, edit, delete. + */ + if ( 'annotation' === $comment_type ) { + $caps[] = 'moderate_comments'; + return $caps; + } + } + + /* + * Otherwise, simply map to the equivalent *_posts capability. + */ + if ( 'create_annotations' === $cap ) { + $caps[] = 'edit_posts'; + } else { + $caps[] = str_replace( 'annotations', 'posts', $cap ); + } + + return $caps; + } + + return $caps; + } + + /** + * Gets an array of comment (i.e., annotation) info. + * + * @since [version] + * + * @param WP_Comment|int $comment Comment (i.e., annotation) object or ID. + * + * @return array Annotation info, including parent post info. + * Returns an empty array on failure. + */ + public static function get_comment_info( $comment ) { + /* + * Collect comment info. + */ + + if ( ! $comment ) { + return array(); + } + + $comment = get_comment( $comment ); + + if ( ! $comment ) { + return array(); + } + + if ( ! in_array( $comment->comment_type, self::$types, true ) ) { + return array(); // Not an annotation. + } + + $comment_type = $comment->comment_type; + $comment_status = self::get_comment_status( $comment ); + + if ( ! $comment_type || ! $comment_status ) { + return array(); + } + + /* + * Collect parent post info. + */ + + if ( ! $comment->comment_post_ID ) { + return array(); + } + + $post_info = self::get_post_info( $comment->comment_post_ID ); + + if ( ! $post_info ) { + return array(); + } + + $post = $post_info['post']; + $post_type = $post_info['post_type']; + $post_status = $post_info['post_status']; + + /* + * Return all info. + */ + + return compact( + 'comment', + 'comment_type', + 'comment_status', + // --- + 'post', + 'post_type', + 'post_status' + ); + } + + /** + * Gets an annotation's parent post info. + * + * @since [version] + * + * @param WP_Post|int $post Post object or ID. + * + * @return array Array of post info. Empty array on failure. + */ + public static function get_post_info( $post ) { + if ( ! $post ) { + return array(); + } + + $post = get_post( $post ); + + if ( ! $post ) { + return array(); + } + + if ( 'revision' === $post->post_type ) { + return array(); // Must not be a revision. + } + + $post_type = get_post_type_object( $post->post_type ); + $post_status = get_post_status_object( get_post_status( $post ) ); + + if ( ! $post_type || ! $post_status ) { + return array(); + } + + return compact( + 'post', + 'post_type', + 'post_status' + ); + } + + /** + * Gets comment status (supports custom statuses). + * + * Unlike {@see wp_get_comment_status()}, this supports custom statuses. Otherwise, + * it closely resembles {@see wp_get_comment_status()}. + * + * @since [version] + * + * @param WP_Comment|int $comment Comment (i.e., annotation) object or ID. + * + * @return string|bool 'approved', 'unapproved', 'spam', 'trash', or a + * custom status. Returns false on failure. + * + * @see wp_get_comment_status() + */ + public static function get_comment_status( $comment ) { + if ( ! $comment ) { + return false; + } + + $comment = get_comment( $comment ); + + if ( ! $comment ) { + return false; + } + + $status = $comment->comment_approved; + $status = self::translate_comment_status( $status ); + + return $status ? $status : false; + } + + /** + * Translates/normalizes an annotation's comment status. + * + * Matches normalized statuses returned by {@see wp_get_comment_status()}. + * + * @since [version] + * + * @param string $status Comment status. + * + * @return string 'approved', 'unapproved', 'spam', 'trash', or a custom + * status. Returns an empty string on failure. + * + * @see wp_get_comment_status() + */ + public static function translate_comment_status( $status ) { + $status = (string) $status; + + switch ( $status ) { + case 'approved': + case 'approve': + case '1': + return 'approved'; + + case 'hold': + case '0': + return 'unapproved'; + } + + return $status; + } + + /** + * Sets comment status (supports custom statuses). + * + * Unlike {@see wp_set_comment_status()}, this supports custom statuses. Otherwise, + * it closely resembles {@see wp_set_comment_status()}. + * + * @since [version] + * + * @param WP_Comment|int $comment Comment (i.e., annotation) object or ID. + * @param string $new_status New comment status. Supports custom statuses. + * @param bool $error Whether to return a {@see WP_Error} on failure. + * Default is false. + * + * @return bool|WP_Error True on success. False (or a {@see WP_Error}) + * on failure. + * + * @see wp_set_comment_status() + */ + public static function set_comment_status( $comment, $new_status, $error = false ) { + global $wpdb; + + if ( ! $comment ) { + if ( $error ) { + return new WP_Error( 'update_error', __( 'Could not update comment status.', 'gutenberg' ) ); + } + return false; + } + + $old_comment = get_comment( $comment ); + $old_comment = $old_comment ? clone $old_comment : null; + + if ( ! $old_comment ) { + if ( $error ) { + return new WP_Error( 'update_error', __( 'Could not update comment status.', 'gutenberg' ) ); + } + return false; + } + + $new_status = (string) $new_status; + $new_raw_status = $new_status; // Before normalizing. + $old_raw_status = $old_comment->comment_approved; + + switch ( $new_status ) { + case 'approved': + case 'approve': + case '1': + $new_status = '1'; + add_action( 'wp_set_comment_status', 'wp_new_comment_notify_postauthor' ); + break; + + case 'hold': + case '0': + $new_status = '0'; + break; + } + + if ( ! $wpdb->update( $wpdb->comments, array( 'comment_approved' => $new_status ), array( 'comment_ID' => $old_comment->comment_ID ) ) ) { + if ( $error ) { + return new WP_Error( 'db_update_error', __( 'Could not update comment status.', 'gutenberg' ), $wpdb->last_error ); + } + return false; + } + + clean_comment_cache( $old_comment->comment_ID ); + $new_comment = get_comment( $old_comment->comment_ID ); + + /** This filter is documented in wp-includes/comment.php */ + do_action( 'wp_set_comment_status', $new_comment->comment_ID, $new_raw_status ); + + wp_transition_comment_status( $new_raw_status, $old_raw_status, $new_comment ); + wp_update_comment_count( $new_comment->comment_post_ID ); + + return true; + } + + /** + * Removes a comment from spam (supports custom restoration statuses). + * + * Unlike {@see wp_unspam_comment()}, this supports custom restoration statuses. + * Otherwise, it closely resembles {@see wp_unspam_comment()}. + * + * @since [version] + * + * @param WP_Comment|int $comment Comment (i.e., annotation) object or ID. + * + * @return bool True on success. False on failure. + * + * @see wp_unspam_comment() + */ + public static function unspam_comment( $comment ) { + if ( ! $comment ) { + return false; + } + + $comment = get_comment( $comment ); + + if ( ! $comment ) { + return false; + } + + /** This action is documented in wp-includes/comment.php */ + do_action( 'unspam_comment', $comment->comment_ID, $comment ); + + $status = (string) get_comment_meta( $comment->comment_ID, '_wp_trash_meta_status', true ); + $status = $status ? $status : '0'; + + if ( self::set_comment_status( $comment, $status ) ) { + delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_status' ); + delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_time' ); + + /** This action is documented in wp-includes/comment.php */ + do_action( 'unspammed_comment', $comment->comment_ID, $comment ); + + return true; + } + + return false; + } + + /** + * Removes a comment from trash (supports custom restoration statuses). + * + * Unlike {@see wp_untrash_comment()}, this supports custom restoration statuses. + * Otherwise, it closely resembles {@see wp_untrash_comment()}. + * + * @since [version] + * + * @param WP_Comment|int $comment Comment (i.e., annotation) object or ID. + * + * @return bool True on success. False on failure. + * + * @see wp_untrash_comment() + */ + public static function untrash_comment( $comment ) { + if ( ! $comment ) { + return false; + } + + $comment = get_comment( $comment ); + + if ( ! $comment ) { + return false; + } + + /** This action is documented in wp-includes/comment.php */ + do_action( 'untrash_comment', $comment->comment_ID, $comment ); + + $status = (string) get_comment_meta( $comment->comment_ID, '_wp_trash_meta_status', true ); + $status = $status ? $status : '0'; + + if ( self::set_comment_status( $comment, $status ) ) { + delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_time' ); + delete_comment_meta( $comment->comment_ID, '_wp_trash_meta_status' ); + + /** This action is documented in wp-includes/comment.php */ + do_action( 'untrashed_comment', $comment->comment_ID, $comment ); + + return true; + } + + return false; + } + + /** + * Queries additional REST API collection parameters. + * + * @since [version] + * + * @param array $query_vars {@see WP_Query} vars. + * @param WP_REST_Request $request REST API request. + * + * @return array Filtered query args. + * + * @see WP_Annotation_Utils::on_comments_clauses() + * @see WP_REST_Comments_Controller::get_items() + */ + public static function on_rest_comment_query( $query_vars, $request ) { + /* + * Only filter REST API endpoint for annotations. + */ + if ( ! preg_match( '/\/annotations$/', $request->get_route() ) ) { + return $query_vars; + } + + /* + * Support hierarchical queries. + */ + if ( isset( $request['hierarchical'] ) ) { + $query_vars['hierarchical'] = $request['hierarchical']; + } + + /* + * This query var serves two purposes: + * + * 1. It's a flag that we read when filtering comments_clauses, which allows + * annotations to be returned by WP_Comment_Query, or not. + * + * 2. It defines a separate cache_domain for annotation comment queries, because the + * cache is impacted by this flag, given our comments_clauses filter. + * + * Thus, when cache_domain is 'annotations', annotations can be returned by + * WP_Comment_Query. If cache_domain is not 'annotations', annotation comment types + * cannot be returned. + */ + $query_vars['cache_domain'] = 'annotations'; + + /* + * Build meta queries. + */ + $meta_queries = array(); + + $vias = $request['via']; + $vias = $vias ? (array) $vias : array(); + $vias = array_map( 'strval', $vias ); + + if ( $vias ) { + $meta_queries[] = array( + 'key' => '_via', + 'value' => $vias, + 'compare' => 'IN', + ); + } + + /* + * Preserve an existing meta query. + */ + if ( $meta_queries ) { + if ( ! empty( $query_vars['meta_query'] ) ) { + $query_vars['meta_query'] = array( + 'relation' => 'AND', + $query_vars['meta_query'], + array( + 'relation' => 'AND', + $meta_queries, + ), + ); + } else { + $query_vars['meta_query'] = array( + 'relation' => 'AND', + $meta_queries, + ); + } + } + + return $query_vars; + } + + /** + * Filters WP_Comment_Query clauses. + * + * @since [version] + * + * @param array $pieces Array of comment query clauses. + * @param WP_Comment_Query $query Current WP_Comment_Query instance. + * + * @return array Array of comment query clauses. + * + * @see WP_Annotation_Utils::on_rest_comment_query() + * @see WP_Comment_Query::get_comment_ids() + */ + public static function on_comments_clauses( $pieces, $query ) { + global $wpdb; + + /* + * When cache_domain is 'annotations', annotations only then can be returned by + * WP_Comment_Query. Otherwise, if cache_domain is not 'annotations', annotation + * comment types cannot be returned whatsoever. + * + * The point being that we want to keep annotations out of any normal comment query + * performed by core, and also keep them away from comment-related plugins; i.e., + * annotations will be unexpected by most plugins. If a plugin *does* want to query + * annotations, they should set cache_domain to 'annotations'. + */ + if ( 'annotations' !== $query->query_vars['cache_domain'] ) { + $annotation_types = self::$types; + + foreach ( $annotation_types as &$_type ) { + $_type = $wpdb->prepare( '%s', $_type ); + } + $pieces['where'] .= $pieces['where'] ? ' AND ' : ''; + $pieces['where'] .= 'comment_type NOT IN (' . implode( ', ', $annotation_types ) . ')'; + } + + /* + * This works arounds a bug in WP_Comment_Query. + * + * If 'hierarchical' is not empty, WP_Comment_Query forces 'parent' to '0', even if + * 'parent__in' is already defined. That's a problem that we must correct here, because + * the REST API uses 'parent__in', not 'parent'. See: . + */ + if ( 'annotations' === $query->query_vars['cache_domain'] && $query->query_vars['hierarchical'] ) { + // If performing a hierarchical comment query, WP_Comment_Query will set the 'parent' query var + // to a value of 0 by default; i.e., if it wasn't defined already. That conflicts with 'parent__in'. + if ( $query->query_vars['parent__in'] && ! $query->query_vars['parent'] ) { + + // So 'parent__in' is not empty, and 'parent' is empty (e.g., 0 or otherwise). + // Now, if the clause itself contains 'comment_parent IN' as a result of 'parent__in'. + if ( preg_match( '/\bcomment_parent\s+IN\b/i', $pieces['where'] ) ) { + + // Then remove the conflicting 'comment_parent =' SQL that is brought about by 'parent'. + // In other words, we want to do away with 'parent' and let 'parent__in' work as expected. + $pieces['where'] = preg_replace( '/\s+AND\s+comment_parent\s*\=\s*[0-9]+/', '', $pieces['where'] ); + } + } + } + + return $pieces; + } + + /** + * Checks an annotation comment type. + * + * @since [version] + * + * @param string $type Comment type. + * + * @return bool True if comment is valid. + */ + public static function is_valid_type( $type ) { + /** + * Filters comment types allowed for annotations. + * + * @since [version] + * + * @param array Comment types allowed for annotations. + */ + $allow_types = apply_filters( 'annotation_allow_types', self::$allow_types ); + + return is_string( $type ) && in_array( $type, $allow_types, true ); + } + + /** + * Checks an annotation comment status. + * + * @since [version] + * + * @param string $status Comment status. + * @param bool $allow_actions Allow actions? Default false. + * + * @return bool True if status is valid. + */ + public static function is_valid_status( $status, $allow_actions = false ) { + $allow_statuses = array( 'approved', 'approve', '1', 'hold', '0', 'spam', 'trash' ); + $allow_statuses = array_merge( $allow_statuses, self::$custom_statuses ); + + if ( $allow_actions ) { + $allow_statuses = array_merge( $allow_statuses, array( 'unspam', 'untrash' ) ); + } + return is_string( $status ) && in_array( $status, $allow_statuses, true ); + } + + /** + * Checks an annotation comment status (or status action). + * + * @since [version] + * + * @param string $status Comment status. + * + * @return bool True if status is valid. + */ + public static function is_valid_status_or_action( $status ) { + return self::is_valid_status( $status, true ); + } + + /** + * Validates a W3C annotation client identifier. + * + * @since [version] + * + * @param string $client The annotation client to check. + * + * @return bool True if client is valid. + * + * @link https://www.w3.org/TR/annotation-model/#rendering-software + */ + public static function is_valid_client( $client ) { + if ( '' === $client ) { + return true; // Empty is OK. + } + + if ( ! is_string( $client ) ) { + return false; + } + $raw_client = $client; + $client = preg_replace( '/[^a-z0-9:_\-]/i', '', $client ); + $client = substr( trim( $client, ':_-' ), 0, 250 ); + + if ( ! $client || $client !== $raw_client ) { + return false; + } + + return true; + } + + /** + * Validates a W3C annotation selector deeply. + * + * @since [version] + * + * @param array $selector Selector to check. + * @param bool $recursive For internal use only. + * + * @return bool True if selector is valid. + * + * @link https://www.w3.org/TR/annotation-model/#selectors + */ + public static function is_valid_selector( $selector, $recursive = false ) { + if ( ! $recursive && array() === $selector ) { + return true; // Empty is OK. + } + + if ( ! $selector || ! is_array( $selector ) ) { + return false; + } elseif ( empty( $selector['type'] ) || ! is_string( $selector['type'] ) ) { + return false; + } elseif ( 2 < count( array_keys( $selector ) ) ) { + return false; + } + + /** + * Filters selector types allowed for annotations. + * + * @since [version] + * + * @param array Selector types allowed for annotations. + */ + $allow_selectors = apply_filters( 'annotation_allow_selectors', self::$allow_selectors ); + if ( ! in_array( $selector['type'], $allow_selectors, true ) ) { + return false; + } + + if ( 'RangeSelector' !== $selector['type'] ) { + if ( 'SvgSelector' === $selector['type'] ) { + $max_selector_size = 131072; // 128kb. + } else { + $max_selector_size = 16384; // 16kb. + } + + /** + * Filters max annotation selector size (in bytes). + * + * @since [version] + * + * @param int Max annotation selector size (in bytes). + * @param array An array of all selector details. + */ + $max_selector_size = apply_filters( 'annotation_max_selector_size', $max_selector_size, $selector ); + + $selector_minus_refinements = $selector; + unset( $selector_minus_refinements['refinedBy'] ); + $selector_size = strlen( json_encode( $selector_minus_refinements ) ); + + if ( $selector_size > $max_selector_size ) { + return false; + } + } + + switch ( $selector['type'] ) { + case 'FragmentSelector': + $allow_keys = array( + 'type', + 'value', + 'conformsTo', + 'refinedBy', + ); + if ( array_diff_key( $selector, array_fill_keys( $allow_keys, 0 ) ) ) { + return false; + } elseif ( empty( $selector['value'] ) || ! is_string( $selector['value'] ) ) { + return false; + } elseif ( isset( $selector['conformsTo'] ) && ! wp_parse_url( $selector['conformsTo'] ) ) { + return false; + } elseif ( isset( $selector['refinedBy'] ) && ! self::is_valid_selector( $selector['refinedBy'], true ) ) { + return false; + } + return true; + + case 'CssSelector': + case 'XPathSelector': + $allow_keys = array( + 'type', + 'value', + 'refinedBy', + ); + if ( array_diff_key( $selector, array_fill_keys( $allow_keys, 0 ) ) ) { + return false; + } elseif ( empty( $selector['value'] ) || ! is_string( $selector['value'] ) ) { + return false; + } elseif ( isset( $selector['refinedBy'] ) && ! self::is_valid_selector( $selector['refinedBy'], true ) ) { + return false; + } + return true; + + case 'TextQuoteSelector': + $allow_keys = array( + 'type', + 'exact', + 'prefix', + 'suffix', + ); + if ( array_diff_key( $selector, array_fill_keys( $allow_keys, 0 ) ) ) { + return false; + } elseif ( ! isset( $selector['exact'] ) || ! is_string( $selector['exact'] ) ) { + return false; + } elseif ( isset( $selector['prefix'] ) && ! is_string( $selector['prefix'] ) ) { + return false; + } elseif ( isset( $selector['suffix'] ) && ! is_string( $selector['suffix'] ) ) { + return false; + } elseif ( '' === $selector['exact'] ) { + return false; + } + return true; + + case 'TextPositionSelector': + case 'DataPositionSelector': + $allow_keys = array( + 'type', + 'start', + 'end', + ); + if ( array_diff_key( $selector, array_fill_keys( $allow_keys, 0 ) ) ) { + return false; + } elseif ( ! isset( $selector['start'] ) || ! is_int( $selector['start'] ) || 0 > $selector['start'] ) { + return false; + } elseif ( ! isset( $selector['end'] ) || ! is_int( $selector['end'] ) || 0 > $selector['end'] ) { + return false; + } + return true; + + case 'SvgSelector': + /* + * @TODO SVG selectors are disabled for the time being. See {@see + * WP_Annotation_Utils::$allow_selectors} for further details. + * + * Please DO NOT ENABLE until a better security scan can be performed here. + */ + $allow_keys = array( + 'type', + 'id', // URL leading to an SVG file. + 'value', // Inline SVG markup. + ); + if ( array_diff_key( $selector, array_fill_keys( $allow_keys, 0 ) ) ) { + return false; + } elseif ( empty( $selector['id'] ) && empty( $selector['value'] ) ) { + return false; + } elseif ( ! empty( $selector['id'] ) && ! wp_parse_url( $selector['id'] ) ) { + return false; + } elseif ( ! empty( $selector['value'] ) && ! stripos( (string) $selector['value'], '' ) === false ) { + return false; + } + return true; + + case 'RangeSelector': + $allow_keys = array( + 'type', + 'startSelector', + 'endSelector', + ); + if ( array_diff_key( $selector, array_fill_keys( $allow_keys, 0 ) ) ) { + return false; + } elseif ( empty( $selector['startSelector'] ) || empty( $selector['endSelector'] ) ) { + return false; + } elseif ( ! self::is_valid_selector( $selector['startSelector'], true ) ) { + return false; + } elseif ( ! self::is_valid_selector( $selector['endSelector'], true ) ) { + return false; + } + return true; + } + + return false; + } +} diff --git a/lib/class-wp-rest-annotations-controller.php b/lib/class-wp-rest-annotations-controller.php new file mode 100644 index 00000000000000..8b43e33fb9c779 --- /dev/null +++ b/lib/class-wp-rest-annotations-controller.php @@ -0,0 +1,1104 @@ +namespace = 'wp/v2'; // @codingStandardsIgnoreLine + $this->rest_base = 'annotations'; + + $this->meta = new WP_REST_Comment_Meta_Fields(); + } + + /** + * Retrieves annotation schema. + * + * Overrides parent method and makes 'type' writable. + * + * @since [version] + * + * @return array Annotation schema. + * + * @see WP_REST_Comments_Controller::get_item_schema() + */ + public function get_item_schema() { + $schema = parent::get_item_schema(); + + $schema['properties']['type'] = array( + 'type' => 'string', + 'arg_options' => array( + 'validate_callback' => 'WP_Annotation_Utils::is_valid_type', + ), + 'context' => array( 'view', 'edit', 'embed' ), + 'description' => __( 'Annotation comment type.', 'gutenberg' ), + 'default' => WP_Annotation_Utils::$types[0], + ); + $schema['properties']['status'] = array( + 'type' => 'string', + 'arg_options' => array( + 'validate_callback' => 'WP_Annotation_Utils::is_valid_status_or_action', + ), + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Annotation status.', 'gutenberg' ), + 'default' => 'approve', + ); + $schema['properties']['children'] = array( + 'readonly' => true, + 'type' => 'array', + 'items' => array( + 'type' => 'object', + ), + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Hierarchical children in threaded format.', 'gutenberg' ), + ); + + return $schema; + } + + /** + * Retrieves additional fields. + * + * Overrides parent method and adds additional fields specific to annotation comment + * types. Note: Intentionally *not* using {@see register_rest_field()} because that + * would affect all comment types. + * + * @since [version] + * + * @param string $object_type Optional object type. + * + * @return array Additional fields. + * + * @see WP_REST_Controller::get_collection_params() + */ + protected function get_additional_fields( $object_type = null ) { + $fields = parent::get_additional_fields( $object_type ); + + $fields['via'] = array( + 'get_callback' => array( $this, 'on_get_additional_field' ), + 'update_callback' => array( $this, 'on_update_additional_field' ), + 'schema' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'W3C annotation client identifier.', 'gutenberg' ), + 'default' => '', + ), + ); + + $fields['selector'] = array( + 'get_callback' => array( $this, 'on_get_additional_field' ), + 'update_callback' => array( $this, 'on_update_additional_field' ), + 'schema' => array( + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'W3C annotation selector.', 'gutenberg' ), + + 'properties' => array( + 'type' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'enum' => WP_Annotation_Utils::$selectors, + 'description' => __( 'Type of selector.', 'gutenberg' ), + ), + 'additionalProperties' => true, + ), + 'default' => array(), + ), + ); + + return $fields; + } + + /** + * Retrieves collection parameters. + * + * Overrides parent method and adds additional params specific to annotation comment + * types. Note: Intentionally *not* using REST API filters because that would affect + * all comment types. + * + * @since [version] + * + * @return array Collection parameters. + * + * @see WP_REST_Comments_Controller::get_collection_params() + */ + public function get_collection_params() { + $params = parent::get_collection_params(); + + $params['post']['required'] = true; + + $params['type'] = array( + 'type' => 'array', + 'description' => __( 'Annotation type(s).', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + 'enum' => WP_Annotation_Utils::$types, + ), + 'default' => array( WP_Annotation_Utils::$types[0] ), + 'validate_callback' => array( $this, 'validate_type_collection_param' ), + ); + + $params['status'] = array( + 'type' => 'array', + 'description' => __( 'Annotation status(es).', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + ), + 'default' => array( 'approve' ), + 'validate_callback' => array( $this, 'validate_status_collection_param' ), + ); + + $params['via'] = array( + 'type' => 'array', + 'description' => __( 'W3C annotation client identifier(s).', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + ), + 'validate_callback' => array( $this, 'validate_via_collection_param' ), + ); + + $params['hierarchical'] = array( + 'type' => 'string', + 'description' => __( 'Results in hierarchical format?', 'gutenberg' ), + 'enum' => array( '', 'flat', 'threaded' ), + ); + + return $params; + } + + /** + * Validates 'type' collection parameter. + * + * @since [version] + * + * @param string|array $types Annotation comment types. + * + * @return WP_Error|bool True if valid, {@see WP_Error} otherwise. + */ + public function validate_type_collection_param( $types ) { + if ( ! is_array( $types ) ) { + $types = preg_split( '/[\s,]+/', (string) $types ); + } + + if ( ! wp_is_numeric_array( $types ) ) { + return new WP_Error( 'rest_annotation_invalid_array_param_type', __( 'Invalid type(s).', 'gutenberg' ) ); + } + + foreach ( $types as $type ) { + if ( ! WP_Annotation_Utils::is_valid_type( $type ) ) { + return new WP_Error( 'rest_annotation_invalid_param_type', __( 'Invalid type.', 'gutenberg' ) ); + } + } + + return true; + } + + /** + * Validates 'status' collection parameter. + * + * @since [version] + * + * @param string|array $statuses Annotation comment statuses. + * + * @return WP_Error|bool True if valid, {@see WP_Error} otherwise. + */ + public function validate_status_collection_param( $statuses ) { + if ( ! is_array( $statuses ) ) { + $statuses = preg_split( '/[\s,]+/', (string) $statuses ); + } + + if ( ! wp_is_numeric_array( $statuses ) ) { + return new WP_Error( 'rest_annotation_invalid_array_param_status', __( 'Invalid status(es).', 'gutenberg' ) ); + } + + foreach ( $statuses as $status ) { + if ( ! WP_Annotation_Utils::is_valid_status( $status ) ) { + return new WP_Error( 'rest_annotation_invalid_param_status', __( 'Invalid status.', 'gutenberg' ) ); + } + } + + return true; + } + + /** + * Validates the 'via' collection parameter. + * + * @since [version] + * + * @param string|array $vias W3C annotation client identifier(s). + * + * @return WP_Error|bool True if valid, {@see WP_Error} otherwise. + */ + public function validate_via_collection_param( $vias ) { + if ( ! is_array( $vias ) ) { + $vias = preg_split( '/[\s,]+/', (string) $vias ); + } + + if ( ! wp_is_numeric_array( $vias ) ) { + return new WP_Error( 'rest_annotation_invalid_array_param_via', __( 'Invalid client identifier(s).', 'gutenberg' ) ); + } + + foreach ( $vias as $via ) { + if ( ! WP_Annotation_Utils::is_valid_client( $via ) ) { + return new WP_Error( 'rest_annotation_invalid_param_via', __( 'Invalid client identifier.', 'gutenberg' ) ); + } + } + + return true; + } + + /** + * Gets an additional field value. + * + * @since [version] + * + * @param WP_Comment|array $comment Comment data. + * @param string $field Name of the field to get. + * @param WP_Rest_Request $request Full REST API request details. + * + * @return mixed|null Current value, null otherwise. + * + * @see WP_REST_Annotations_Controller::get_additional_fields() + */ + public function on_get_additional_field( $comment, $field, $request ) { + if ( $comment instanceof WP_Comment ) { + $comment_id = $comment->comment_ID; + } elseif ( ! empty( $comment['id'] ) ) { + $comment_id = $comment['id']; + } + + if ( empty( $comment_id ) ) { + return null; + } + + $value = get_comment_meta( $comment_id, '_' . $field, true ); + + switch ( $field ) { + case 'via': + return is_string( $value ) ? $value : ''; + + case 'selector': + return is_array( $value ) ? $value : array(); + } + + return null; + } + + /** + * Updates an additional field value. + * + * @since [version] + * + * @param string $value New field value. + * @param WP_Comment|array $comment Comment data. + * @param string $field Name of the field to update. + * @param WP_Rest_Request $request Full REST API request details. + * + * @return bool|WP_Error {@see WP_Error} on failure, null otherwise. + * + * @see WP_REST_Annotations_Controller::get_additional_fields() + */ + public function on_update_additional_field( $value, $comment, $field, $request ) { + // translators: %s is a REST API field name associated with failure. + $error = __( 'Validation failure. Failed to update: %s.', 'gutenberg' ); + $error = new WP_Error( 'rest_annotation_field_validation_update_failure', sprintf( $error, $field ), array( 'status' => 400 ) ); + + if ( $comment instanceof WP_Comment ) { + $comment_id = $comment->comment_ID; + } elseif ( ! empty( $comment['id'] ) ) { + $comment_id = $comment['id']; + } + + if ( empty( $comment_id ) ) { + return $error; + } + + switch ( $field ) { + case 'via': + if ( ! WP_Annotation_Utils::is_valid_client( $value ) ) { + return $error; + } + return update_comment_meta( $comment_id, '_' . $field, $value ); + + case 'selector': + if ( ! WP_Annotation_Utils::is_valid_selector( $value ) ) { + return $error; + } + return update_comment_meta( $comment_id, '_' . $field, $value ); + } + + return $error; + } + + /** + * Prepares a comment for DB insertion. + * + * Overrides parent method and handles 'comment_type'. + * + * @since [version] + * + * @param WP_REST_Request $request Full REST API request details. + * + * @return array|WP_Error Prepared comment, {@see WP_Error} otherwise. + * + * @see WP_REST_Comments_Controller::prepare_item_for_database() + */ + protected function prepare_item_for_database( $request ) { + $prepared_comment = parent::prepare_item_for_database( $request ); + + if ( isset( $request['type'] ) ) { + $prepared_comment['comment_type'] = $request['type']; + } + + return $prepared_comment; + } + + /** + * Prepares an annotation response. + * + * Overrides parent method and handles 'children'. + * + * @since [version] + * + * @param WP_Comment $comment Comment object. + * @param WP_REST_Request $request Request object. + * + * @return WP_REST_Response Response object. + */ + public function prepare_item_for_response( $comment, $request ) { + $response = parent::prepare_item_for_response( $comment, $request ); + + if ( 'threaded' === $request['hierarchical'] ) { + $data = $response->get_data(); + $data['children'] = array(); + + foreach ( $comment->get_children() as $child_comment ) { + $child_response = $this->prepare_item_for_response( $child_comment, $request ); + $child_data = $child_response->get_data(); + $child_data['_links'] = $child_response->get_links(); + $data['children'][] = $child_data; + } + + $response->set_data( $data ); + } + + return $response; + } + + /** + * Sets a comment's status. + * + * Overrides parent method and handles custom statuses. + * + * @since [version] + * + * @param string|int $new_status New status. + * @param int $comment_id Comment ID. + * + * @return bool True if status changed. + * + * @see WP_REST_Comments_Controller::handle_status_param() + */ + protected function handle_status_param( $new_status, $comment_id ) { + $old_status = WP_Annotation_Utils::get_comment_status( $comment_id ); + + if ( $new_status === $old_status ) { + return false; + } + + switch ( $new_status ) { + case 'approved': + case 'approve': + case '1': + return wp_set_comment_status( $comment_id, 'approve' ); + + case 'hold': + case '0': + return wp_set_comment_status( $comment_id, 'hold' ); + + case 'spam': + return wp_spam_comment( $comment_id ); + + case 'unspam': // Supports custom restoration statuses. + return WP_Annotation_Utils::unspam_comment( $comment_id ); + + case 'trash': + return wp_trash_comment( $comment_id ); + + case 'untrash': // Supports custom restoration statuses. + return WP_Annotation_Utils::untrash_comment( $comment_id ); + + default: // Supports custom statuses. + return WP_Annotation_Utils::set_comment_status( $comment_id, $new_status ); + } + + return false; + } + + /** + * Creates a comment; i.e., an annotation. + * + * Overrides parent method and allows comment 'type'. Excludes front-end {@see + * wp_allow_comment()} check paranoia for back-end annotations. Handles response + * 'context' differently by checking annotation permissions. + * + * @since [version] + * + * @param WP_REST_Request $request Full REST API request details. + * + * @return WP_Error|WP_REST_Response Response object, {@see WP_Error} otherwise. + * + * @see WP_REST_Comments_Controller::create_item() + */ + public function create_item( $request ) { + if ( ! empty( $request['id'] ) ) { + return new WP_Error( 'rest_readonly_annotation_param_id', __( 'ID is read-only.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + $prepared_comment = $this->prepare_item_for_database( $request ); + + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + if ( empty( $prepared_comment['comment_content'] ) ) { + return new WP_Error( 'rest_missing_annotation_content', __( 'Content empty.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! isset( $prepared_comment['comment_date_gmt'] ) ) { + $prepared_comment['comment_date_gmt'] = current_time( 'mysql', true ); + } + + $missing_author = empty( $prepared_comment['user_id'] ) + && empty( $prepared_comment['comment_author'] ) + && empty( $prepared_comment['comment_author_email'] ) + && empty( $prepared_comment['comment_author_url'] ); + + if ( $missing_author && is_user_logged_in() ) { + $user = wp_get_current_user(); + + $prepared_comment['user_id'] = $user->ID; + $prepared_comment['comment_author'] = $user->display_name; + $prepared_comment['comment_author_email'] = $user->user_email; + $prepared_comment['comment_author_url'] = $user->user_url; + } + + if ( empty( $prepared_comment['comment_author'] ) || empty( $prepared_comment['comment_author_email'] ) ) { + if ( get_option( 'require_name_email' ) ) { + return new WP_Error( 'rest_missing_annotation_author_data', __( 'Missing author data.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + } + + if ( ! isset( $prepared_comment['comment_author_email'] ) ) { + $prepared_comment['comment_author_email'] = ''; + } + + if ( ! isset( $prepared_comment['comment_author_url'] ) ) { + $prepared_comment['comment_author_url'] = ''; + } + + if ( ! isset( $prepared_comment['comment_agent'] ) ) { + $prepared_comment['comment_agent'] = ''; + } + + $check_comment_lengths = wp_check_comment_data_max_lengths( $prepared_comment ); + + if ( is_wp_error( $check_comment_lengths ) ) { + return new WP_Error( $check_comment_lengths->get_error_code(), __( 'Comment field exceeds maximum length allowed.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( 'annotation' === $prepared_comment['comment_type'] ) { + $wp_allow_comment_status = wp_allow_comment( $prepared_comment, true ); + + if ( is_wp_error( $wp_allow_comment_status ) ) { + $error_code = $wp_allow_comment_status->get_error_code(); + $error_message = $wp_allow_comment_status->get_error_message(); + + if ( 'comment_duplicate' === $error_code ) { + return new WP_Error( $error_code, $error_message, array( 'status' => 409 ) ); + } + + if ( 'comment_flood' === $error_code ) { + return new WP_Error( $error_code, $error_message, array( 'status' => 400 ) ); + } + + return $wp_allow_comment_status; + } else { + $prepared_comment['comment_approved'] = $wp_allow_comment_status; + } + } + + /** This filter is documented in wp-includes/rest-api/class-wp-rest-comments-controller.php */ + $prepared_comment = apply_filters( 'rest_pre_insert_comment', $prepared_comment, $request ); + + if ( is_wp_error( $prepared_comment ) ) { + return $prepared_comment; + } + + $comment_id = wp_insert_comment( wp_filter_comment( wp_slash( (array) $prepared_comment ) ) ); + + if ( ! $comment_id ) { + return new WP_Error( 'rest_annotation_insert_failure', __( 'Annotation insertion failure.', 'gutenberg' ), array( + 'status' => 500, + ) ); + } + + if ( isset( $request['status'] ) && current_user_can( 'edit_annotation', $comment_id ) ) { + $this->handle_status_param( $request['status'], $comment_id ); + } + + $comment = get_comment( $comment_id ); + + if ( ! $comment ) { + return new WP_Error( 'rest_annotation_insert_retrieve_failure', __( 'Annotation insert/retrieve failure.', 'gutenberg' ), array( + 'status' => 500, + ) ); + } + + /** This action is documented in wp-includes/rest-api/class-wp-rest-comments-controller.php */ + do_action( 'rest_insert_comment', $comment, $request, true ); + + $schema = $this->get_item_schema(); + + if ( ! empty( $schema['properties']['meta'] ) && isset( $request['meta'] ) ) { + $meta_update = $this->meta->update_value( $request['meta'], $comment_id ); + + if ( is_wp_error( $meta_update ) ) { + return $meta_update; + } + } + + $fields_update = $this->update_additional_fields_for_object( $comment, $request ); + + if ( is_wp_error( $fields_update ) ) { + return $fields_update; + } + + $request->set_param( 'context', current_user_can( 'edit_annotation', $comment->comment_ID ) ? 'edit' : 'view' ); + + $response = $this->prepare_item_for_response( $comment, $request ); + $response = rest_ensure_response( $response ); + + $response->set_status( 201 ); + + // phpcs:ignore PHPCompatibility.PHP.NewKeywords.t_namespaceFound — mistaken as 'namespace' keyword. + $response->header( 'Location', rest_url( sprintf( '%s/%s/%d', $this->namespace, $this->rest_base, $comment_id ) ) ); // @codingStandardsIgnoreLine + + return $response; + } + + /** + * Checks if request has access to create an item. + * + * @since [version] + * + * @param WP_REST_Request $request Full REST API request details. + * + * @return bool|WP_Error True if request has access to create, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Comments_Controller::create_item_permissions_check() + */ + public function create_item_permissions_check( $request ) { + $post_id = $request['post']; + $type = $request['type']; + $status = $request['status']; + $parent_id = $request['parent']; + $default_params = $request->get_default_params(); + + if ( ! $post_id ) { + return new WP_Error( 'rest_missing_annotation_post', __( 'Missing post.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $type ) { + return new WP_Error( 'rest_missing_annotation_type', __( 'Missing type.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $status && '0' !== $status ) { + return new WP_Error( 'rest_invalid_annotation_param_status', __( 'Missing status.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $this->check_read_post_permission( $post_id, $request ) ) { + return new WP_Error( 'rest_cannot_read_annotation_post', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( ! current_user_can( 'create_annotation', $post_id, $type ) ) { + return new WP_Error( 'rest_cannot_create_annotation', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( $parent_id && ! current_user_can( 'read_annotation', $parent_id ) ) { + return new WP_Error( 'rest_cannot_read_annotation_parent', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( ! is_user_logged_in() ) { + /** This filter is documented in wp-includes/rest-api/class-wp-rest-comments-controller.php */ + $allow_anonymous = apply_filters( 'rest_allow_anonymous_comments', false, $request ); + + if ( ! $allow_anonymous ) { + return new WP_Error( 'rest_cannot_create_anonymous_annotation', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( get_option( 'require_name_email' ) ) { + if ( ! $request['author_name'] || ! is_email( $request['author_email'] ) ) { + return new WP_Error( 'rest_missing_annotation_author_data', __( 'Missing author data.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + } + + foreach ( array( + 'id', + ) as $param ) { + if ( isset( $request[ $param ] ) && ( ! isset( $default_params[ $param ] ) || $request[ $param ] !== $default_params[ $param ] ) ) { + return new WP_Error( 'rest_readonly_annotation_param_' . $param, __( 'Read-only param.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + } + + if ( ! current_user_can( 'edit_annotations', $type ) ) { + foreach ( array( + 'date', + 'date_gmt', + 'meta', + ) as $param ) { + if ( isset( $request[ $param ] ) && ( ! isset( $default_params[ $param ] ) || $request[ $param ] !== $default_params[ $param ] ) ) { + return new WP_Error( 'rest_forbidden_annotation_param_' . $param, __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + } + + if ( ! current_user_can( 'edit_others_annotations', $type ) ) { + foreach ( array( + 'author', + 'author_ip', + 'author_user_agent', + ) as $param ) { + if ( isset( $request[ $param ] ) && ( ! isset( $default_params[ $param ] ) || $request[ $param ] !== $default_params[ $param ] ) ) { + return new WP_Error( 'rest_forbidden_annotation_param_' . $param, __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + $allow_statuses = array( 'approved', 'approve', '1' ); + $allow_statuses = array_merge( $allow_statuses, WP_Annotation_Utils::$custom_statuses ); + + if ( ! in_array( $status, $allow_statuses, true ) ) { + return new WP_Error( 'rest_forbidden_annotation_param_status', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + return true; + } + + /** + * Checks if request has access to read items. + * + * @since [version] + * + * @param WP_REST_Request $request Full REST API request details. + * + * @return bool|WP_Error True if request has access to read, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Comments_Controller::get_items_permissions_check() + */ + public function get_items_permissions_check( $request ) { + $post_ids = (array) $request['post']; + $types = (array) $request['type']; + $statuses = (array) $request['status']; + $default_params = $request->get_default_params(); + + if ( ! $post_ids ) { + return new WP_Error( 'rest_missing_annotation_posts', __( 'Missing post(s).', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $types ) { + return new WP_Error( 'rest_missing_annotation_types', __( 'Missing type(s).', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $statuses ) { + return new WP_Error( 'rest_missing_annotation_statuses', __( 'Missing status(es).', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + foreach ( $post_ids as $post_id ) { + if ( ! $post_id ) { + return new WP_Error( 'rest_missing_annotations_post', __( 'Missing post.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $this->check_read_post_permission( $post_id, $request ) ) { + return new WP_Error( 'rest_cannot_read_annotations_post', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + foreach ( $types as $type ) { + if ( ! current_user_can( 'read_annotations', $post_id, $type ) ) { + return new WP_Error( 'rest_cannot_read_annotations', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + } + + foreach ( $types as $type ) { + if ( ! current_user_can( 'edit_annotations', $type ) ) { + if ( 'edit' === $request['context'] ) { + return new WP_Error( 'rest_forbidden_annotations_context', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + $allow_statuses = array( 'approved', 'approve', '1' ); + $allow_statuses = array_merge( $allow_statuses, WP_Annotation_Utils::$custom_statuses ); + + if ( array_diff( $statuses, $allow_statuses ) ) { + return new WP_Error( 'rest_forbidden_annotation_param_status', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + if ( ! current_user_can( 'edit_others_annotations', $type ) ) { + $current_user_id = get_current_user_id(); + $current_user_can_edit = current_user_can( 'edit_annotations', $type ); + $is_current_user_query = $current_user_id && $request['author'] === $current_user_id; + + foreach ( array( + 'author', + 'author_exclude', + 'author_email', + ) as $param ) { + if ( ! isset( $request[ $param ] ) ) { + continue; + } elseif ( isset( $default_params[ $param ] ) + && $request[ $param ] === $default_params[ $param ] ) { + continue; + } + if ( 'author' === $param ) { + if ( ! $is_current_user_query || ! $current_user_can_edit ) { + return new WP_Error( 'rest_forbidden_annotations_param_' . $param, __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } else { + return new WP_Error( 'rest_forbidden_annotations_param_' . $param, __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + $allow_statuses = array( 'approved', 'approve', '1' ); + $allow_statuses = array_merge( $allow_statuses, WP_Annotation_Utils::$custom_statuses ); + + if ( $is_current_user_query && $current_user_can_edit ) { + $allow_statuses[] = 'trash'; // A user can view their own trash. + } + + if ( array_diff( $statuses, $allow_statuses ) ) { + return new WP_Error( 'rest_forbidden_annotations_param_status', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + } + + return true; + } + + /** + * Checks if request has access to read an item. + * + * @since [version] + * + * @param WP_REST_Request $request Full REST API request details. + * + * @return bool|WP_Error True if request has read access, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Comments_Controller::get_item_permissions_check() + */ + public function get_item_permissions_check( $request ) { + $id = $request['id']; + $comment = $id ? get_comment( $id ) : null; + $post_id = $comment ? absint( $comment->comment_post_ID ) : 0; + + if ( ! $comment ) { + return new WP_Error( 'rest_missing_annotation', __( 'Missing annotation.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $post_id ) { + return new WP_Error( 'rest_missing_annotation_post', __( 'Missing post.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $this->check_read_post_permission( $post_id, $request ) ) { + return new WP_Error( 'rest_cannot_read_annotation_post', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( ! current_user_can( 'read_annotation', $comment->comment_ID ) ) { + return new WP_Error( 'rest_cannot_read_annotation', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( 'edit' === $request['context'] && ! current_user_can( 'edit_annotation', $comment->comment_ID ) ) { + return new WP_Error( 'rest_forbidden_annotation_context', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + return true; + } + + /** + * Checks if request has access to update an item. + * + * @since [version] + * + * @param WP_REST_Request $request Full REST API request details. + * + * @return bool|WP_Error True if request has access to update, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Comments_Controller::update_item_permissions_check() + */ + public function update_item_permissions_check( $request ) { + $id = $request['id']; + $comment = $id ? get_comment( $id ) : null; + $post_id = $comment ? absint( $comment->comment_post_ID ) : 0; + + if ( ! $comment ) { + return new WP_Error( 'rest_missing_annotation', __( 'Missing annotation.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $post_id ) { + return new WP_Error( 'rest_missing_annotation_post', __( 'Missing post.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $this->check_read_post_permission( $post_id, $request ) ) { + return new WP_Error( 'rest_cannot_read_annotation_post', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( ! current_user_can( 'edit_annotation', $comment->comment_ID ) ) { + return new WP_Error( 'rest_cannot_update_annotation', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + foreach ( array( + 'id' => 'comment_ID', + 'post' => 'comment_post_ID', + 'type' => 'comment_type', + 'parent' => 'comment_parent', + ) as $param => $prop ) { + if ( in_array( $param, array( 'id', 'post', 'parent' ), true ) ) { + if ( isset( $request[ $param ] ) && $request[ $param ] !== (int) $comment->{$prop} ) { + return new WP_Error( 'rest_cannot_update_annotation_param_' . $param, __( 'Read-only param.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + } elseif ( isset( $request[ $param ] ) && $request[ $param ] !== $comment->{$prop} ) { + return new WP_Error( 'rest_cannot_update_annotation_param_' . $param, __( 'Read-only param.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + } + + if ( ! current_user_can( 'edit_others_annotations', $comment->comment_type ) ) { + foreach ( array( + 'author' => 'user_id', + 'author_name' => 'comment_author', + 'author_email' => 'comment_author_email', + 'author_ip' => 'comment_author_IP', + 'author_user_agent' => 'comment_agent', + 'author_url' => 'comment_author_url', + ) as $param ) { + if ( isset( $request[ $param ] ) && $request[ $param ] !== $comment->{$prop} ) { + return new WP_Error( 'rest_forbidden_annotation_param_' . $param, __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + + if ( isset( $request['status'] ) ) { + $allow_statuses = array( 'approved', 'approve', '1' ); + $allow_statuses = array_merge( $allow_statuses, WP_Annotation_Utils::$custom_statuses ); + + if ( current_user_can( 'delete_annotation', $comment->comment_ID ) ) { + $allow_statuses[] = 'trash'; // The user can trash also. + } + + if ( ! in_array( $request['status'], $allow_statuses, true ) ) { + return new WP_Error( 'rest_forbidden_annotation_param_status', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + } + + return true; + } + + /** + * Checks if request has access to delete an item. + * + * @since [version] + * + * @param WP_REST_Request $request Full REST API request details. + * + * @return bool|WP_Error True if request has access to delete, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Comments_Controller::delete_item_permissions_check() + */ + public function delete_item_permissions_check( $request ) { + $id = $request['id']; + $comment = $id ? get_comment( $id ) : null; + $post_id = $comment ? absint( $comment->comment_post_ID ) : 0; + + if ( ! $comment ) { + return new WP_Error( 'rest_missing_annotation', __( 'Missing annotation.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $post_id ) { + return new WP_Error( 'rest_missing_annotation_post', __( 'Missing post.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! $this->check_read_post_permission( $post_id, $request ) ) { + return new WP_Error( 'rest_cannot_read_annotation_post', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( ! current_user_can( 'delete_annotation', $comment->comment_ID ) ) { + return new WP_Error( 'rest_cannot_delete_annotation', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + return true; + } + + /** + * Checks if the post can be read. + * + * Overrides parent method and supports post object or ID. + * + * @since [version] + * + * @param WP_Post|int $post Post object or ID. + * @param WP_REST_Request $request Request data to check. + * + * @return bool True if post can be read. + */ + protected function check_read_post_permission( $post, $request ) { + $post = $post ? get_post( $post ) : null; + return $post ? parent::check_read_post_permission( $post, $request ) : false; + } + + /** + * Checks if a comment can be read. + * + * @since [version] + * + * @param WP_Comment $comment Comment object. + * @param WP_REST_Request $request Full REST API request details. + * + * @return bool True if comment can be read. + * + * @see WP_REST_Comments_Controller::check_read_permission() + */ + protected function check_read_permission( $comment, $request ) { + return current_user_can( 'read_annotation', $comment->comment_ID ); + } + + /** + * Checks if a comment can be edited. + * + * @since [version] + * + * @param WP_Comment $comment Comment object. + * + * @return bool True if comment can be edited. + * + * @see WP_REST_Comments_Controller::check_edit_permission() + */ + protected function check_edit_permission( $comment ) { + return current_user_can( 'edit_annotation', $comment->comment_ID ); + } +} diff --git a/lib/load.php b/lib/load.php index 0da31fddf577cc..5afc658fd74821 100644 --- a/lib/load.php +++ b/lib/load.php @@ -13,6 +13,8 @@ require dirname( __FILE__ ) . '/class-wp-block-type.php'; require dirname( __FILE__ ) . '/class-wp-block-type-registry.php'; require dirname( __FILE__ ) . '/class-wp-rest-blocks-controller.php'; +require dirname( __FILE__ ) . '/class-wp-annotation-utils.php'; +require dirname( __FILE__ ) . '/class-wp-rest-annotations-controller.php'; require dirname( __FILE__ ) . '/blocks.php'; require dirname( __FILE__ ) . '/client-assets.php'; require dirname( __FILE__ ) . '/compat.php'; diff --git a/lib/register.php b/lib/register.php index 56f0d809d9b9fa..aed91f7cf5abd5 100644 --- a/lib/register.php +++ b/lib/register.php @@ -409,6 +409,30 @@ function gutenberg_register_post_types() { } add_action( 'init', 'gutenberg_register_post_types' ); +/** + * Initializes annotations. + * + * @since [version] + */ +function gutenberg_init_annotations() { + add_filter( 'map_meta_cap', 'WP_Annotation_Utils::on_map_meta_cap', 10, 4 ); + add_filter( 'comments_clauses', 'WP_Annotation_Utils::on_comments_clauses', 1000, 2 ); +} +add_action( 'init', 'gutenberg_init_annotations' ); + +/** + * Initializes REST API for annotations. + * + * @since [version] + */ +function gutenberg_rest_api_init_annotations() { + $controller = new WP_REST_Annotations_Controller(); + $controller->register_routes(); + + add_filter( 'rest_comment_query', 'WP_Annotation_Utils::on_rest_comment_query', 10, 2 ); +} +add_action( 'rest_api_init', 'gutenberg_rest_api_init_annotations' ); + /** * Gets revisions details for the selected post. * diff --git a/phpunit/class-annotations-test.php b/phpunit/class-annotations-test.php new file mode 100644 index 00000000000000..cb93e41b187a41 --- /dev/null +++ b/phpunit/class-annotations-test.php @@ -0,0 +1,555 @@ +user->create( array( 'role' => $r ) ); + + self::$post_id[ "post_by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'publish', + 'comment_status' => 'open', + 'post_title' => 'Post by ' . $r, + 'post_content' => '

bold italic test post.

', + ) ); + + self::$post_id[ "draft_by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'draft', + 'comment_status' => 'open', + 'post_title' => 'Draft by ' . $r, + 'post_content' => '

bold italic test draft.

', + ) ); + } + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( + 'annotation' => array( + 'in_post_by' => self::$post_id[ "post_by_{$r}" ], + ), + 'admin_annotation' => array( + 'in_post_backend_by' => self::$post_id[ "post_by_{$r}" ], + 'in_draft_backend_by' => self::$post_id[ "draft_by_{$r}" ], + ), + ) as $_comment_type => $_post_key_ids ) { + foreach ( $_post_key_ids as $k => $_post_id ) { + $_common_annotation_data = array( + 'user_id' => self::$user_id[ $_r ], + 'comment_post_ID' => $_post_id, + 'comment_type' => $_comment_type, + 'comment_approved' => '1', + ); + $_common_annotation_meta = array( + '_via' => 'gutenberg', + '_selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + ); + + self::$anno_id[ "{$_r}:{$k}_{$r}" ] = $factory->comment->create( + array_merge( $_common_annotation_data, array( + 'comment_parent' => 0, + 'comment_content' => '

bold italic test annotation.

', + 'comment_meta' => $_common_annotation_meta, + ) ) + ); + + self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] = $factory->comment->create( + array_merge( $_common_annotation_data, array( + 'comment_parent' => self::$anno_id[ "{$_r}:{$k}_{$r}" ], + 'comment_content' => '

bold italic test annotation reply.

', + 'comment_meta' => $_common_annotation_meta, + ) ) + ); + + self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] = $factory->comment->create( + array_merge( $_common_annotation_data, array( + 'comment_parent' => self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], + 'comment_content' => '

bold italic test annotation reply to reply.

', + 'comment_meta' => $_common_annotation_meta, + ) ) + ); + } + } + } + } + } + + /** + * Delete fake data after our tests run. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$roles as $r ) { + wp_delete_post( self::$post_id[ "post_by_{$r}" ], true ); + wp_delete_post( self::$post_id[ "draft_by_{$r}" ], true ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + wp_delete_comment( self::$anno_id[ "{$_r}:{$k}_{$r}" ], true ); + wp_delete_comment( self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], true ); + wp_delete_comment( self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ], true ); + } + } + self::delete_user( self::$user_id[ $r ] ); + } + } + + /** + * On setup. + */ + public function setUp() { + parent::setUp(); + + add_filter( 'annotation_allow_types', array( $this, 'allowTypes' ) ); + remove_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + } + + /** + * On teardown. + */ + public function tearDown() { + remove_filter( 'annotation_allow_types', array( $this, 'allowTypes' ) ); + add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + parent::tearDown(); + } + + /** + * Allows all of the annotation comment types being tested here. + * + * @return array Allowed annotation comment types. + */ + public function allowTypes() { + return array( 'annotation', 'admin_annotation' ); + } + + /* + * Basic tests. + */ + + /** + * Check that we can get types. + */ + public function test_get_types() { + $this->assertContains( 'annotation', WP_Annotation_Utils::$types ); + $this->assertContains( 'admin_annotation', WP_Annotation_Utils::$types ); + } + + /** + * Check that we can get selectors. + */ + public function test_get_selectors() { + $this->assertContains( 'FragmentSelector', WP_Annotation_Utils::$selectors ); + $this->assertContains( 'CssSelector', WP_Annotation_Utils::$selectors ); + $this->assertContains( 'XPathSelector', WP_Annotation_Utils::$selectors ); + $this->assertContains( 'TextQuoteSelector', WP_Annotation_Utils::$selectors ); + $this->assertContains( 'TextPositionSelector', WP_Annotation_Utils::$selectors ); + $this->assertContains( 'DataPositionSelector', WP_Annotation_Utils::$selectors ); + $this->assertContains( 'SvgSelector', WP_Annotation_Utils::$selectors ); + $this->assertContains( 'RangeSelector', WP_Annotation_Utils::$selectors ); + } + + /** + * Check that we can get custom statuses. + */ + public function test_get_substatuses() { + $this->assertContains( 'resolve', WP_Annotation_Utils::$custom_statuses ); + $this->assertContains( 'reject', WP_Annotation_Utils::$custom_statuses ); + $this->assertContains( 'archive', WP_Annotation_Utils::$custom_statuses ); + } + + /** + * Check that we have necessary hooks. + */ + public function test_has_hooks() { + $this->assertNotEmpty( has_filter( 'map_meta_cap', 'WP_Annotation_Utils::on_map_meta_cap' ) ); + $this->assertNotEmpty( has_filter( 'comments_clauses', 'WP_Annotation_Utils::on_comments_clauses' ) ); + } + + /* + * Test user permissions. + */ + + /** + * Check that anonymous users gain very little access to annotations. + * + * Exception: Front-end public annotations in a public (published) post can be read + * by the public, which means that an anonymous user gains read access. + * + * Exception: Front-end public annotations in a public post can be created by the + * public. Assuming comments are enabled in the post, and commenting does not require + * registration. + */ + public function test_anonymous_allow_deny_permissions() { + $r = 'anonymous'; + wp_set_current_user( 0 ); + + foreach ( array( + 'create_annotations', + 'delete_annotations', + 'delete_others_annotations', + 'delete_private_annotations', + 'delete_published_annotations', + 'edit_annotations', + 'edit_others_annotations', + 'edit_private_annotations', + 'edit_published_annotations', + 'publish_annotations', + 'read_private_annotations', + ) as $c ) { + foreach ( array( null, 'annotation', 'admin_annotation' ) as $t ) { + $i = "{$r}:{$c}:{$t}"; // Identifier. + $this->assertSame( "{$i}:false", "{$i}:" . ( current_user_can( $c, $t ) ? 'true' : 'false' ) ); + } + } + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( 'annotation', 'admin_annotation' ) as $t ) { + foreach ( array( 'create_annotation' ) as $c ) { + $p = self::$post_id[ "{$k}_{$_r}" ]; + $i = "{$r}:{$c}:in_{$k}_{$_r}:{$t}"; // Identifier. + $v = 'post_by' === $k && 'annotation' === $t ? 'true' : 'false'; + $this->assertSame( "{$i}:{$v}", "{$i}:" . ( current_user_can( $c, $p, $t ) ? 'true' : 'false' ) ); + } + } + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + foreach ( array( 'read_annotation', 'edit_annotation', 'delete_annotation' ) as $c ) { + $a = self::$anno_id[ "{$_r}:{$k}_{$_r}" ]; + $i = "{$r}:{$c}:{$k}_{$_r}"; // Identifier. + $v = 'in_post_by' === $k && 'read_annotation' === $c ? 'true' : 'false'; + $this->assertSame( "{$i}:{$v}", "{$i}:" . ( current_user_can( $c, $a ) ? 'true' : 'false' ) ); + } + } + } + } + + /** + * Check that subscribers have no access to back-end annotations whatsoever. + */ + public function test_subscriber_deny_permissions() { + foreach ( array( 'subscriber' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( array( + 'create_annotations', + 'delete_annotations', + 'delete_others_annotations', + 'delete_private_annotations', + 'delete_published_annotations', + 'edit_annotations', + 'edit_others_annotations', + 'edit_private_annotations', + 'edit_published_annotations', + 'publish_annotations', + 'read_private_annotations', + ) as $c ) { + foreach ( array( null, 'admin_annotation' ) as $t ) { + $i = "{$r}:{$c}:{$t}"; // Identifier. + $this->assertSame( "{$i}:false", "{$i}:" . ( current_user_can( $c, $t ) ? 'true' : 'false' ) ); + } + } + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( null, 'admin_annotation' ) as $t ) { + foreach ( array( 'create_annotation' ) as $c ) { + $p = self::$post_id[ "{$k}_{$_r}" ]; + $i = "{$r}:{$c}:in_{$k}_{$_r}:{$t}"; // Identifier. + $this->assertSame( "{$i}:false", "{$i}:" . ( current_user_can( $c, $p, $t ) ? 'true' : 'false' ) ); + } + } + } + foreach ( array( 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + foreach ( array( 'read_annotation', 'edit_annotation', 'delete_annotation' ) as $c ) { + $a = self::$anno_id[ "{$r}:{$k}_{$_r}" ]; + $i = "{$r}:{$c}:{$k}_{$_r}"; // Identifier. + $this->assertSame( "{$i}:false", "{$i}:" . ( current_user_can( $c, $a ) ? 'true' : 'false' ) ); + } + } + } + } + } + + /** + * Check that subscribers have access to create and read front-end annotations, but + * that they do not have the ability to edit or delete front-end annotations. + */ + public function test_subscriber_allow_deny_permissions() { + foreach ( array( 'subscriber' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by' ) as $k ) { + foreach ( array( 'annotation' ) as $t ) { + foreach ( array( 'create_annotation' ) as $c ) { + $p = self::$post_id[ "{$k}_{$_r}" ]; + $i = "{$r}:{$c}:{$k}_{$_r}:{$t}"; // Identifier. + $this->assertSame( "{$i}:true", "{$i}:" . ( current_user_can( $c, $p, $t ) ? 'true' : 'false' ) ); + } + } + } + foreach ( array( 'in_post_by' ) as $k ) { + foreach ( array( 'read_annotation', 'edit_annotation', 'delete_annotation' ) as $c ) { + $a = self::$anno_id[ "{$r}:{$k}_{$_r}" ]; + $i = "{$r}:{$c}:{$k}_{$_r}"; // Identifier. + $v = 'in_post_by' === $k && 'read_annotation' === $c ? 'true' : 'false'; + $this->assertSame( "{$i}:{$v}", "{$i}:" . ( current_user_can( $c, $a ) ? 'true' : 'false' ) ); + } + } + } + } + } + + /** + * Check that admins and editors can access all annotations without restriction. + * Admins and editors can create, read, edit, delete, and otherwise manipulate any + * annotation. + */ + public function test_admin_editor_allow_permissions() { + foreach ( array( 'administrator', 'editor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( array( + 'create_annotations', + 'delete_annotations', + 'delete_others_annotations', + 'delete_private_annotations', + 'delete_published_annotations', + 'edit_annotations', + 'edit_others_annotations', + 'edit_private_annotations', + 'edit_published_annotations', + 'publish_annotations', + 'read_private_annotations', + ) as $c ) { + foreach ( array( null, 'annotation', 'admin_annotation' ) as $t ) { + $i = "{$r}:{$c}:{$t}"; // Identifier. + $this->assertSame( "{$i}:true", "{$i}:" . ( current_user_can( $c, $t ) ? 'true' : 'false' ) ); + } + } + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( 'annotation', 'admin_annotation' ) as $t ) { + foreach ( array( 'create_annotation' ) as $c ) { + $p = self::$post_id[ "{$k}_{$_r}" ]; + $i = "{$r}:{$c}:in_{$k}_{$_r}:{$t}"; // Identifier. + $this->assertSame( "{$i}:true", "{$i}:" . ( current_user_can( $c, $p, $t ) ? 'true' : 'false' ) ); + } + } + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + foreach ( array( 'read_annotation', 'edit_annotation', 'delete_annotation' ) as $c ) { + $a = self::$anno_id[ "{$r}:{$k}_{$_r}" ]; + $i = "{$r}:{$c}:{$k}_{$_r}"; // Identifier. + $this->assertSame( "{$i}:true", "{$i}:" . ( current_user_can( $c, $a ) ? 'true' : 'false' ) ); + } + } + } + } + } + + /** + * Check that authors and contributors are able to create, read, edit, and delete + * front and back-end annotations in their own published posts and also in their + * drafts. + * + * Exception: A contributor is not allowed to edit or delete their own front-end + * annotations in any post that is now public; i.e., once their post is published, + * they are treated like any other front-end annotator. Even in a post that they're + * the author of. + */ + public function test_author_contributor_allow_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip other roles. + } + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( 'annotation', 'admin_annotation' ) as $t ) { + foreach ( array( 'create_annotation' ) as $c ) { + $p = self::$post_id[ "{$k}_{$_r}" ]; + $i = "{$r}:{$c}:in_{$k}_{$_r}:{$t}"; // Identifier. + $this->assertSame( "{$i}:true", "{$i}:" . ( current_user_can( $c, $p, $t ) ? 'true' : 'false' ) ); + } + } + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + foreach ( array( 'read_annotation', 'edit_annotation', 'delete_annotation' ) as $c ) { + $a = self::$anno_id[ "{$r}:{$k}_{$_r}" ]; + $i = "{$r}:{$c}:{$k}_{$_r}"; // Identifier. + $v = 'contributor' === $r && 'in_post_by' === $k && 'read_annotation' !== $c ? 'false' : 'true'; + $this->assertSame( "{$i}:{$v}", "{$i}:" . ( current_user_can( $c, $a ) ? 'true' : 'false' ) ); + } + } + } + } + } + + /** + * Check that authors and contributors are unable to access back-end annotations in + * any post that was drafted or published by someone else other than them. + */ + public function test_author_contributor_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip their own here. + } + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( 'admin_annotation' ) as $t ) { + foreach ( array( 'create_annotation' ) as $c ) { + $p = self::$post_id[ "{$k}_{$_r}" ]; + $i = "{$r}:{$c}:in_{$k}_{$_r}:{$t}"; // Identifier. + $this->assertSame( "{$i}:false", "{$i}:" . ( current_user_can( $c, $p, $t ) ? 'true' : 'false' ) ); + } + } + } + foreach ( array( 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + foreach ( array( 'read_annotation', 'edit_annotation', 'delete_annotation' ) as $c ) { + $a = self::$anno_id[ "{$r}:{$k}_{$_r}" ]; + $i = "{$r}:{$c}:{$k}_{$_r}"; // Identifier. + $this->assertSame( "{$i}:false", "{$i}:" . ( current_user_can( $c, $a ) ? 'true' : 'false' ) ); + } + } + } + } + } + + /* + * Test post annotation deletion. + */ + + /** + * Check that permanently deleting a post erases all of its annotations. + */ + public function test_delete_post_annotations() { + $post_id = $this->factory->post->create( array( + 'post_author' => self::$user_id['editor'], + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Post by editor.', + 'post_content' => '

bold italic test post.

', + ) ); + $this->assertInternalType( 'int', $post_id ); + $this->assertGreaterThan( 0, $post_id ); + + foreach ( array( 'annotation', 'admin_annotation' ) as $type ) { + for ( $i = 0; $i < 2; $i++ ) { // Two of each type. + $comment_id = $this->factory->comment->create( array( + 'comment_post_ID' => $post_id, + 'comment_type' => $type, + 'comment_parent' => 0, + 'comment_approved' => '1', + 'user_id' => self::$user_id['editor'], + 'comment_content' => '

bold italic test annotation.

', + 'comment_meta' => array( + '_via' => 'gutenberg', + '_selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + ), + ) ); + $this->assertInternalType( 'int', $comment_id ); + $this->assertGreaterThan( 0, $comment_id ); + } + } + wp_delete_post( $post_id, true ); + + $query = new WP_Comment_Query(); + $comment_ids = $query->query( array( + 'fields' => 'ids', + 'post_parent' => $post_id, + 'cache_domain' => 'annotations', + 'type' => array( 'annotation', 'admin_annotation' ), + 'status' => 'any', + 'number' => 0, + ) ); + + $this->assertEmpty( $comment_ids ); + } +} diff --git a/phpunit/class-rest-annotations-controller-test.php b/phpunit/class-rest-annotations-controller-test.php new file mode 100644 index 00000000000000..1d0839218d926a --- /dev/null +++ b/phpunit/class-rest-annotations-controller-test.php @@ -0,0 +1,1408 @@ +user->create( array( 'role' => $r ) ); + + self::$post_id[ "post_by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'publish', + 'comment_status' => 'open', + 'post_title' => 'Post by ' . $r, + 'post_content' => '

bold italic test post.

', + ) ); + + self::$post_id[ "draft_by_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $r ], + 'post_type' => 'post', + 'post_status' => 'draft', + 'comment_status' => 'open', + 'post_title' => 'Draft by ' . $r, + 'post_content' => '

bold italic test draft.

', + ) ); + } + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( + 'annotation' => array( + 'in_post_by' => self::$post_id[ "post_by_{$r}" ], + ), + 'admin_annotation' => array( + 'in_post_backend_by' => self::$post_id[ "post_by_{$r}" ], + 'in_draft_backend_by' => self::$post_id[ "draft_by_{$r}" ], + ), + ) as $_comment_type => $_post_key_ids ) { + foreach ( $_post_key_ids as $k => $_post_id ) { + $_common_annotation_data = array( + 'user_id' => self::$user_id[ $_r ], + 'comment_post_ID' => $_post_id, + 'comment_type' => $_comment_type, + 'comment_approved' => '1', + ); + $_common_annotation_meta = array( + '_via' => 'gutenberg', + '_selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + ); + + self::$anno_id[ "{$_r}:{$k}_{$r}" ] = $factory->comment->create( + array_merge( $_common_annotation_data, array( + 'comment_parent' => 0, + 'comment_content' => '

bold italic test annotation.

', + 'comment_meta' => $_common_annotation_meta, + ) ) + ); + + self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] = $factory->comment->create( + array_merge( $_common_annotation_data, array( + 'comment_parent' => self::$anno_id[ "{$_r}:{$k}_{$r}" ], + 'comment_content' => '

bold italic test annotation reply.

', + 'comment_meta' => $_common_annotation_meta, + ) ) + ); + + self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] = $factory->comment->create( + array_merge( $_common_annotation_data, array( + 'comment_parent' => self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], + 'comment_content' => '

bold italic test annotation reply to reply.

', + 'comment_meta' => $_common_annotation_meta, + ) ) + ); + } + } + } + } + } + + /** + * Delete fake data after our tests run. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$roles as $r ) { + wp_delete_post( self::$post_id[ "post_by_{$r}" ], true ); + wp_delete_post( self::$post_id[ "draft_by_{$r}" ], true ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + wp_delete_comment( self::$anno_id[ "{$_r}:{$k}_{$r}" ], true ); + wp_delete_comment( self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], true ); + wp_delete_comment( self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ], true ); + } + } + self::delete_user( self::$user_id[ $r ] ); + } + } + + /** + * On setup. + */ + public function setUp() { + parent::setUp(); + + add_filter( 'rest_allow_anonymous_comments', '__return_true' ); + add_filter( 'annotation_allow_types', array( $this, 'allowTypes' ) ); + remove_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + } + + /** + * On teardown. + */ + public function tearDown() { + remove_filter( 'rest_allow_anonymous_comments', '__return_true' ); + remove_filter( 'annotation_allow_types', array( $this, 'allowTypes' ) ); + add_action( 'check_comment_flood', 'check_comment_flood_db', 10, 4 ); + + parent::tearDown(); + } + + /** + * Allows all of the annotation comment types being tested here. + * + * @return array Allowed annotation comment types. + */ + public function allowTypes() { + return array( 'annotation', 'admin_annotation' ); + } + + /* + * Basic tests. + */ + + /** + * Check that our routes got registered properly. + */ + public function test_register_routes() { + $routes = $this->server->get_routes(); + + $this->assertArrayHasKey( self::$rest_ns_base, $routes ); + $this->assertCount( 2, $routes[ self::$rest_ns_base ] ); + + $this->assertArrayHasKey( self::$rest_ns_base . '/(?P[\d]+)', $routes ); + $this->assertCount( 3, $routes[ self::$rest_ns_base . '/(?P[\d]+)' ] ); + } + + /** + * Check that we've defined a JSON schema properly. + */ + public function test_get_item_schema() { + $request = new WP_REST_Request( 'OPTIONS', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + $properties = $data['schema']['properties']; + + $this->assertArrayHasKey( 'id', $properties ); + $this->assertArrayHasKey( 'author', $properties ); + $this->assertArrayHasKey( 'author_avatar_urls', $properties ); + $this->assertArrayHasKey( 'author_email', $properties ); + $this->assertArrayHasKey( 'author_ip', $properties ); + $this->assertArrayHasKey( 'author_name', $properties ); + $this->assertArrayHasKey( 'author_url', $properties ); + $this->assertArrayHasKey( 'author_user_agent', $properties ); + $this->assertArrayHasKey( 'content', $properties ); + $this->assertArrayHasKey( 'date', $properties ); + $this->assertArrayHasKey( 'date_gmt', $properties ); + $this->assertArrayHasKey( 'link', $properties ); + $this->assertArrayHasKey( 'meta', $properties ); + $this->assertArrayHasKey( 'parent', $properties ); + $this->assertArrayHasKey( 'post', $properties ); + $this->assertArrayHasKey( 'status', $properties ); + $this->assertArrayHasKey( 'type', $properties ); + + $this->assertArrayHasKey( 'via', $properties ); + $this->assertArrayHasKey( 'selector', $properties ); + $this->assertArrayHasKey( 'children', $properties ); + + $this->assertSame( 17 + 3, count( $properties ) ); + } + + /** + * Check that our endpoints support the context param. + */ + public function test_context_param() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'OPTIONS', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + + $request = new WP_REST_Request( 'OPTIONS', self::$rest_ns_base . '/' . self::$anno_id['editor:in_post_by_editor'] ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 'view', $data['endpoints'][0]['args']['context']['default'] ); + $this->assertSame( array( 'view', 'embed', 'edit' ), $data['endpoints'][0]['args']['context']['enum'] ); + } + + /* + * Collection tests. + */ + + /** + * Check that we can GET a collection of annotations. + */ + public function test_get_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', self::$post_id['post_by_editor'] ); + if ( isset( $type ) ) { + $request->set_param( 'type', $type ); + } + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 15, count( $data ) ); + $this->check_collection( $response, 'view' ); + } + } + + /** + * Check that we can GET a collection of front and back-end annotations that exist in + * multiple post IDs specified the 'post' collection param. + */ + public function test_get_multiple_post_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', array( + self::$post_id['post_by_editor'], + self::$post_id['post_by_author'], + self::$post_id['post_by_contributor'], + ) ); + if ( isset( $type ) ) { + $request->set_param( 'type', $type ); + } + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 45, count( $data ) ); + $this->check_collection( $response, 'view' ); + } + } + + /** + * Check that we can GET a collection of front and back-end annotations with specific + * post IDs and also with specific parent annotation IDs. + */ + public function test_get_multiple_post_parent_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( + '' => array( + self::$anno_id['editor:in_post_by_editor'], + self::$anno_id['author:in_post_by_author'], + self::$anno_id['contributor:in_post_by_contributor'], + ), + 'annotation' => array( + self::$anno_id['editor:in_post_by_editor'], + self::$anno_id['author:in_post_by_author'], + self::$anno_id['contributor:in_post_by_contributor'], + ), + 'admin_annotation' => array( + self::$anno_id['editor:in_post_backend_by_editor'], + self::$anno_id['author:in_post_backend_by_author'], + self::$anno_id['contributor:in_post_backend_by_contributor'], + ), + ) as $type => $parent_ids ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', array( + self::$post_id['post_by_editor'], + self::$post_id['post_by_author'], + self::$post_id['post_by_contributor'], + ) ); + if ( $type ) { + $request->set_param( 'type', $type ); + } + $request->set_param( 'parent', $parent_ids ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 3, count( $data ) ); + $this->check_collection( $response, 'view' ); + } + } + + /** + * Check that a collection of front and back-end annotations are flat by default. + */ + public function test_get_flat_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', self::$post_id['post_by_editor'] ); + if ( isset( $type ) ) { + $request->set_param( 'type', $type ); + } + $request->set_param( 'parent', array( 0 ) ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 5, count( $data ) ); + + foreach ( $data as $item ) { + $this->assertArrayNotHasKey( 'children', $item ); + } + $this->check_collection( $response, 'view' ); + } + } + + /** + * Check that we can GET a collection of front and back-end annotations in + * hierarchical=flat format. + */ + public function test_get_hierarchical_flat_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', self::$post_id['post_by_editor'] ); + if ( isset( $type ) ) { + $request->set_param( 'type', $type ); + } + $request->set_param( 'parent', array( 0 ) ); + $request->set_param( 'hierarchical', 'flat' ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 15, count( $data ) ); + + foreach ( $data as $item ) { + $this->assertArrayNotHasKey( 'children', $item ); + } + $this->check_collection( $response, 'view' ); + } + } + + /** + * Check that we can GET a collection of front and back-end annotations in + * hierarchical=threaded format. + */ + public function test_get_hierarchical_threaded_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', self::$post_id['post_by_editor'] ); + if ( isset( $type ) ) { + $request->set_param( 'type', $type ); + } + $request->set_param( 'parent', array( 0 ) ); + $request->set_param( 'hierarchical', 'threaded' ); + $request->set_param( 'per_page', 100 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->assertSame( 5, count( $data ) ); + + foreach ( $data as $level0 ) { + $this->assertArrayHasKey( 'children', $level0 ); + $this->assertSame( 1, count( $level0['children'] ) ); + + foreach ( $level0['children'] as $level1 ) { + $this->assertArrayHasKey( 'children', $level1 ); + $this->assertSame( 1, count( $level1['children'] ) ); + + foreach ( $level1['children'] as $level2 ) { + $this->assertArrayHasKey( 'children', $level2 ); + $this->assertSame( 0, count( $level2['children'] ) ); + } + } + } + $this->check_collection( $response, 'view' ); + } + } + + /* + * Single item tests. + */ + + /** + * Check that we get a 404 when we try to GET a non-numeric annotation ID. + */ + public function test_get_item_not_found() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base . '/xyz' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 404, $status ); + $this->assertSame( 'rest_no_route', $data['code'] ); + } + + /** + * Check that we get a 404 when we try to GET a nonexistent annotation ID. + */ + public function test_get_missing_item_not_found() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base . '/123456789' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_missing_annotation', $data['code'] ); + } + + /** + * Check that we can GET a single annotation. + */ + public function test_get_item() { + wp_set_current_user( self::$user_id['author'] ); + + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id['author:in_post_by_author'] + ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_data( $data, 'view', $response->get_links() ); + } + + /** + * Check that we can GET a single annotation in edit context. + */ + public function test_prepare_item() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id['editor:in_post_by_editor'] + ); + $request->set_param( 'context', 'edit' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_data( $data, 'edit', $response->get_links() ); + } + + /** + * Check that a user who can edit the posts of others can GET a single annotation by + * another user. + */ + public function test_get_item_by_other() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id['contributor:in_post_by_contributor'] + ); + $request->set_param( 'context', 'edit' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_data( $data, 'edit', $response->get_links() ); + } + + /** + * Check that we can POST a single front and back-end annotation. + */ + public function test_create_item() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + + $request->set_body_params( array( + 'post' => self::$post_id['post_by_editor'], + 'type' => $type, + 'parent' => 0, + 'status' => 'approve', + 'author' => self::$user_id['editor'], + 'content' => '

content

', + 'via' => 'gutenberg', + 'selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $status ); + $this->check_data( $data, 'edit', $response->get_links() ); + + wp_delete_comment( $data['id'], true ); + } + } + + /** + * Check that we can PUT a single annotation. + */ + public function test_update_item() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id['contributor:in_post_by_contributor'] + ); + $request->set_body_params( array( + 'content' => '

content

', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_data( $data, 'edit', $response->get_links() ); + } + + /** + * Test that a user is unable to PUT invalid fields. + */ + public function test_update_item_with_invalid_fields() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id['editor:in_post_by_editor'] + ); + $request->set_body_params( array( + 'content' => '', // Cannot be empty. + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_comment_content_invalid', $data['code'] ); + } + + /** + * Check that we can DELETE a single annotation. + */ + public function test_delete_item() { + wp_set_current_user( self::$user_id['author'] ); + + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + + $request->set_body_params( array( + 'post' => self::$post_id['post_by_author'], + 'type' => 'admin_annotation', + 'parent' => 0, + 'status' => 'approve', + 'content' => '

content

', + 'via' => null, + 'selector' => null, + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->check_data( $data, 'edit', $response->get_links() ); + + $request = new WP_REST_Request( 'DELETE', self::$rest_ns_base . '/' . $data['id'] ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + } + + /* + * Test user permissions. + */ + + /** + * Check that a post ID is required to list annotations. + */ + public function test_get_all_items_deny_permissions() { + wp_set_current_user( 0 ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_missing_annotation_posts', $data['code'] ); + + foreach ( self::$roles as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_missing_annotation_posts', $data['code'] ); + } + } + + /** + * Check that a valid post ID is required to list annotations. + */ + public function test_invalid_post_deny_permissions() { + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + foreach ( array( 'administrator', 'editor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', 0 ); + if ( isset( $type ) ) { + $request->set_param( 'type', $type ); + } + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_missing_annotations_post', $data['code'] ); + } + } + + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + foreach ( array( 'administrator', 'editor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', 123456789 ); + if ( isset( $type ) ) { + $request->set_param( 'type', $type ); + } + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations_post', $data['code'] ); + } + } + } + + /** + * Check that anonymous users can't GET a single back-end annotation, but they can + * gain read access to any single public front-end annotation. + */ + public function test_anonymous_get_item_allow_deny_permissions() { + wp_set_current_user( 0 ); + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$r}:{$k}_{$_r}" ] + ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'in_post_by' === $k ) { + $this->assertSame( 200, $status ); + $this->check_data( $data, 'view', $response->get_links() ); + } elseif ( 'in_draft_backend_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation_post', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation', $data['code'] ); + } + } + } + } + } + + /** + * Check that subscribers can't GET a single back-end annotation, but they can gain + * read access to any single public front-end annotation. + */ + public function test_subscriber_get_item_allow_deny_permissions() { + wp_set_current_user( self::$user_id['subscriber'] ); + + foreach ( self::$roles as $r ) { + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'GET', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$r}:{$k}_{$_r}" ] + ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'in_post_by' === $k ) { + $this->assertSame( 200, $status ); + $this->check_data( $data, 'view', $response->get_links() ); + } elseif ( 'subscriber' !== $_r && 'in_draft_backend_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation_post', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation', $data['code'] ); + } + } + } + } + } + + /** + * Check that anonymous users can't GET back-end annotations, but they can gain read + * access to public front-end annotations. + */ + public function test_anonymous_get_items_allow_deny_permissions() { + wp_set_current_user( 0 ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', self::$post_id[ "{$k}_{$_r}" ] ); + $request->set_param( 'type', $type ); + $request->set_param( 'status', 'approve' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'post_by' === $k && 'annotation' === $type ) { + $this->assertSame( 200, $status ); + $this->check_collection( $response, 'view' ); + } elseif ( 'draft_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations_post', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations', $data['code'] ); + } + } + } + } + } + + /** + * Check that subscribers can't GET back-end annotations, but they can gain read + * access to public front-end annotations. + */ + public function test_subscriber_get_items_allow_deny_permissions() { + wp_set_current_user( self::$user_id['subscriber'] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( 'annotation', 'admin_annotation' ) as $type ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', self::$post_id[ "{$k}_{$_r}" ] ); + $request->set_param( 'type', $type ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'post_by' === $k && 'annotation' === $type ) { + $this->assertSame( 200, $status ); + $this->check_collection( $response, 'view' ); + } elseif ( 'subscriber' !== $_r && 'draft_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations_post', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations', $data['code'] ); + } + } + } + } + } + + /** + * Check that anonymous users are unable to PUT an annotation. + */ + public function test_anonymous_update_item_deny_permissions() { + wp_set_current_user( 0 ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$_r}:${k}_${_r}" ] + ); + $request->set_param( 'content', '

content

' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'in_draft_backend_by' === $k ) { + // see: . + $this->assertTrue( in_array( $status, array( 401, 403 ), true ) ); + $this->assertSame( 'rest_cannot_read_annotation_post', $data['code'] ); + } else { + // see: . + $this->assertTrue( in_array( $status, array( 401, 403 ), true ) ); + $this->assertSame( 'rest_cannot_update_annotation', $data['code'] ); + } + } + } + } + + /** + * Check that subscribers are unable to PUT an annotation. + */ + public function test_subscribers_update_item_deny_permissions() { + foreach ( array( 'subscriber' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "{$r}:${k}_${_r}" ] + ); + $request->set_param( 'content', '

content

' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( $r !== $_r && 'in_draft_backend_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation_post', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_update_annotation', $data['code'] ); + } + } + } + } + } + + /** + * Check that authors and contributors can't GET back-end annotations in posts + * authored by others. + */ + public function test_author_contributor_get_others_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $_r === $r ) { + continue; // Skip their own. + } + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', self::$post_id[ "post_by_{$_r}" ] ); + $request->set_param( 'type', 'admin_annotation' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations', $data['code'] ); + } + } + } + + /** + * Check that authors and contributors can't GET back-end annotations for an array of + * post IDs, when any single post ID is owned by others. + */ + public function test_author_contributor_get_items_by_post_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip their own role. + } + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'post', array( + self::$post_id[ "{$k}_{$r}" ], + self::$post_id[ "{$k}_{$_r}" ], + ) ); + $request->set_param( 'type', 'admin_annotation' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( $r !== $_r && 'draft_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations_post', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotations', $data['code'] ); + } + } + } + } + } + + /** + * Check that admins and editors are able to create an annotation with any status. + */ + public function test_admin_editor_create_item_any_status_allow_permissions() { + foreach ( array( 'administrator', 'editor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip others. + } + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + foreach ( array( '1', 'approved', 'approve', 'archive', '0', 'hold', 'spam', 'trash' ) as $status ) { + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + + if ( isset( $type ) ) { + $_type = compact( 'type' ); + } else { + $_type = array(); + } + $request->set_body_params( $_type + array( + 'post' => self::$post_id[ "post_by_{$_r}" ], + 'status' => $status, + 'author' => self::$user_id[ $r ], + 'content' => '

content

', + 'via' => 'gutenberg', + 'selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $status ); + $this->check_data( $data, 'edit', $response->get_links() ); + + wp_delete_comment( $data['id'], true ); + } + } + } + } + } + + /** + * Check that authors and contributors are able to create an annotation with an + * unrestricted status. + */ + public function test_author_contributor_create_item_unrestricted_status_allow_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip others. + } + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + foreach ( array( '1', 'approved', 'approve', 'archive' ) as $status ) { + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + + if ( isset( $type ) ) { + $_type = compact( 'type' ); + } else { + $_type = array(); + } + $request->set_body_params( $_type + array( + 'post' => self::$post_id[ "post_by_{$_r}" ], + 'status' => $status, + 'content' => '

content

', + 'via' => 'gutenberg', + 'selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $status ); + $context = current_user_can( 'edit_annotation', $data['id'] ) ? 'edit' : 'view'; + $this->check_data( $data, $context, $response->get_links() ); + + wp_delete_comment( $data['id'], true ); + } + } + } + } + } + + /** + * Check that authors and contributors are unable to create an annotation with a + * restricted status. + */ + public function test_author_contributor_create_item_restricted_status_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip others. + } + foreach ( array( null, 'annotation', 'admin_annotation' ) as $type ) { + foreach ( array( '0', 'hold', 'spam', 'trash' ) as $status ) { + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + + if ( isset( $type ) ) { + $_type = compact( 'type' ); + } else { + $_type = array(); + } + $request->set_body_params( $_type + array( + 'post' => self::$post_id[ "post_by_{$_r}" ], + 'status' => $status, + 'content' => '

content

', + 'via' => 'gutenberg', + 'selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_forbidden_annotation_param_status', $data['code'] ); + } + } + } + } + } + + /** + * Test that authors and contributors are unable to PUT annotations in others' posts. + */ + public function test_author_contributor_update_item_in_others_post_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip their own. + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $request->set_param( 'content', '

content

' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( $r !== $_r && 'in_draft_backend_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation_post', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_update_annotation', $data['code'] ); + } + } + } + } + } + + /** + * Test that authors and contributors are unable to DELETE annotations in others' + * posts. + */ + public function test_author_contributor_delete_item_in_others_post_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r === $_r ) { + continue; // Skip their own. + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'DELETE', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'in_draft_backend_by' !== $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_delete_annotation', $data['code'] ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation_post', $data['code'] ); + } + } + } + } + } + + /** + * Test that authors and contributors are able to PUT annotations in their own posts. + * + * Exception: A contributor can't edit a public front-end annotation in a published + * post. i.e., Once their post has been published they're treated like any other + * front-end annotator. + */ + public function test_author_contributor_update_item_in_own_allow_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip others. + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'PUT', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $request->set_body_params( array( + 'content' => '

content

', + ) ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'contributor' === $r && 'in_post_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_update_annotation', $data['code'] ); + } else { + $this->assertSame( 200, $status ); + $this->check_data( $data, 'edit', $response->get_links() ); + } + } + } + } + } + + /** + * Test that authors and contributors are able to DELETE their own annotations in + * their own posts. + * + * Exception: A contributor can't delete a public front-end annotation in a published + * post. i.e., Once their post has been published they're treated like any other + * front-end annotator. + */ + public function test_author_contributor_delete_item_in_own_allow_deny_permissions() { + foreach ( array( 'author', 'contributor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + foreach ( self::$roles as $_r ) { + if ( $r !== $_r ) { + continue; // Skip others. + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $request = new WP_REST_Request( + 'DELETE', + self::$rest_ns_base . + '/' . self::$anno_id[ "${r}:{$k}_{$_r}" ] + ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'contributor' === $r && 'in_post_by' === $k ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_delete_annotation', $data['code'] ); + } else { + $this->assertSame( 200, $status ); + } + } + } + } + } + + /** + * Test comment collection data. + * + * @param WP_REST_Response $response REST API response. + * @param string $context Exepcted response context. + */ + protected function check_collection( $response, $context ) { + $this->assertNotInstanceOf( 'WP_Error', $response ); + + $response = rest_ensure_response( $response ); + $this->assertEquals( 200, $response->get_status() ); + + $headers = $response->get_headers(); + $this->assertArrayHasKey( 'X-WP-Total', $headers ); + $this->assertArrayHasKey( 'X-WP-TotalPages', $headers ); + + foreach ( $response->get_data() as $data ) { + $this->check_data( $data, $context, $data['_links'] ); + } + } + + /** + * Test comment data. + * + * @param array $data Response data. + * @param string $context Response context. + * @param array $links Response links. + */ + protected function check_data( $data, $context, $links ) { + $this->assertSame( true, ! empty( $data['id'] ) ); + $comment = get_comment( $data['id'] ); + + $this->assertSame( (int) $comment->comment_ID, $data['id'] ); + $this->assertSame( (int) $comment->comment_post_ID, $data['post'] ); + $this->assertSame( (int) $comment->comment_parent, $data['parent'] ); + $this->assertSame( (int) $comment->user_id, $data['author'] ); + $this->assertSame( $comment->comment_author, $data['author_name'] ); + $this->assertSame( $comment->comment_author_url, $data['author_url'] ); + $this->assertSame( wpautop( $comment->comment_content ), $data['content']['rendered'] ); + + // phpcs:ignore PHPCompatibility.PHP.RemovedExtensions.mysql_DeprecatedRemoved — function provided by core. + $this->assertSame( mysql_to_rfc3339( $comment->comment_date ), $data['date'] ); // @codingStandardsIgnoreLine + $this->assertSame( mysql_to_rfc3339( $comment->comment_date_gmt ), $data['date_gmt'] ); // @codingStandardsIgnoreLine + + $this->assertSame( get_comment_link( $comment ), $data['link'] ); + $this->assertArrayHasKey( 'author_avatar_urls', $data ); + + $this->assertSame( get_comment_meta( $comment->comment_ID, '_via', true ), $data['via'] ); + $this->assertSame( get_comment_meta( $comment->comment_ID, '_selector', true ), $data['selector'] ); + + $this->assertSame( rest_url( '/wp/v2/posts/' . $data['post'] ), $links['up'][0]['href'] ); + $this->assertSame( rest_url( '/wp/v2/users/' . $data['author'] ), $links['author'][0]['href'] ); + $this->assertSame( rest_url( self::$rest_ns_base . '/' . $data['id'] ), $links['self'][0]['href'] ); + $this->assertSame( rest_url( self::$rest_ns_base ), $links['collection'][0]['href'] ); + + if ( 'edit' === $context ) { + $this->assertSame( $comment->comment_author_email, $data['author_email'] ); + $this->assertSame( $comment->comment_author_IP, $data['author_ip'] ); + $this->assertSame( $comment->comment_agent, $data['author_user_agent'] ); + $this->assertSame( $comment->comment_content, $data['content']['raw'] ); + } + + if ( 'edit' !== $context ) { + $this->assertArrayNotHasKey( 'author_email', $data ); + $this->assertArrayNotHasKey( 'author_ip', $data ); + $this->assertArrayNotHasKey( 'author_user_agent', $data ); + $this->assertArrayNotHasKey( 'raw', $data['content'] ); + } + } +}