diff --git a/lib/class-wp-annotation-utils.php b/lib/class-wp-annotation-utils.php new file mode 100644 index 00000000000000..940734a9bbc85a --- /dev/null +++ b/lib/class-wp-annotation-utils.php @@ -0,0 +1,2111 @@ + false, + 'delete_with_user' => false, + 'hierarchical' => true, + 'supports' => array( + 'author', + 'editor', + 'custom-fields', + ), + 'show_in_rest' => true, + 'rest_base' => 'annotations', + 'rest_controller_class' => 'WP_REST_Annotations_Controller', + + 'map_meta_cap' => false, // See filter below. + 'capabilities' => array( + // Meta-caps. + 'create_post' => 'create_annotation', + 'read_post' => 'read_annotation', + 'edit_post' => 'edit_annotation', + 'delete_post' => 'delete_annotation', + + // Primitive pseudo-caps. + 'create_posts' => 'create_annotations', + + // Primitive caps used outside map_meta_cap(). + 'edit_posts' => 'edit_annotations', + 'edit_others_posts' => 'edit_others_annotations', + 'publish_posts' => 'publish_annotations', + 'read_private_posts' => 'read_private_annotations', + + // Primitive caps used inside map_meta_cap(). + 'read' => 'read_annotations', + 'delete_posts' => 'delete_annotations', + 'delete_private_posts' => 'delete_private_annotations', + 'delete_published_posts' => 'delete_published_annotations', + 'delete_others_posts' => 'delete_others_annotations', + 'edit_private_posts' => 'edit_private_annotations', + 'edit_published_posts' => 'edit_published_annotations', + ), + ) ); + + add_filter( 'map_meta_cap', array( __CLASS__, 'on_map_meta_cap' ), 10, 4 ); + add_action( 'delete_post', array( __CLASS__, 'on_delete_post' ), 10, 1 ); + } + + /** + * Maps annotation meta-caps and pseudo-caps. + * + * This handles annotation capabilities defined by the post type. Note that an + * annotation can target either '' (front-end) or 'admin' (back-end). So these + * permission checks must take both targets into careful consideration. + * + * @since [version] + * + * @param array $caps Required capabilities. + * @param string $cap Capability to check/map. + * @param int $user_id User ID (empty if not logged in). + * @param array $args Arguments to {@see map_meta_cap()}. + * + * @return array Array of required capabilities. + * + * @see WP_Annotation_Utils::register_post_type() + */ + public static function on_map_meta_cap( $caps, $cap, $user_id, $args ) { + switch ( $cap ) { + /* + * Requires $args[0], $args[1] with parent post ID and target. + */ + case 'create_annotation': // This is a custom annotation meta-cap. + $caps = array_diff( $caps, array( $cap ) ); + + if ( ! isset( $args[0], $args[1] ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $user_id = absint( $user_id ); + $parent_post_id = absint( $args[0] ); + $parent_post_target = (string) $args[1]; + $parent_post_info = self::get_parent_post_info( $parent_post_id ); + + if ( ! $parent_post_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $parent_post = $parent_post_info['parent_post']; + $parent_post_type = $parent_post_info['parent_post_type']; + $parent_post_status = $parent_post_info['parent_post_status']; + + /* + * Cannot annotate if parent post is in the trash. + */ + if ( 'trash' === $parent_post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Check that annotation parent post target is valid. + */ + if ( ! self::is_valid_parent_post_target( $parent_post_target ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If creating a front-end annotation on a parent post having a public or private status, + * and the user can read & comment on the parent post, then they can create front-end + * annotations. Note: Callers should also check if {@see post_password_required()} before + * allowing access. + */ + if ( '' === $parent_post_target // Front-end target. + && ( $parent_post_status->public || $parent_post_status->private ) + && post_type_supports( $parent_post_type->name, 'comments' ) && comments_open( $parent_post ) + && ( $user_id || ! get_option( 'comment_registration' ) ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_post, $user_id, $parent_post->ID ) ); + + if ( $parent_post_status->public && ! get_option( 'comment_registration' ) && array( 'read' ) === $caps ) { + // If parent post is public, comment registration is off, and parent post only requires 'read' access. + $caps = array(); // Allow anonymous public access. + } + return $caps; + } + + /* + * If creating a back-end annotation in a parent post authored by this user, and the user + * can edit a parent post type, then they can create back-end annotations. For example, a + * contributor can create back-end annotations in any parent post they authored, even if + * they can no longer *edit* the parent post itself; e.g., after it's approved/published. + */ + if ( '' !== $parent_post_target && $user_id && (int) $parent_post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_posts, $user_id ) ); + } + + /* + * Otherwise, requires the ability to edit the annotation's parent post. + */ + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_post, $user_id, $parent_post->ID ) ); + + /* + * Requires $args[0] with the annotation's post 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 ); + $post_id = absint( $args[0] ); + $post_info = self::get_post_info( $post_id, true ); + + if ( ! $post_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $post = $post_info['post']; + $post_type = $post_info['post_type']; + $post_status = $post_info['post_status']; + $parent_post = $post_info['parent_post']; + $parent_post_type = $post_info['parent_post_type']; + $parent_post_status = $post_info['parent_post_status']; + $parent_post_target = $post_info['parent_post_target']; + + /* + * Cannot read annotation if parent post is in the trash. + */ + if ( 'trash' === $parent_post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Check that annotation parent post target is valid. + */ + if ( ! self::is_valid_parent_post_target( $parent_post_target ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If it's an unpublished front-end annotation in a public or private parent post, and the + * user can read the parent post and moderate comments, they can read the annotation. Note: + * Callers should also check if {@see post_password_required()}. + */ + if ( '' === $parent_post_target // Front-end target. + && ! $post_status->public && ! $post_status->private + && ( $parent_post_status->public || $parent_post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_post, $user_id, $parent_post->ID ) ); + $caps[] = 'moderate_comments'; + + return $caps; + } + + /* + * If it's a front-end annotation having a public or private status; in a public or private + * parent post, and the user can read the parent post, then they can read the public and + * perhaps private annotation. Note: Callers should also check if {@see + * post_password_required()} before allowing access. + */ + if ( '' === $parent_post_target // Front-end target. + && ( $post_status->public || $post_status->private ) + && ( $parent_post_status->public || $parent_post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_post, $user_id, $parent_post->ID ) ); + + if ( $post_status->private && ( ! $user_id || (int) $post->post_author !== $user_id ) ) { + // Also requires the ability to read private posts, of the parent post type. + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_private_posts, $user_id ) ); + } + + if ( $post_status->public && $parent_post_status->public && array( 'read' ) === $caps ) { + // If both posts are public and the parent post only requires 'read' access. + $caps = array(); // Allow anonymous public access. + } + return $caps; + } + + /* + * If it's a back-end annotation in a parent post authored by this user, and the user can + * edit a parent post type, then they can read the back-end annotation. For example, a + * contributor can read back-end annotations in any parent post they authored, even if they + * can no longer *edit* the parent post itself; e.g., after it's approved/published. + */ + if ( '' !== $parent_post_target && $user_id && (int) $parent_post->post_author === $user_id ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_posts, $user_id ) ); + + if ( $post_status->private && ( ! $user_id || (int) $post->post_author !== $user_id ) ) { + // Also requires the ability to read private posts, of the parent post type. + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_private_posts, $user_id ) ); + } + return $caps; + } + + /* + * Otherwise, requires the ability to edit the annotation's parent post. + */ + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_post, $user_id, $parent_post->ID ) ); + + if ( $post_status->private && ( ! $user_id || (int) $post->post_author !== $user_id ) ) { + // Also requires the ability to read private posts, of the parent post type. + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_private_posts, $user_id ) ); + } + return $caps; + + /* + * Optionally supports $args[0], $args[1] with parent post ID and target. + */ + 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 ); + $parent_post_id = absint( $args[0] ); + $parent_post_target = (string) $args[1]; + $parent_post_info = self::get_parent_post_info( $parent_post_id ); + + if ( ! $parent_post_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $parent_post = $parent_post_info['parent_post']; + $parent_post_type = $parent_post_info['parent_post_type']; + $parent_post_status = $parent_post_info['parent_post_status']; + + /* + * Cannot read annotations if parent post is in the trash. + */ + if ( 'trash' === $parent_post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Check that annotation parent post target is valid. + */ + if ( ! self::is_valid_parent_post_target( $parent_post_target ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If reading front-end annotations in a public or private parent post, and the user can + * read the parent post, then they can read annotations. Note: Callers should also check if + * {@see post_password_required()} before allowing access. + */ + if ( '' === $parent_post_target // Front-end target. + && ( $parent_post_status->public || $parent_post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_post, $user_id, $parent_post->ID ) ); + + if ( $parent_post_status->public && array( 'read' ) === $caps ) { + // If parent post is public and only requires 'read' access. + $caps = array(); // Allow anonymous public access. + } + return $caps; + } + + /* + * If reading back-end annotations in a parent post authored by this user, and the user can + * edit a parent post type, then they can read back-end annotations. For example, a + * contributor can read back-end annotations in any parent post they authored, even if they + * can no longer *edit* the parent post itself; e.g., after it's approved/published. + */ + if ( '' !== $parent_post_target && $user_id && (int) $parent_post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_posts, $user_id ) ); + } + + /* + * Otherwise, requires the ability to edit the annotation's parent post. + */ + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_post, $user_id, $parent_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 ); + $post_id = absint( $args[0] ); + $post_info = self::get_post_info( $post_id, true ); + + if ( ! $post_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $post = $post_info['post']; + $post_type = $post_info['post_type']; + $post_status = $post_info['post_status']; + $parent_post = $post_info['parent_post']; + $parent_post_type = $post_info['parent_post_type']; + $parent_post_status = $post_info['parent_post_status']; + $parent_post_target = $post_info['parent_post_target']; + + /* + * Cannot edit annotation if parent post is in the trash. + */ + if ( 'trash' === $parent_post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Check that annotation parent post target is valid. + */ + if ( ! self::is_valid_parent_post_target( $parent_post_target ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If it's a front-end annotation (with any status) in a public or private parent post, and + * the user can read the parent post and moderate comments, they can edit annotation. Note: + * Callers should also check if {@see post_password_required()}. + */ + if ( '' === $parent_post_target // Front-end target. + && ( $parent_post_status->public || $parent_post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_post, $user_id, $parent_post->ID ) ); + + /* + * If the front-end annotation was authored by the specific user, and the user can edit the + * parent post, they can edit the annotation. + */ + if ( $user_id && (int) $post->post_author === $user_id + && user_can( $user_id, $parent_post_type->cap->edit_post, $parent_post->ID ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_post, $user_id, $parent_post->ID ) ); + } else { + $caps[] = 'moderate_comments'; + } + + return $caps; + } + + /* + * If it's an annotation authored by this user. + */ + if ( $user_id && (int) $post->post_author === $user_id ) { + /* + * If it's a back-end annotation, and it's also in a parent post authored by this user, and + * the user can edit a parent post type, then they can edit their own back-end annotation. + * For example, a contributor can edit their own back-end annotation in any parent post they + * authored, even if they can no longer *edit* the parent post itself; e.g., after it's + * approved/published. + */ + if ( '' !== $parent_post_target && (int) $parent_post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_posts, $user_id ) ); + } + + /* + * Otherwise, requires the ability to edit the annotation's parent post. + */ + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_post, $user_id, $parent_post->ID ) ); + } + + /* + * Otherwise, requires the ability to edit the annotation's parent post. Also requires an + * administrator with the ability to edit others posts, of the parent post type. + */ + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_post, $user_id, $parent_post->ID ) ); + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_others_posts, $user_id ) ); + + if ( 'trash' === $post->post_status ) { + // When in the trash, test the would-be restoration status. + $wp_trash_meta_status = get_post_meta( $post->ID, '_wp_trash_meta_status', true ); + $wp_trash_meta_status = $wp_trash_meta_status ? $wp_trash_meta_status : 'draft'; + + $post_status = get_post_status_object( $wp_trash_meta_status ); + $post_status = $post_status ? $post_status : get_post_status_object( 'draft' ); + } + if ( $post_status->public || 'future' === $post_status->name ) { + // Also requires the ability to edit published posts, of the parent post type. + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_published_posts, $user_id ) ); + } + if ( $post_status->private ) { + // Also requires the ability to edit private posts, of the parent post type. + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->edit_private_posts, $user_id ) ); + } + return $caps; + + /* + * 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 ); + $post_id = absint( $args[0] ); + $post_info = self::get_post_info( $post_id, true ); + + if ( ! $post_info ) { + $caps[] = 'do_not_allow'; + return $caps; + } + $post = $post_info['post']; + $post_type = $post_info['post_type']; + $post_status = $post_info['post_status']; + $parent_post = $post_info['parent_post']; + $parent_post_type = $post_info['parent_post_type']; + $parent_post_status = $post_info['parent_post_status']; + $parent_post_target = $post_info['parent_post_target']; + + /* + * Cannot delete annotation if parent post is in the trash. + */ + if ( 'trash' === $parent_post_status->name ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * Check that annotation parent post target is valid. + */ + if ( ! self::is_valid_parent_post_target( $parent_post_target ) ) { + $caps[] = 'do_not_allow'; + return $caps; + } + + /* + * If it's a front-end annotation (with any status) in a public or private parent post, and + * the user can read the parent post and moderate comments, they can delete annotation. + * Note: Callers should also check if {@see post_password_required()}. + */ + if ( '' === $parent_post_target // Front-end target. + && ( $parent_post_status->public || $parent_post_status->private ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->read_post, $user_id, $parent_post->ID ) ); + + /* + * If the front-end annotation was authored by the specific user, and the user can delete the + * parent post, they can delete the annotation. + */ + if ( $user_id && (int) $post->post_author === $user_id + && user_can( $user_id, $parent_post_type->cap->delete_post, $parent_post->ID ) ) { + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->delete_post, $user_id, $parent_post->ID ) ); + } else { + $caps[] = 'moderate_comments'; + } + + return $caps; + } + + /* + * If it's an annotation authored by this user. + */ + if ( $user_id && (int) $post->post_author === $user_id ) { + /* + * If it's a back-end annotation, and it's also in a parent post authored by this user, and + * the user can delete a parent post type, then they can delete their own back-end + * annotation. For example, a contributor can delete their own back-end annotation in any + * parent post they authored, even if they can no longer *delete* the parent post itself; + * e.g., after it's approved/published. + */ + if ( '' !== $parent_post_target && (int) $parent_post->post_author === $user_id ) { + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->delete_posts, $user_id ) ); + } + + /* + * Otherwise, requires the ability to delete the annotation's parent post. + */ + return array_merge( $caps, map_meta_cap( $parent_post_type->cap->delete_post, $user_id, $parent_post->ID ) ); + } + + /* + * Otherwise, requires the ability to delete the annotation's parent post. Also requires an + * administrator with the ability to delete others posts, of the parent post type. + */ + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->delete_post, $user_id, $parent_post->ID ) ); + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->delete_others_posts, $user_id ) ); + + if ( 'trash' === $post->post_status ) { + // When in the trash, test the would-be restoration status. + $wp_trash_meta_status = get_post_meta( $post->ID, '_wp_trash_meta_status', true ); + $wp_trash_meta_status = $wp_trash_meta_status ? $wp_trash_meta_status : 'draft'; + + $post_status = get_post_status_object( $wp_trash_meta_status ); + $post_status = $post_status ? $post_status : get_post_status_object( 'draft' ); + } + if ( $post_status->public || 'future' === $post_status->name ) { + // Also requires the ability to delete published posts, of the parent post type. + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->delete_published_posts, $user_id ) ); + } + if ( $post_status->private ) { + // Also requires the ability to delete private posts, of the parent post type. + $caps = array_merge( $caps, map_meta_cap( $parent_post_type->cap->delete_private_posts, $user_id ) ); + } + return $caps; + + /* + * All other pseudo-caps are handled dynamically. + * These are simply mapped to the equivalent *_posts cap. + * Optionally supports $args[0] with an annotation parent post target. + */ + 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] ) ) { + $parent_post_target = (string) $args[0]; + + /* + * Check that annotation parent post target is valid. + */ + if ( ! self::is_valid_parent_post_target( $parent_post_target ) ) { + $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 ( '' === $parent_post_target ) { + $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 annotation (and parent post) info. + * + * @since [version] + * + * @param WP_Post|int $post Post (i.e., annotation) object or ID. + * @param bool $resolve_revision Resolve revision? Default false. If true, + * returns the revision's parent info. + * + * @return array Post (i.e., annotation) info, including + * parent info. Empty array on failure. + */ + public static function get_post_info( $post, $resolve_revision = false ) { + /* + * Collect post info. + */ + + if ( ! $post ) { + return array(); + } + + $post = get_post( $post ); + + if ( ! $post ) { + return array(); + } + + if ( 'revision' == $post->post_type && $resolve_revision ) { + if ( ! $post->post_parent ) { + return array(); + } + + $post = get_post( $post->post_parent ); + + if ( ! $post ) { + return array(); + } + } + + if ( $post->post_type !== self::$post_type ) { + return array(); // Not an annotation. + } + + $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(); + } + + /* + * Collect parent post info. + */ + + $parent_post_id = absint( get_post_meta( $post->ID, '_parent_post', true ) ); + $parent_post_info = self::get_parent_post_info( $parent_post_id ); + + if ( ! $parent_post_info ) { + return array(); + } + + $parent_post = $parent_post_info['parent_post']; + $parent_post_type = $parent_post_info['parent_post_type']; + $parent_post_status = $parent_post_info['parent_post_status']; + $parent_post_target = (string) get_post_meta( $post->ID, '_parent_post_target', true ); + + /* + * Return all info. + */ + + return compact( + 'post', + 'post_type', + 'post_status', + 'parent_post', + 'parent_post_type', + 'parent_post_status', + 'parent_post_target' + ); + } + + /** + * Gets an array of parent post (non-annotation) info. + * + * @since [version] + * + * @param WP_Post|int $parent_post Parent post (non-annotation) object or ID. + * + * @return array Parent post info. Empty array on failure. + */ + public static function get_parent_post_info( $parent_post ) { + if ( ! $parent_post ) { + return array(); + } + + $parent_post = get_post( $parent_post ); + + if ( ! $parent_post ) { + return array(); + } + + if ( 'revision' === $parent_post->post_type ) { + return array(); // Must not be a revision. + } + + if ( $parent_post->post_type === self::$post_type ) { + return array(); // Must not be an annotation. + } + + $parent_post_type = get_post_type_object( $parent_post->post_type ); + $parent_post_status = get_post_status_object( get_post_status( $parent_post ) ); + + if ( ! $parent_post_type || ! $parent_post_status ) { + return array(); + } + + return compact( + 'parent_post', + 'parent_post_type', + 'parent_post_status' + ); + } + + /** + * Delete a post's annotations whenever its permanently deleted from the database. + * + * @since [version] + * + * @param int $post_id Post ID being deleted. + */ + public static function on_delete_post( $post_id ) { + if ( ! $post_id ) { + return; + } + + $post = get_post( $post_id ); + + if ( ! $post || $post->post_type === self::$post_type ) { + return; // Only dealing with non-annotation types. + } + + $query = new WP_Query(); + $annotation_ids = $query->query( array( + 'fields' => 'ids', + 'post_type' => self::$post_type, + 'post_status' => array_keys( get_post_stati() ), + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'suppress_filters' => true, + 'posts_per_page' => -1, + 'meta_query' => array( + 'key' => '_parent_post', + 'value' => $post->ID, + ), + ) ); + + foreach ( $annotation_ids as $annotation_id ) { + wp_delete_post( $annotation_id, true ); // Force deletion. + } + } + + /** + * Registers additional REST API fields. + * + * @since [version] + */ + public static function register_additional_rest_fields() { + register_rest_field( self::$post_type, 'parent_post', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'required' => true, + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Parent post ID.', 'gutenberg' ), + ), + ) ); + + register_rest_field( self::$post_type, 'parent_post_target', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'enum' => self::$parent_post_targets, + 'description' => __( 'Parent post target.', 'gutenberg' ), + 'default' => '', + ), + ) ); + + register_rest_field( self::$post_type, 'parent_post_password', array( + 'schema' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Parent post password.', 'gutenberg' ), + ), + ) ); + + register_rest_field( self::$post_type, 'via', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'W3C annotation client identifier.', 'gutenberg' ), + 'default' => '', + ), + ) ); + + register_rest_field( self::$post_type, 'author_meta', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'schema' => array( + 'readonly' => true, + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Author metadata.', 'gutenberg' ), + + 'properties' => array( + 'display_name' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Display name.', 'gutenberg' ), + ), + 'image_url' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Square avatar image URL.', 'gutenberg' ), + ), + ), + ), + ) ); + + register_rest_field( self::$post_type, 'creator', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => sprintf( + // translators: %s is a regular expression pattern to clarify data requirements. + __( 'Creator (plugin, service, other). Requires a non-numeric slug: %s', 'gutenberg' ), + '^[a-z][a-z0-9_-]*[a-z0-9]$' + ), + ), + ) ); + + register_rest_field( self::$post_type, 'creator_meta', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Creator metadata.', 'gutenberg' ), + + 'properties' => array( + 'display_name' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Display name.', 'gutenberg' ), + ), + 'image_url' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Square avatar image URL.', 'gutenberg' ), + ), + ), + ), + ) ); + + register_rest_field( self::$post_type, 'selector', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_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' => self::$selectors, + 'description' => __( 'Type of selector.', 'gutenberg' ), + ), + 'additionalProperties' => true, + ), + 'default' => array(), + ), + ) ); + + register_rest_field( self::$post_type, 'substatus', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'update_callback' => array( __CLASS__, 'on_update_additional_rest_field' ), + 'schema' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'enum' => self::$substatuses, + 'description' => __( 'Current substatus.', 'gutenberg' ), + 'default' => '', + ), + ) ); + + register_rest_field( self::$post_type, 'last_substatus_time', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'schema' => array( + 'readonly' => true, + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Last substatus change (GMT/UTC timestamp).', 'gutenberg' ), + ), + ) ); + + register_rest_field( self::$post_type, 'substatus_history', array( + 'get_callback' => array( __CLASS__, 'on_get_additional_rest_field' ), + 'schema' => array( + 'readonly' => true, + 'type' => 'array', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Substatus history.', 'gutenberg' ), + + 'items' => array( + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'History entry.', 'gutenberg' ), + + 'properties' => array( + 'identity' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Identity (user or creator).', 'gutenberg' ), + ), + 'identity_meta' => array( + 'type' => 'object', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Identity metadata.', 'gutenberg' ), + + 'properties' => array( + 'display_name' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Display name.', 'gutenberg' ), + ), + 'image_url' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'Square avatar image URL.', 'gutenberg' ), + ), + ), + ), + 'time' => array( + 'type' => 'integer', + 'context' => array( 'view', 'edit' ), + 'description' => __( 'When substatus changed (GMT/UTC timestamp).', 'gutenberg' ), + ), + 'old' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'enum' => self::$substatuses, + 'description' => __( 'Old substatus.', 'gutenberg' ), + ), + 'new' => array( + 'type' => 'string', + 'context' => array( 'view', 'edit' ), + 'enum' => self::$substatuses, + 'description' => __( 'New substatus.', 'gutenberg' ), + ), + ), + ), + ), + ) ); + } + + /** + * Add additional REST API filters for annotations. + * + * @since [version] + */ + public static function add_rest_related_filters() { + add_filter( 'rest_' . self::$post_type . '_collection_params', array( __CLASS__, 'on_rest_collection_params' ) ); + add_filter( 'rest_' . self::$post_type . '_query', array( __CLASS__, 'on_rest_collection_query' ), 10, 2 ); + + // Note: Akisment could use this same filter to spam-check front-end annotations. + add_filter( 'rest_pre_insert_' . self::$post_type, array( __CLASS__, 'on_rest_pre_insert' ), 10, 2 ); + } + + /** + * Adds additional REST API collection parameters. + * + * @since [version] + * + * @param array $params JSON Schema-formatted collection parameters. + * + * @return array Filtered JSON Schema-formatted collection parameters. + * + * @see WP_Annotation_Utils::add_rest_related_filters() + */ + public static function on_rest_collection_params( $params ) { + $params['hierarchical'] = array( + 'type' => 'string', + 'description' => __( 'Results in hierarchical format?', 'gutenberg' ), + 'enum' => array( '', 'flat', 'threaded' ), + ); + + $params['parent_post'] = array( + 'required' => true, + 'type' => 'array', + 'description' => __( 'Limit result set to those with one or more parent post IDs.', 'gutenberg' ), + 'items' => array( + 'type' => 'integer', + ), + ); + + $params['parent_post_target'] = array( + 'type' => 'array', + 'description' => __( 'Limit result set to those with a specific parent post target.', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + 'enum' => self::$parent_post_targets, + ), + 'default' => array( '' ), + ); + + $params['parent_post_password'] = array( + 'type' => 'array', + 'description' => __( 'Password(s) for parent post ID in request.', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + ), + ); + + $params['via'] = array( + 'type' => 'array', + 'description' => __( 'Limit result set to those generated by one or more clients.', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + ), + 'validate_callback' => array( __CLASS__, 'validate_via_collection_param' ), + ); + + $params['creator'] = array( + 'type' => 'array', + 'description' => __( 'Limit result set to those by one or more creators.', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + ), + 'validate_callback' => array( __CLASS__, 'validate_creator_collection_param' ), + ); + + $params['substatus'] = array( + 'type' => 'array', + 'description' => __( 'Limit result set to those assigned one or more substatuses.', 'gutenberg' ), + 'items' => array( + 'type' => 'string', + 'enum' => self::$substatuses, + ), + 'default' => array( '' ), + ); + + return $params; + } + + /** + * 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 ( ! self::is_valid_client( $via ) ) { + return new WP_Error( 'rest_annotation_invalid_param_via', __( 'Invalid client identifier.', 'gutenberg' ) ); + } + } + + return true; + } + + /** + * Validates the 'creator' collection parameter. + * + * @since [version] + * + * @param string|array $creators Annotation creator(s). + * + * @return WP_Error|bool True if valid, {@see WP_Error} otherwise. + */ + public function validate_creator_collection_param( $creators ) { + if ( ! is_array( $creators ) ) { + $creators = preg_split( '/[\s,]+/', (string) $creators ); + } + + if ( ! wp_is_numeric_array( $creators ) ) { + return new WP_Error( 'rest_annotation_invalid_array_param_creator', __( 'Invalid creator(s).', 'gutenberg' ) ); + } + + foreach ( $creators as $creator ) { + if ( ! self::is_valid_creator( $creator ) ) { + return new WP_Error( 'rest_annotation_invalid_param_creator', __( 'Invalid creator.', 'gutenberg' ) ); + } + } + + return true; + } + + /** + * 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_rest_collection_params() + */ + public static function on_rest_collection_query( $query_vars, $request ) { + /* + * A hierarchical request sets post_parent to 0 by default. + */ + if ( $request['hierarchical'] && ! $request['parent'] ) { + $query_vars['post_parent'] = 0; + } + + /* + * Build meta queries. + */ + $meta_queries = array(); + + $parent_post_ids = $request['parent_post']; + $parent_post_ids = $parent_post_ids ? (array) $parent_post_ids : array(); + $parent_post_ids = array_map( 'absint', $parent_post_ids ); + + if ( $parent_post_ids ) { + $meta_queries[] = array( + 'key' => '_parent_post', + 'value' => $parent_post_ids, + 'compare' => 'IN', + ); + } + + $parent_post_targets = $request['parent_post_target']; + $parent_post_targets = isset( $parent_post_targets ) ? (array) $parent_post_targets : array(); + $parent_post_targets = array_map( 'strval', $parent_post_targets ); + + if ( $parent_post_targets ) { + $meta_queries[] = array( + 'key' => '_parent_post_target', + 'value' => $parent_post_targets, + 'compare' => 'IN', + ); + } + + $vias = $request['via']; + $vias = $vias ? (array) $vias : array(); + $vias = array_map( 'strval', $vias ); + + if ( $vias ) { + $meta_queries[] = array( + 'key' => '_via', + 'value' => $vias, + 'compare' => 'IN', + ); + } + + $creators = $request['creator']; + $creators = $creators ? (array) $creators : array(); + $creators = array_map( 'strval', $creators ); + + if ( $creators ) { + $meta_queries[] = array( + 'key' => '_creator', + 'value' => $creators, + 'compare' => 'IN', + ); + } + + $substatuses = $request['substatus']; + $substatuses = $substatuses ? (array) $substatuses : array(); + $substatuses = array_map( 'strval', $substatuses ); + + if ( $substatuses ) { + $meta_queries[] = array( + 'key' => '_substatus', + 'value' => $substatuses, + '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; + } + + /** + * Gets an additional REST API field value. + * + * @since [version] + * + * @param array|WP_Post $post Post (i.e., an annotation). + * @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_Annotation_Utils::register_additional_rest_fields() + */ + public static function on_get_additional_rest_field( $post, $field, $request ) { + /* + * There is some inconsistency (array|WP_Post) in the REST API hooks. So here we are + * double-checking the $post data type before we begin. + */ + if ( is_array( $post ) ) { + if ( ! empty( $post['id'] ) ) { + $post = get_post( $post['id'] ); + } elseif ( ! empty( $post['ID'] ) ) { + $post = get_post( $post['ID'] ); + } + } + + $value = get_post_meta( $post->ID, '_' . $field, true ); + + switch ( $field ) { + case 'parent_post': + return is_numeric( $value ) ? absint( $value ) : 0; + + case 'parent_post_target': + return is_string( $value ) ? $value : ''; + + case 'via': + return is_string( $value ) ? $value : ''; + + case 'author_meta': + $defaults = array( + 'display_name' => '', + 'image_url' => '', + ); + $value = is_array( $value ) ? $value : array(); + return array_merge( $defaults, $value ); + + case 'creator': + return is_string( $value ) ? $value : ''; + + case 'creator_meta': + $defaults = array( + 'display_name' => '', + 'image_url' => '', + ); + $value = is_array( $value ) ? $value : array(); + return array_merge( $defaults, $value ); + + case 'selector': + return is_array( $value ) ? $value : array(); + + case 'substatus': + return is_string( $value ) ? $value : ''; + + case 'last_substatus_time': + return is_numeric( $value ) ? absint( $value ) : 0; + + case 'substatus_history': + return is_array( $value ) ? $value : array(); + } + } + + /** + * Updates an additional REST API field value. + * + * @since [version] + * + * @param string $value New field value. + * @param array|WP_Post $post Post (i.e., an annotation). + * @param string $field Name of the field to update. + * @param WP_Rest_Request $request Full REST API request details. + * + * @return WP_Error|null {@see WP_Error} on failure, null otherwise. + * + * @see WP_Annotation_Utils::register_additional_rest_fields() + */ + public static function on_update_additional_rest_field( $value, $post, $field, $request ) { + /* + * There is some inconsistency (array|WP_Post) in the REST API hooks. So here we are + * double-checking the $post data type before we begin. + */ + if ( is_array( $post ) ) { + if ( ! empty( $post['id'] ) ) { + $post = get_post( $post['id'] ); + } elseif ( ! empty( $post['ID'] ) ) { + $post = get_post( $post['ID'] ); + } + } + + switch ( $field ) { + case 'parent_post': + $value = self::validate_rest_field_parent_post_on_update( $value, $post, $request ); + + if ( is_wp_error( $value ) ) { + return $value; + } + update_post_meta( $post->ID, '_' . $field, $value ); + + break; + + case 'parent_post_target': + $value = self::validate_rest_field_parent_post_target_on_update( $value, $post, $request ); + + if ( is_wp_error( $value ) ) { + return $value; + } + update_post_meta( $post->ID, '_' . $field, $value ); + + break; + + case 'via': + $value = self::validate_rest_field_via_on_update( $value, $post, $request ); + + if ( is_wp_error( $value ) ) { + return $value; + } + update_post_meta( $post->ID, '_' . $field, $value ); + + break; + + case 'creator': + $value = self::validate_rest_field_creator_on_update( $value, $request['creator_meta'], $post, $request ); + + if ( is_wp_error( $value ) ) { + return $value; + } + update_post_meta( $post->ID, '_' . $field, $value['id'] ); + + break; + + case 'creator_meta': + $value = self::validate_rest_field_creator_on_update( $request['creator'], $value, $post, $request ); + + if ( is_wp_error( $value ) ) { + return $value; + } + update_post_meta( $post->ID, '_' . $field, $value['meta'] ); + + break; + + case 'selector': + $value = self::validate_rest_field_selector_on_update( $value, $post, $request ); + + if ( is_wp_error( $value ) ) { + return $value; + } + update_post_meta( $post->ID, '_' . $field, $value ); + + break; + + case 'substatus': + $value = self::validate_rest_field_substatus_on_update( $value, $post, $request ); + $old_value = (string) get_post_meta( $post->ID, '_' . $field, true ); + + if ( is_wp_error( $value ) ) { + return $value; + } + update_post_meta( $post->ID, '_' . $field, $value ); + self::maybe_update_rest_field_substatus_history( $value, $old_value, $post, $request ); + + break; + + default: + return self::rest_field_unexpected_update_error( $field ); + } + } + + /** + * Filters an annotation before REST API create or update. + * + * @since [version] + * + * @param stdClass $prepared Prepared post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @return stdClass|WP_Error Prepared post, {@see WP_Error} otherwise. + * + * @see WP_Annotation_Utils::add_rest_related_filters() + */ + public static function on_rest_pre_insert( $prepared, $request ) { + if ( $request['id'] ) { // Updating. + if ( isset( $prepared->post_content ) && ! $prepared->post_content ) { + return new WP_Error( 'rest_cannot_update_annotation_content_empty', __( 'Content cannot be empty.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + } elseif ( empty( $prepared->post_content ) ) { + return new WP_Error( 'rest_cannot_create_annotation_content_empty', __( 'Content cannot be empty.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + return $prepared; + } + + /** + * Validates a parent post ID on REST API update. + * + * @since [version] + * + * @param string|int $parent_post_id Parent post ID. + * @param WP_Post $post Post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @return int|WP_Error Parent post ID, {@see WP_Error} otherwise. + * + * @see WP_Annotation_Utils::on_update_additional_rest_field() + */ + protected static function validate_rest_field_parent_post_on_update( $parent_post_id, $post, $request ) { + $error = self::rest_field_validation_update_error( 'parent_post' ); + + if ( ! $parent_post_id ) { + return $error; + } elseif ( ! is_numeric( $parent_post_id ) ) { + return $error; + } + + $parent_post_id = absint( $parent_post_id ); + $parent_post = get_post( $parent_post_id ); + + if ( ! $parent_post || $parent_post->post_type === self::$post_type ) { + return $error; // Must be a child of a non-annotation post type. + } + + return $parent_post_id; + } + + /** + * Validates parent post target on REST API update. + * + * @since [version] + * + * @param string $parent_post_target Parent post target. + * @param WP_Post $post Post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @return string|WP_Error Parent post target, + * {@see WP_Error} otherwise. + * + * @see WP_Annotation_Utils::on_update_additional_rest_field() + */ + protected static function validate_rest_field_parent_post_target_on_update( $parent_post_target, $post, $request ) { + if ( ! self::is_valid_parent_post_target( $parent_post_target ) ) { + return self::rest_field_validation_update_error( 'parent_post_target' ); + } + + return $parent_post_target; + } + + /** + * Validates 'via' (W3C annotation client identifier) on REST API update. + * + * @since [version] + * + * @param string $via Annotation client. + * @param WP_Post $post Post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @return string|WP_Error Via (client), {@see WP_Error} otherwise. + * + * @see WP_Annotation_Utils::on_update_additional_rest_field() + * + * @link https://www.w3.org/TR/annotation-model/#rendering-software + */ + protected static function validate_rest_field_via_on_update( $via, $post, $request ) { + if ( ! self::is_valid_client( $via ) ) { + return self::rest_field_validation_update_error( 'via' ); + } + + return $via; + } + + /** + * Validates a 'creator' on REST API update (ID & meta together). + * + * @since [version] + * + * @param string $id Arbitrary creator ID. + * @param array $meta Creator meta values. + * @param WP_Post $post Post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @return array|WP_Error Associative array [id, meta], + * {@see WP_Error} otherwise. + * + * @see WP_Annotation_Utils::on_update_additional_rest_field() + */ + protected static function validate_rest_field_creator_on_update( $id, $meta, $post, $request ) { + $error = self::rest_field_validation_update_error( array( 'creator', 'creator_meta' ) ); + + if ( '' === $id ) { // Empty is OK. + $meta = array(); + return compact( 'id', 'meta' ); + } + + if ( ! $id || ! self::is_valid_creator( $id ) ) { + return $error; + } elseif ( ! $meta || ! is_array( $meta ) ) { + return $error; + } + + $default_meta = array( + 'display_name' => '', + 'image_url' => '', + ); + $raw_meta = $meta; // Original input meta. + + $existing_meta = get_post_meta( $post->ID, '_creator_meta', true ); + $existing_meta = is_array( $existing_meta ) ? $existing_meta : array(); + + $meta = array_merge( $default_meta, $existing_meta, $meta ); + $meta = array_intersect_key( $meta, $default_meta ); + + /* Check display name. */ + + if ( ! is_string( $meta['display_name'] ) ) { + return $error; + } + $meta['display_name'] = sanitize_text_field( $meta['display_name'] ); + $meta['display_name'] = mb_substr( $meta['display_name'], 0, 250 ); + + if ( ! $meta['display_name'] ) { + return $error; + } elseif ( $meta['display_name'] !== $raw_meta['display_name'] ) { + return $error; + } + + /* Check image URL. */ + + if ( ! is_string( $meta['image_url'] ) ) { + return $error; + } elseif ( 2000 < strlen( $meta['image_url'] ) ) { + return $error; + } + $image_path = (string) wp_parse_url( $meta['image_url'], PHP_URL_PATH ); + $image_filetype = wp_check_filetype( $image_path ); + + if ( ! in_array( $image_filetype['ext'], array( 'jpg', 'jpeg', 'png', 'gif', 'ico' ), true ) ) { + return $error; + } + + return compact( 'id', 'meta' ); + } + + /** + * Validates a W3C annotation selector on REST API update. + * + * @since [version] + * + * @param array $selector Selector type/data. + * @param WP_Post $post Post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @return array|WP_Error Selector array, {@see WP_Error} otherwise. + * + * @see WP_Annotation_Utils::on_update_additional_rest_field() + * + * @link https://www.w3.org/TR/annotation-model/#selectors + */ + protected static function validate_rest_field_selector_on_update( $selector, $post, $request ) { + if ( ! $selector ) { + return array(); // Empty is OK. + } + + if ( ! self::is_valid_selector( $selector ) ) { + return self::rest_field_validation_update_error( 'selector' ); + } + + return $selector; + } + + /** + * Validates a substatus on REST API update. + * + * @since [version] + * + * @param string $substatus Substatus. + * @param WP_Post $post Post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @return string|WP_Error Substatus, {@see WP_Error} otherwise. + * + * @see WP_Annotation_Utils::on_update_additional_rest_field() + */ + protected static function validate_rest_field_substatus_on_update( $substatus, $post, $request ) { + if ( ! in_array( $substatus, self::$substatuses, true ) ) { + return self::rest_field_validation_update_error( 'substatus' ); + } + + return $substatus; + } + + /** + * Maybe update substatus history on REST API update. + * + * @since [version] + * + * @param string $new New substatus. + * @param string $old Old substatus. + * @param WP_Post $post Post (i.e., an annotation). + * @param WP_Rest_Request $request Full REST API request details. + * + * @see WP_Annotation_Utils::on_update_additional_rest_field() + */ + protected static function maybe_update_rest_field_substatus_history( $new, $old, $post, $request ) { + if ( $new === $old ) { + return; // No change. + } + + $current_time = time(); + $user = wp_get_current_user(); + $new_history_entry = array(); // Initialize. + + $history = get_post_meta( $post->ID, '_substatus_history', true ); + $history = is_array( $history ) ? $history : array(); + + $creator = self::validate_rest_field_creator_on_update( + $request['creator'], $request['creator_meta'], $post, $request + ); + + if ( ! is_wp_error( $creator ) ) { + $new_history_entry = array( + 'identity' => $creator['id'], + 'identity_meta' => array( + 'display_name' => $creator['meta']['display_name'], + 'image_url' => $creator['meta']['image_url'], + ), + 'time' => $current_time, + 'old' => $old, + 'new' => $new, + ); + } elseif ( $user->exists() ) { + $new_history_entry = array( + 'identity' => (string) $user->ID, + 'identity_meta' => array( + 'display_name' => $user->display_name, + 'image_url' => get_avatar_url( $user->ID ), + ), + 'time' => $current_time, + 'old' => $old, + 'new' => $new, + ); + } + + if ( $new_history_entry ) { + /** + * Allows annotation substatus history length to be increased or decreased. + * + * @since [version] + * + * @param int $length Maximum substatus changes to remember in each annotation. By + * default, substatus history will remember the last 25 changes. + */ + $history_length = apply_filters( 'annotation_substatus_history_length', 25 ); + + $history[] = $new_history_entry; + $history = array_slice( $history, -$history_length ); + + update_post_meta( $post->ID, '_last_substatus_time', $current_time ); + update_post_meta( $post->ID, '_substatus_history', $history ); + } + } + + /** + * Validates a parent post target. + * + * @since [version] + * + * @param string $target Parent post target. + * + * @return bool True if parent post target is valid. + */ + protected static function is_valid_parent_post_target( $target ) { + /** + * Filters parent post targets allowed for annotations. + * + * @since [version] + * + * @param array Parent post targets allowed for annotations. + */ + $allow_parent_post_targets = apply_filters( 'annotation_allow_parent_post_targets', self::$allow_parent_post_targets ); + + return in_array( $target, $allow_parent_post_targets, true ); + } + + /** + * Validates a annotation creator's identifier. + * + * @since [version] + * + * @param string $creator The annotation creator to check. + * + * @return bool True if creator is valid. + */ + protected static function is_valid_creator( $creator ) { + if ( '' === $creator ) { + return true; // Empty is OK. + } + + if ( ! is_string( $creator ) ) { + return false; + } + $raw_creator = $creator; + $creator = sanitize_key( $creator ); + $creator = substr( trim( $creator, '_-' ), 0, 250 ); + + if ( ! $creator || $creator !== $raw_creator ) { + return false; + } elseif ( is_numeric( $creator ) ) { + return false; + } + + return 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 + */ + protected 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 + */ + protected 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; + } + + /** + * Returns a {@see WP_Error} for REST API field update validation errors. + * + * @since [version] + * + * @param string|string[] $field The problematic field name(s). + * + * @return WP_Error {@see WP_Error} object instance. + */ + protected static function rest_field_validation_update_error( $field ) { + if ( is_array( $field ) ) { + $field = implode( ', ', array_map( 'strval', $field ) ); + } + $field = (string) $field; + + // translators: %s is a comma-delimited list of REST API field names associated with failure. + $error = __( 'Validation error. Unexpected: %s.', 'gutenberg' ); + return new WP_Error( 'rest_annotation_field_validation_update_failure', sprintf( $error, $field ), array( 'status' => 400 ) ); + } + + /** + * Returns a {@see WP_Error} for unexpected REST API field update errors. + * + * @since [version] + * + * @param string $field The problematic field name. + * + * @return WP_Error {@see WP_Error} object instance. + */ + protected static function rest_field_unexpected_update_error( $field ) { + if ( is_array( $field ) ) { + $field = implode( ', ', array_map( 'strval', $field ) ); + } + $field = (string) $field; + + // translators: %s is a comma-delimited list of REST API field names associated with failure. + $error = __( 'Unexpected error. Failed to update: %s.', 'gutenberg' ); + return new WP_Error( 'rest_annotation_field_unexpected_update_failure', sprintf( $error, $field ), array( 'status' => 400 ) ); + } +} diff --git a/lib/class-wp-rest-annotations-controller.php b/lib/class-wp-rest-annotations-controller.php new file mode 100644 index 00000000000000..9cba4e5d92abf1 --- /dev/null +++ b/lib/class-wp-rest-annotations-controller.php @@ -0,0 +1,603 @@ +set_param( 'slug', uniqid( 'a' ) ); + } + + $response = parent::create_item( $request ); + + if ( ! is_wp_error( $response ) ) { + // Handle response context differently; i.e., check permission explicitly. + $request->set_param( 'context', current_user_can( 'edit_annotation', $response->data['id'] ) ? 'edit' : 'view' ); + } + + return $response; + } + + /** + * Retrieves a collection of items. + * + * @since [version] + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_REST_Response|WP_Error Response object on success, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Posts_Controller::get_items() + */ + public function get_items( $request ) { + $response = parent::get_items( $request ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + return $this->maybe_fill_descendants( $request, $response ); + } + + /** + * Determines allowed query_vars for a {@see + * WP_REST_Annotations_Controller::get_items()} response. + * + * Also stores prepared query vars in a class property for {@see + * WP_REST_Annotations_Controller::fill_descendants()}. + * + * @since [version] + * + * @param array $prepared_args Optional. {@see WP_Query} arguments. + * @param WP_REST_Request $request Optional. Full details about the request. + * + * @return array Prepared query arguments. + * + * @see WP_REST_Posts_Controller::prepare_items_query() + */ + protected function prepare_items_query( $prepared_args = array(), $request = null ) { + $this->prepared_query_vars = parent::prepare_items_query( $prepared_args, $request ); + + return $this->prepared_query_vars; + } + + /** + * Maybe fill descendants for posts in current response. + * + * @since [version] + * + * @param WP_REST_Request $request Full details about the request. + * @param WP_REST_Response $response Current response with annotations. + * + * @return WP_REST_Response|WP_Error New response with annotations + all of their + * descendants, {@see WP_Error} otherwise. + * + * @see WP_Comment_Query::fill_descendants() + */ + protected function maybe_fill_descendants( $request, $response ) { + if ( ! $request['hierarchical'] ) { + return $response; + } + + /* + * Establish parent query vars to consider in cache algorithm below, by ignoring parent + * query vars that are not a factor when caching children. The more we can *safely* + * ignore, the better our cache hit-ratio will be. + */ + $parent_query_vars_to_ignore_in_level_cache_keys = array( + 'page', + 'paged', + 'offset', + 'nopaging', + 'posts_per_page', + 'posts_per_archive_page', + + 'fields', + 'no_found_rows', + + 'cache_results', + 'update_post_meta_cache', + 'update_post_term_cache', + 'lazy_load_term_meta', + ); + $parent_query_vars_in_level_cache_keys = array_diff_key( + $this->prepared_query_vars, + array_fill_keys( $parent_query_vars_to_ignore_in_level_cache_keys, null ) + ); + $level_cache_key_template = 'get_' . $this->post_type . '_child_ids:{%level_parent_id%}'; // Replace {%level_parent_id%}. + $level_cache_key_template .= ':' . md5( serialize( $parent_query_vars_in_level_cache_keys ) ); + $level_cache_key_template .= ':' . wp_cache_get_last_changed( 'posts' ); + + /* + * Establish child query vars as a mirror of parent query vars, minus a few that should + * simply be ignored when querying child descendants. + * + * Note: post__in is ignored in child queries so it's possible to query for specific + * parents that a block references, while not ignoring descendants of those parents. + */ + $parent_query_vars_to_ignore_when_querying_child_levels = array( + 'p', + 'page_id', + 'pagename', + 'attachment_id', + + 'post__in', + 'post_name__in', + + 'post_parent', + 'post_parent__in', + 'post_parent__not_in', + + 'page', + 'paged', + 'offset', + 'nopaging', + 'posts_per_page', + 'posts_per_archive_page', + + 'fields', + 'no_found_rows', + 'ignore_sticky_posts', + + 'cache_results', + 'update_post_meta_cache', + 'update_post_term_cache', + 'lazy_load_term_meta', + ); + $child_query_vars_template = array_diff_key( + $this->prepared_query_vars, + array_fill_keys( $parent_query_vars_to_ignore_when_querying_child_levels, null ) + ); + $child_query_vars_template['cache_results'] = true; + $child_query_vars_template['ignore_sticky_posts'] = true; + $child_query_vars_template['no_found_rows'] = true; + $child_query_vars_template['posts_per_page'] = -1; + + /* + * Retrieve an entire level of children at a time. + */ + $response_data = $response->get_data(); + $level = 0; + $levels = array( + $level => wp_list_pluck( $response_data, 'id' ), + ); + + do { // While we have child IDs at current level. + + $level_child_ids = array(); + $level_uncached_parent_ids = array(); + $level_parent_ids = $levels[ $level ]; + + foreach ( $level_parent_ids as $level_parent_id ) { + $level_cache_key = str_replace( '{%level_parent_id%}', $level_parent_id, $level_cache_key_template ); + $level_parent_child_ids = wp_cache_get( $level_cache_key, $this->post_type ); + + if ( false !== $level_parent_child_ids ) { + $level_child_ids = array_merge( $level_child_ids, $level_parent_child_ids ); + } else { + $level_uncached_parent_ids[] = $level_parent_id; + } + } + + if ( $level_uncached_parent_ids ) { + $level_query = new WP_Query(); + $level_query_vars = $child_query_vars_template; + $level_query_vars['post_parent__in'] = $level_uncached_parent_ids; + + $level_posts = $level_query->query( $level_query_vars ); + $level_parent_map = array_fill_keys( $level_uncached_parent_ids, array() ); + + foreach ( $level_posts as $level_post ) { + $level_parent_map[ $level_post->post_parent ][] = $level_post->ID; + $level_child_ids[] = $level_post->ID; + } + foreach ( $level_parent_map as $level_parent_id => $level_parent_child_ids ) { + $level_cache_key = str_replace( '{%level_parent_id%}', $level_parent_id, $level_cache_key_template ); + wp_cache_set( $level_cache_key, $level_parent_child_ids, $this->post_type ); + } + } + + $level_child_ids = array_unique( $level_child_ids ); + $levels[ ++$level ] = $level_child_ids; + + } while ( $level_child_ids ); + + /* + * Establish non-top-level descendants and prime post caches. + */ + for ( + $i = 1, + $c = count( $levels ), + $descendant_ids = array(); + $i < $c; + $i++ + ) { + $descendant_ids = array_merge( $descendant_ids, $levels[ $i ] ); + } + _prime_post_caches( $descendant_ids ); + + /* + * Flat array of all response data + descendants. + */ + $all_response_data = $response_data; + + foreach ( $descendant_ids as $descendant_id ) { + $descendant_post = get_post( $descendant_id ); + + if ( ! $descendant_post || ! $this->check_read_permission( $descendant_post ) ) { + continue; // Exclude in either case. + } + $descendant_response = $this->prepare_item_for_response( $descendant_post, $request ); + $all_response_data[] = $this->prepare_response_for_collection( $descendant_response ); + } + + /* + * If a threaded representation was requested, build tree. + */ + if ( 'threaded' === $request['hierarchical'] ) { + $refs = array(); + $threaded_response_data = array(); + + foreach ( $all_response_data as &$data ) { // By reference. + $data['children'] = array(); + + // If not in reference array, it's top level. + if ( ! isset( $refs[ $data['parent'] ] ) ) { + $threaded_response_data[] = &$data; + $refs[ $data['id'] ] = &$data; + + } else { // Add child by reference. + $refs[ $data['parent'] ]['children'][] = &$data; + $refs[ $data['id'] ] = &$data; + } + } + $all_response_data = $threaded_response_data; // Top-level. + } + + /* + * Update response data & return. + */ + $response->set_data( $all_response_data ); + + return $response; + } + + /** + * Checks if an annotation can be read. + * + * Overrides the parent method because it allows read access if the post status is + * 'publish', w/o checking read permissions explicitly. Therefore, this method is + * more secure than the parent method alone. + * + * @since [version] + * + * @param WP_Post $post Post object. + * + * @return bool True if the annotation can be read. + * + * @see WP_REST_Posts_Controller::check_read_permission() + */ + public function check_read_permission( $post ) { + $parent_check = parent::check_read_permission( $post ); + + if ( true !== $parent_check ) { + return $parent_check; + } + + if ( ! ( $post instanceof WP_Post ) ) { + return false; + } + + return current_user_can( 'read_annotation', $post->ID ); + } + + /** + * Checks if a given request has access to read (get). + * + * Overrides the parent method because it doesn't consider parent post permissions. + * Therefore, this method is more secure than the parent method alone. + * + * @since [version] + * + * @param WP_REST_Request $request Full details about the request. + * + * @return bool|WP_Error True if the request has read access, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Posts_Controller::get_items_permissions_check() + */ + public function get_items_permissions_check( $request ) { + $parent_check = parent::get_items_permissions_check( $request ); + + if ( is_wp_error( $parent_check ) ) { + return $parent_check; + } + + $parent_post_ids = $request['parent_post']; + $parent_post_ids = $parent_post_ids ? (array) $parent_post_ids : array(); + $parent_post_ids = array_map( 'absint', $parent_post_ids ); + + $parent_post_targets = $request['parent_post_target']; + $parent_post_targets = isset( $parent_post_targets ) ? (array) $parent_post_targets : array(); + $parent_post_targets = array_map( 'strval', $parent_post_targets ); + + $parent_post_passwords = $request['parent_post_password']; + $parent_post_passwords = $parent_post_passwords ? (array) $parent_post_passwords : array(); + $parent_post_passwords = array_map( 'strval', $parent_post_passwords ); + + if ( ! $parent_post_ids ) { + return new WP_Error( 'rest_missing_annotation_parent_post', __( 'Invalid parent post ID.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } elseif ( ! $parent_post_targets ) { + return new WP_Error( 'rest_missing_annotation_parent_post_target', __( 'Invalid parent post target.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } // Must have at least one parent post ID & target to check permissions properly. + + foreach ( $parent_post_ids as $key => $parent_post_id ) { + foreach ( $parent_post_targets as $parent_post_target ) { + $parent_post_password = isset( $parent_post_passwords[ $key ] ) ? $parent_post_passwords[ $key ] : ''; + + if ( ! $parent_post_id || ! get_post( $parent_post_id ) ) { + return new WP_Error( 'rest_missing_annotation_parent_post', __( 'Invalid parent post ID.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! current_user_can( 'read_annotations', $parent_post_id, $parent_post_target ) ) { + return new WP_Error( 'rest_cannot_read_annotation_parent_post', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( $this->parent_post_password_required( $parent_post_id, $parent_post_target, $parent_post_password ) ) { + return new WP_Error( 'rest_annotation_parent_post_password_required', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + } + } + + return true; + } + + /** + * Checks if a given request has access to create. + * + * Overrides the parent method because it doesn't consider parent post permissions. + * Therefore, this method is more secure than the parent method alone. + * + * @since [version] + * + * @param WP_REST_Request $request Full details about the request. + * + * @return bool|WP_Error True if the request has access to create, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Posts_Controller::create_item_permissions_check() + */ + public function create_item_permissions_check( $request ) { + $parent_check = parent::create_item_permissions_check( $request ); + + if ( is_wp_error( $parent_check ) ) { + if ( 'rest_cannot_create' !== $parent_check->get_error_code() ) { + return $parent_check; + } + } + + $parent_post_id = absint( $request['parent_post'] ); + $parent_post_target = (string) $request['parent_post_target']; + $parent_post_password = (string) $request['parent_post_password']; + $parent_id = absint( $request['parent'] ); + + if ( ! $parent_post_id || ! get_post( $parent_post_id ) ) { + return new WP_Error( 'rest_missing_annotation_parent_post', __( 'Missing parent post.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! current_user_can( 'create_annotation', $parent_post_id, $parent_post_target ) ) { + return new WP_Error( 'rest_cannot_create_annotation', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( $this->parent_post_password_required( $parent_post_id, $parent_post_target, $parent_post_password ) ) { + return new WP_Error( 'rest_annotation_parent_post_password_required', __( '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(), + ) ); + } + + return true; + } + + /** + * Checks if a given request has access to update. + * + * Overrides the parent method because it doesn't consider parent post permissions. + * Therefore, this method is more secure than the parent method alone. + * + * @since [version] + * + * @param WP_REST_Request $request Full details about the request. + * + * @return bool|WP_Error True if the request has access to update, + * {@see WP_Error} otherwise. + * + * @see WP_REST_Posts_Controller::update_item_permissions_check() + */ + public function update_item_permissions_check( $request ) { + $parent_check = parent::update_item_permissions_check( $request ); + + if ( is_wp_error( $parent_check ) ) { + return $parent_check; + } + + $post_id = absint( $request['id'] ); + $post_info = WP_Annotation_Utils::get_post_info( $post_id ); + + if ( ! $post_info ) { + return new WP_Error( 'rest_missing_annotation', __( 'Missing annotation.', 'gutenberg' ), array( + 'status' => 404, + ) ); + } + $post = $post_info['post']; + $post_parent_id = $post->post_parent; + + $parent_post = $post_info['parent_post']; + $parent_post_id = $post_info['parent_post']->ID; + $parent_post_target = $post_info['parent_post_target']; + + if ( isset( $request['parent_post'] ) ) { + $new_parent_post_id = absint( $request['parent_post'] ); + } else { + $new_parent_post_id = $parent_post_id; + } + + if ( isset( $request['parent_post_target'] ) ) { + $new_parent_post_target = (string) $request['parent_post_target']; + } else { + $new_parent_post_target = $parent_post_target; + } + + if ( isset( $request['parent'] ) ) { + $new_post_parent_id = absint( $request['parent'] ); + } else { + $new_post_parent_id = $post_parent_id; + } + + $new_parent_post_password = (string) $request['parent_post_password']; + + if ( $new_parent_post_id !== $parent_post_id || $new_parent_post_target !== $parent_post_target || $new_post_parent_id !== $post_parent_id ) { + if ( ! $new_parent_post_id || ! get_post( $new_parent_post_id ) ) { + return new WP_Error( 'rest_missing_annotation_parent_post', __( 'Missing parent post.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( ! current_user_can( 'create_annotation', $new_parent_post_id, $new_parent_post_target ) ) { + return new WP_Error( 'rest_cannot_update_annotation_parent_post', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + if ( $new_post_parent_id && ! get_post( $new_post_parent_id ) ) { + return new WP_Error( 'rest_missing_annotation_parent', __( 'Missing annotation parent.', 'gutenberg' ), array( + 'status' => 400, + ) ); + } + + if ( $new_post_parent_id && ! current_user_can( 'read_annotation', $new_post_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 ( $this->parent_post_password_required( $new_parent_post_id, $new_parent_post_target, $new_parent_post_password ) ) { + return new WP_Error( 'rest_annotation_parent_post_password_required', __( 'Sorry, you are not allowed as this user.', 'gutenberg' ), array( + 'status' => rest_authorization_required_code(), + ) ); + } + + return true; + } + + /** + * Checks if a parent post password is required. + * + * @since [version] + * + * @param string|int $parent_post_id Parent post ID. + * @param string $parent_post_target Parent post target. + * @param string $parent_post_password Parent post password. + * + * @return bool True if a password is required. + * + * @see post_password_required() + */ + protected function parent_post_password_required( $parent_post_id, $parent_post_target, $parent_post_password ) { + if ( ! $parent_post_id ) { + return false; + } elseif ( '' !== $parent_post_target ) { + return false; + } elseif ( ! post_password_required( $parent_post_id ) ) { + return false; + } + + $parent_post_info = WP_Annotation_Utils::get_parent_post_info( $parent_post_id ); + + if ( ! $parent_post_info ) { + return false; + } + + $parent_post = $parent_post_info['parent_post']; + $parent_post_type = $parent_post_info['parent_post_type']; + + if ( ! current_user_can( $parent_post_type->cap->edit_post, $parent_post->ID ) ) { + // phpcs:ignore PHPCompatibility.PHP.NewFunctions.hash_equalsFound — hash_equals() is provided by core. + if ( ! hash_equals( $parent_post->post_password, $parent_post_password ) ) { // @codingStandardsIgnoreLine + return true; + } + } + + return false; + } +} 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..94363c3d1adf5c 100644 --- a/lib/register.php +++ b/lib/register.php @@ -406,6 +406,8 @@ function gutenberg_register_post_types() { 'rest_base' => 'blocks', 'rest_controller_class' => 'WP_REST_Blocks_Controller', ) ); + + WP_Annotation_Utils::register_post_type(); } add_action( 'init', 'gutenberg_register_post_types' ); diff --git a/phpunit/class-annotations-test.php b/phpunit/class-annotations-test.php new file mode 100644 index 00000000000000..321145a28ebab1 --- /dev/null +++ b/phpunit/class-annotations-test.php @@ -0,0 +1,536 @@ +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( + '' => array( + 'in_post_by' => self::$post_id[ "post_by_{$r}" ], + ), + 'admin' => array( + 'in_post_backend_by' => self::$post_id[ "post_by_{$r}" ], + 'in_draft_backend_by' => self::$post_id[ "draft_by_{$r}" ], + ), + ) as $_parent_post_target => $_parent_post_key_ids ) { + foreach ( $_parent_post_key_ids as $k => $_parent_post_id ) { + $_common_annotation_meta = array( + '_parent_post' => $_parent_post_id, + '_parent_post_target' => $_parent_post_target, + '_via' => 'gutenberg', + + '_creator' => 'x-plugin', + '_creator_meta' => array( + 'display_name' => 'X Plugin', + 'image_url' => 'https://example.com/image.png', + ), + '_selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + '_substatus' => '', + '_last_substatus_time' => 0, + '_substatus_history' => array(), + ); + + self::$anno_id[ "{$_r}:{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation.

', + 'meta_input' => $_common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply.

', + 'meta_input' => $_common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply to reply.

', + 'meta_input' => $_common_annotation_meta, + ) ); + } + } + } + } + } + + /** + * Delete fake data after tests run. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$roles as $r ) { + wp_delete_post( self::$post_id[ "post_by_{$r}" ] ); + wp_delete_post( self::$post_id[ "draft_by_{$r}" ] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + wp_delete_post( self::$anno_id[ "{$_r}:{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] ); + } + } + self::delete_user( self::$user_id[ $r ] ); + } + } + + /** + * On setup. + */ + public function setUp() { + parent::setUp(); + + add_filter( 'annotation_allow_parent_post_targets', array( $this, 'allowParentPostTargets' ) ); + } + + /** + * On teardown. + */ + public function tearDown() { + remove_filter( 'annotation_allow_parent_post_targets', array( $this, 'allowParentPostTargets' ) ); + + parent::tearDown(); + } + + /** + * Allows all of the parent post targets being tested here. + * + * @return array Allowed parent post targets. + */ + public function allowParentPostTargets() { + return array( '', 'admin' ); + } + + /* + * Basic tests. + */ + + /** + * Check that we can get the post type. + */ + public function test_get_post_type() { + $this->assertTrue( ! empty( WP_Annotation_Utils::$post_type ) ); + $this->assertTrue( is_string( WP_Annotation_Utils::$post_type ) ); + } + + /** + * Check that we can get parent post targets. + */ + public function test_get_parent_post_targets() { + $this->assertContains( '', WP_Annotation_Utils::$parent_post_targets ); + $this->assertContains( 'admin', WP_Annotation_Utils::$parent_post_targets ); + } + + /** + * 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 substatuses. + */ + public function test_get_substatuses() { + $this->assertContains( '', WP_Annotation_Utils::$substatuses ); + $this->assertContains( 'resolve', WP_Annotation_Utils::$substatuses ); + $this->assertContains( 'reject', WP_Annotation_Utils::$substatuses ); + $this->assertContains( 'archive', WP_Annotation_Utils::$substatuses ); + } + + /** + * Check that we have necessary fundamental hooks. + */ + public function test_post_type_hooks() { + $this->assertNotEmpty( has_filter( 'map_meta_cap', 'WP_Annotation_Utils::on_map_meta_cap' ) ); + $this->assertNotEmpty( has_action( 'delete_post', 'WP_Annotation_Utils::on_delete_post' ) ); + } + + /* + * Test user permissions. + */ + + /** + * Check that anonymous users gain very little access to annotations. + * + * Exception: Front-end public annotations in a public (published) parent post can be + * read by the public, which means that an anonymous user gains read access. + * + * Exception: Front-end public annotations in a public parent post can be created by + * the public. Assuming annotations are enabled in the parent post, and annotating + * does not require registration. + */ + public function test_anonymous_allow_deny_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + $r = 'anonymous'; + wp_set_current_user( 0 ); + + $this->assertSame( "{$r}:create_posts:false", "{$r}:create_posts:" . ( current_user_can( $cap->create_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_posts:false", "{$r}:edit_posts:" . ( current_user_can( $cap->edit_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:publish_posts:false", "{$r}:publish_posts:" . ( current_user_can( $cap->publish_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_posts:false", "{$r}:delete_posts:" . ( current_user_can( $cap->delete_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:read_private_posts:false", "{$r}:read_private_posts:" . ( current_user_can( $cap->read_private_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:edit_others_posts:false", "{$r}:edit_others_posts:" . ( current_user_can( $cap->edit_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_private_posts:false", "{$r}:edit_private_posts:" . ( current_user_can( $cap->edit_private_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_published_posts:false", "{$r}:edit_published_posts:" . ( current_user_can( $cap->edit_published_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:delete_others_posts:false", "{$r}:delete_others_posts:" . ( current_user_can( $cap->delete_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_published_posts:false", "{$r}:delete_published_posts:" . ( current_user_can( $cap->delete_published_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_private_posts:false", "{$r}:delete_private_posts:" . ( current_user_can( $cap->delete_private_posts ) ? 'true' : 'false' ) ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( '', 'admin' ) as $t ) { + $v = 'post_by' === $k && '' === $t ? 'true' : 'false'; + $this->assertSame( "{$r}:create_post:in_{$k}_{$_r}:{$t}:{$v}", "$r:create_post:in_{$k}_{$_r}:{$t}:" . ( current_user_can( $cap->create_post, self::$post_id[ "{$k}_{$_r}" ], $t ) ? 'true' : 'false' ) ); + } + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $v = 'in_post_by' === $k ? 'true' : 'false'; + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:{$v}", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$_r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:false", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$_r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:false", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$_r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + } + } + } + + /** + * Check that subscribers have no access to back-end annotations whatsoever. + */ + public function test_subscriber_deny_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + foreach ( array( 'subscriber' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + $this->assertSame( "{$r}:create_posts:false", "{$r}:create_posts:" . ( current_user_can( $cap->create_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_posts:false", "{$r}:edit_posts:" . ( current_user_can( $cap->edit_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:publish_posts:false", "{$r}:publish_posts:" . ( current_user_can( $cap->publish_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_posts:false", "{$r}:delete_posts:" . ( current_user_can( $cap->delete_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:read_private_posts:false", "{$r}:read_private_posts:" . ( current_user_can( $cap->read_private_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:edit_others_posts:false", "{$r}:edit_others_posts:" . ( current_user_can( $cap->edit_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_private_posts:false", "{$r}:edit_private_posts:" . ( current_user_can( $cap->edit_private_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_published_posts:false", "{$r}:edit_published_posts:" . ( current_user_can( $cap->edit_published_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:delete_others_posts:false", "{$r}:delete_others_posts:" . ( current_user_can( $cap->delete_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_published_posts:false", "{$r}:delete_published_posts:" . ( current_user_can( $cap->delete_published_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_private_posts:false", "{$r}:delete_private_posts:" . ( current_user_can( $cap->delete_private_posts ) ? 'true' : 'false' ) ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( 'admin' ) as $t ) { + $this->assertSame( "{$r}:create_post:in_{$k}_{$_r}:{$t}:false", "$r:create_post:in_{$k}_{$_r}:{$t}:" . ( current_user_can( $cap->create_post, self::$post_id[ "{$k}_{$_r}" ], $t ) ? 'true' : 'false' ) ); + } + } + foreach ( array( 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:false", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:false", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:false", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? '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() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + 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( '' ) as $t ) { + $this->assertSame( "{$r}:create_post:in_{$k}_{$_r}:{$t}:true", "$r:create_post:in_{$k}_{$_r}:{$t}:" . ( current_user_can( $cap->create_post, self::$post_id[ "{$k}_{$_r}" ], $t ) ? 'true' : 'false' ) ); + } + } + foreach ( array( 'in_post_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:true", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:false", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:false", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + } + } + } + } + + /** + * Check that admins and editors can access all annotations without restriction. + * Admins and editors can create, read, edit, and delete any annotation. + */ + public function test_admin_editor_allow_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + foreach ( array( 'administrator', 'editor' ) as $r ) { + wp_set_current_user( self::$user_id[ $r ] ); + + $this->assertSame( "{$r}:create_posts:true", "{$r}:create_posts:" . ( current_user_can( $cap->create_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_posts:true", "{$r}:edit_posts:" . ( current_user_can( $cap->edit_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:publish_posts:true", "{$r}:publish_posts:" . ( current_user_can( $cap->publish_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_posts:true", "{$r}:delete_posts:" . ( current_user_can( $cap->delete_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:read_private_posts:true", "{$r}:read_private_posts:" . ( current_user_can( $cap->read_private_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:edit_others_posts:true", "{$r}:edit_others_posts:" . ( current_user_can( $cap->edit_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_private_posts:true", "{$r}:edit_private_posts:" . ( current_user_can( $cap->edit_private_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_published_posts:true", "{$r}:edit_published_posts:" . ( current_user_can( $cap->edit_published_posts ) ? 'true' : 'false' ) ); + + $this->assertSame( "{$r}:delete_others_posts:true", "{$r}:delete_others_posts:" . ( current_user_can( $cap->delete_others_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_published_posts:true", "{$r}:delete_published_posts:" . ( current_user_can( $cap->delete_published_posts ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_private_posts:true", "{$r}:delete_private_posts:" . ( current_user_can( $cap->delete_private_posts ) ? 'true' : 'false' ) ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'post_by', 'draft_by' ) as $k ) { + foreach ( array( '', 'admin' ) as $t ) { + $this->assertSame( "{$r}:create_post:in_{$k}_{$_r}:{$t}:true", "$r:create_post:in_{$k}_{$_r}:{$t}:" . ( current_user_can( $cap->create_post, self::$post_id[ "{$k}_{$_r}" ], $t ) ? 'true' : 'false' ) ); + } + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:true", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:true", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:true", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? '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 drafts. + * + * Exception: A contributor is not allowed to edit or delete their own front-end + * annotations in any parent post that is now public; i.e., once their post is + * published, they are treated like any other front-end annotator. Even in a parent + * post that they're the author of. + */ + public function test_author_contributor_allow_permissions() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + 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( '', 'admin' ) as $t ) { + $this->assertSame( "{$r}:create_post:in_{$k}_{$_r}:{$t}:true", "$r:create_post:in_{$k}_{$_r}:{$t}:" . ( current_user_can( $cap->create_post, self::$post_id[ "{$k}_{$_r}" ], $t ) ? 'true' : 'false' ) ); + } + } + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:true", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + + $v = 'in_post_by' === $k && 'contributor' === $r ? 'false' : 'true'; + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:{$v}", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:{$v}", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? '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() { + $post_type = get_post_type_object( WP_Annotation_Utils::$post_type ); + $cap = $post_type->cap; // Shorter. + + 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' ) as $t ) { + $this->assertSame( "{$r}:create_post:in_{$k}_{$_r}:{$t}:false", "$r:create_post:in_{$k}_{$_r}:{$t}:" . ( current_user_can( $cap->create_post, self::$post_id[ "{$k}_{$_r}" ], $t ) ? 'true' : 'false' ) ); + } + } + foreach ( array( 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + $this->assertSame( "{$r}:read_post:{$k}_{$_r}:false", "$r:read_post:{$k}_{$_r}:" . ( current_user_can( $cap->read_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:edit_post:{$k}_{$_r}:false", "$r:edit_post:{$k}_{$_r}:" . ( current_user_can( $cap->edit_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? 'true' : 'false' ) ); + $this->assertSame( "{$r}:delete_post:{$k}_{$_r}:false", "$r:delete_post:{$k}_{$_r}:" . ( current_user_can( $cap->delete_post, self::$anno_id[ "{$r}:{$k}_{$_r}" ] ) ? '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 ); + + for ( $i = 0; $i < 3; $i++ ) { + $annotation_id = $this->factory->post->create( array( + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_author' => self::$user_id['editor'], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation.

', + 'meta_input' => array( + '_parent_post' => $post_id, + '_parent_post_target' => 'admin', + '_via' => 'gutenberg', + ), + ) ); + $this->assertInternalType( 'int', $annotation_id ); + $this->assertGreaterThan( 0, $annotation_id ); + } + wp_delete_post( $post_id, true ); + + $query = new WP_Query(); + $annotation_ids = $query->query( array( + 'fields' => 'ids', + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_status' => array_keys( get_post_stati() ), + 'ignore_sticky_posts' => true, + 'no_found_rows' => true, + 'suppress_filters' => true, + 'posts_per_page' => -1, + 'meta_query' => array( + 'key' => '_parent_post', + 'value' => $post_id, + ), + ) ); + + $this->assertEmpty( $annotation_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..636cf83e56f624 --- /dev/null +++ b/phpunit/class-rest-annotations-controller-test.php @@ -0,0 +1,1164 @@ +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( + '' => array( + 'in_post_by' => self::$post_id[ "post_by_{$r}" ], + ), + 'admin' => array( + 'in_post_backend_by' => self::$post_id[ "post_by_{$r}" ], + 'in_draft_backend_by' => self::$post_id[ "draft_by_{$r}" ], + ), + ) as $_parent_post_target => $_parent_post_key_ids ) { + foreach ( $_parent_post_key_ids as $k => $_parent_post_id ) { + $_common_annotation_meta = array( + '_parent_post' => $_parent_post_id, + '_parent_post_target' => $_parent_post_target, + '_via' => 'gutenberg', + + '_creator' => 'x-plugin', + '_creator_meta' => array( + 'display_name' => 'X Plugin', + 'image_url' => 'https://example.com/image.png', + ), + '_selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + '_substatus' => '', + '_last_substatus_time' => 0, + '_substatus_history' => array(), + ); + + self::$anno_id[ "{$_r}:{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_parent' => 0, + 'post_status' => 'publish', + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation.

', + 'meta_input' => $_common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply.

', + 'meta_input' => $_common_annotation_meta, + ) ); + + self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] = $factory->post->create( array( + 'post_author' => self::$user_id[ $_r ], + 'post_status' => 'publish', + 'post_parent' => self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ], + 'post_type' => WP_Annotation_Utils::$post_type, + 'post_content' => '

bold italic test annotation reply to reply.

', + 'meta_input' => $_common_annotation_meta, + ) ); + } + } + } + } + } + + /** + * Delete fake data after tests run. + */ + public static function wpTearDownAfterClass() { + foreach ( self::$roles as $r ) { + wp_delete_post( self::$post_id[ "post_by_{$r}" ] ); + wp_delete_post( self::$post_id[ "draft_by_{$r}" ] ); + + foreach ( self::$roles as $_r ) { + foreach ( array( 'in_post_by', 'in_post_backend_by', 'in_draft_backend_by' ) as $k ) { + wp_delete_post( self::$anno_id[ "{$_r}:{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:_reply_{$k}_{$r}" ] ); + wp_delete_post( self::$anno_id[ "{$_r}:__reply_{$k}_{$r}" ] ); + } + } + self::delete_user( self::$user_id[ $r ] ); + } + } + + /** + * On setup. + */ + public function setUp() { + parent::setUp(); + + add_filter( 'annotation_allow_parent_post_targets', array( $this, 'allowParentPostTargets' ) ); + } + + /** + * On teardown. + */ + public function tearDown() { + remove_filter( 'annotation_allow_parent_post_targets', array( $this, 'allowParentPostTargets' ) ); + + parent::tearDown(); + } + + /** + * Allows all of the parent post targets being tested here. + * + * @return array Allowed parent post targets. + */ + public function allowParentPostTargets() { + return array( '', 'admin' ); + } + + /* + * 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->assertSame( 27, count( $properties ) ); + + $this->assertArrayHasKey( 'parent_post', $properties ); + $this->assertArrayHasKey( 'parent_post_target', $properties ); + $this->assertArrayHasKey( 'parent_post_password', $properties ); + + $this->assertArrayHasKey( 'via', $properties ); + + $this->assertArrayHasKey( 'creator', $properties ); + $this->assertArrayHasKey( 'creator_meta', $properties ); + + $this->assertArrayHasKey( 'selector', $properties ); + + $this->assertArrayHasKey( 'substatus', $properties ); + $this->assertArrayHasKey( 'last_substatus_time', $properties ); + $this->assertArrayHasKey( 'substatus_history', $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. + * + * This test intentionally excludes the optional 'parent_post_target', which allows + * us to confirm that a default 'parent_post_target' is in use. + */ + public function test_get_items() { + wp_set_current_user( self::$user_id['editor'] ); + + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', self::$post_id['post_by_editor'] ); + $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_get_posts_response( $response ); + } + + /** + * Check that we can GET a collection of front and back-end annotations. + */ + public function test_get_parent_post_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', self::$post_id['post_by_editor'] ); + $request->set_param( 'parent_post_target', $parent_post_target ); + $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_get_posts_response( $response ); + } + } + + /** + * Check that we can GET a collection of front and back-end annotations that exist in + * multiple parent post IDs. + */ + public function test_get_multiple_parent_post_items() { + wp_set_current_user( self::$user_id['editor'] ); + + foreach ( array( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', array( + self::$post_id['post_by_editor'], + self::$post_id['post_by_author'], + self::$post_id['post_by_contributor'], + ) ); + $request->set_param( 'parent_post_target', $parent_post_target ); + $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_get_posts_response( $response ); + } + } + + /** + * Check that we can GET a collection of front and back-end annotations with specific + * parent post IDs and also with specific parent annotation IDs. + */ + public function test_get_parent_posts_parents_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'], + ), + 'admin' => 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 $parent_post_target => $parent_ids ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', array( + self::$post_id['post_by_editor'], + self::$post_id['post_by_author'], + self::$post_id['post_by_contributor'], + ) ); + $request->set_param( 'parent_post_target', $parent_post_target ); + $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_get_posts_response( $response ); + } + } + + /** + * 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( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', self::$post_id['post_by_editor'] ); + $request->set_param( 'parent_post_target', $parent_post_target ); + $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_get_posts_response( $response ); + } + } + + /** + * 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( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', self::$post_id['post_by_editor'] ); + $request->set_param( 'parent_post_target', $parent_post_target ); + $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_get_posts_response( $response ); + } + } + + /** + * 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( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', self::$post_id['post_by_editor'] ); + $request->set_param( 'parent_post_target', $parent_post_target ); + $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_get_posts_response( $response ); + } + } + + /* + * 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( 404, $status ); + $this->assertSame( 'rest_post_invalid_id', $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_get_post_response( $response ); + } + + /** + * 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_get_post_response( $response, 'edit' ); + } + + /** + * 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_get_post_response( $response, 'edit' ); + } + + /** + * 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( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'POST', self::$rest_ns_base ); + + $request->set_body_params( array( + 'parent' => 0, + 'status' => 'publish', + 'author' => self::$user_id['editor'], + 'type' => WP_Annotation_Utils::$post_type, + 'content' => '

bold italic test annotation.

', + + 'parent_post' => self::$post_id['post_by_editor'], + 'parent_post_target' => $parent_post_target, + 'via' => 'gutenberg', + + 'creator' => 'x-plugin', + 'creator_meta' => array( + 'display_name' => 'X Plugin', + 'image_url' => 'https://example.com/image.png', + ), + 'selector' => array( + 'type' => 'CssSelector', + 'value' => '#foo', + ), + 'substatus' => '', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $status ); + $this->check_create_post_response( $response ); + + wp_delete_post( $data['id'] ); + } + } + + /** + * 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' => 'hello world', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 200, $status ); + $this->check_update_post_response( $response ); + } + + /** + * 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( + 'substatus' => 'foobar', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_invalid_param', $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( + 'parent' => 0, + 'status' => 'publish', + 'author' => self::$user_id['author'], + 'content' => '

Test annotation.

', + + 'parent_post' => self::$post_id['post_by_author'], + 'parent_post_target' => 'admin', + 'via' => 'gutenberg', + ) ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 201, $response->get_status() ); + $this->check_create_post_response( $response ); + + $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 parent 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_callback_param', $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_callback_param', $data['code'] ); + } + } + + /** + * Check that a valid parent post ID is required to list annotations. + */ + public function test_invalid_parent_post_deny_permissions() { + 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( 'parent_post', 0 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_missing_annotation_parent_post', $data['code'] ); + } + + 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( 'parent_post', 123456789 ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( 400, $status ); + $this->assertSame( 'rest_missing_annotation_parent_post', $data['code'] ); + } + } + + /** + * Check that anonymous users can't GET a single back-end annotation, but that 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_get_post_response( $response ); + } else { + // see: . + $this->assertTrue( in_array( $status, array( 401, 403 ), true ) ); + $this->assertSame( 'rest_forbidden', $data['code'] ); + } + } + } + } + } + + /** + * Check that subscribers can't GET a single back-end annotation, but that 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_get_post_response( $response ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_forbidden', $data['code'] ); + } + } + } + } + } + + /** + * Check that anonymous users can't GET (list) back-end annotations, but that 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( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', self::$post_id[ "{$k}_{$_r}" ] ); + $request->set_param( 'parent_post_target', $parent_post_target ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'post_by' === $k && '' === $parent_post_target ) { + $this->assertSame( 200, $status ); + $this->check_get_posts_response( $response ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation_parent_post', $data['code'] ); + } + } + } + } + } + + /** + * Check that subscribers can't GET (list) back-end annotations, but that 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( '', 'admin' ) as $parent_post_target ) { + $request = new WP_REST_Request( 'GET', self::$rest_ns_base ); + + $request->set_param( 'parent_post', self::$post_id[ "{$k}_{$_r}" ] ); + $request->set_param( 'parent_post_target', $parent_post_target ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'post_by' === $k && '' === $parent_post_target ) { + $this->assertSame( 200, $status ); + $this->check_get_posts_response( $response ); + } else { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_read_annotation_parent_post', $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( 'substatus', 'archive' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + // see: . + $this->assertTrue( in_array( $status, array( 401, 403 ), true ) ); + $this->assertSame( 'rest_cannot_edit', $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( 'substatus', 'archive' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_edit', $data['code'] ); + } + } + } + } + + /** + * Check that authors and contributors can't GET back-end annotations of others. + */ + public function test_author_contributor_deny_get_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( 'parent_post', self::$post_id[ "post_by_{$_r}" ] ); + $request->set_param( 'parent_post_target', 'admin' ); + + $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_annotation_parent_post', $data['code'] ); + } + } + } + + /** + * Check that authors and contributors can't GET (list) back-end annotations for an + * array of parent post IDs, when any parent is owned by others. + */ + public function test_author_contributor_get_items_by_parent_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( 'parent_post', array( + self::$post_id[ "{$k}_{$r}" ], + self::$post_id[ "{$k}_{$_r}" ], + ) ); + $request->set_param( 'parent_post_target', 'admin' ); + + $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_annotation_parent_post', $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( 'substatus', 'archive' ); + + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_edit', $data['code'] ); + } + } + } + } + + /** + * Test that authors and contributors are unable to DELETE annotations in posts + * authored by others. Authors and contributors can't edit 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(); + + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_delete', $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' => 'hello world', + ) ); + $response = $this->server->dispatch( $request ); + $status = $response->get_status(); + $data = $response->get_data(); + + if ( 'in_post_by' === $k && 'contributor' === $r ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_edit', $data['code'] ); + } else { + $this->assertSame( 200, $status ); + $this->check_update_post_response( $response ); + } + } + } + } + } + + /** + * Test that authors and contributors are able to DELETE their own annotations in + * their own posts; i.e., posts that they authored themselves. + * + * 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 ( 'in_post_by' === $k && 'contributor' === $r ) { + $this->assertSame( rest_authorization_required_code(), $status ); + $this->assertSame( 'rest_cannot_delete', $data['code'] ); + } else { + $this->assertSame( 200, $status ); + } + } + } + } + } +}