* - GET /comments/counts * * SECURITY POSTURE * ================ * * 1. `permission_callback` — broad cap gate * (`moderate_comments`, `edit_posts`). * 2. Per-target re-validation inside the callback — * `current_user_can( 'edit_comment', $id )` per row. * * @package WPDesktopMode * @since 0.19.0 */ defined( 'ABSPATH' ) || exit; /** * Allowed bulk actions, mapped to the function that performs them on a single id. * * Each callback returns true on success, false on a soft failure (the * row is skipped) and throws nothing — the bulk endpoint logs misses * but never aborts the batch on a single bad row. * * @since 0.19.0 * * @return array */ function desktop_mode_comments_window_bulk_action_map() { return array( 'approve' => static function ( $id ) { return false !== wp_set_comment_status( $id, 'approve' ); }, 'unapprove' => static function ( $id ) { return false !== wp_set_comment_status( $id, 'hold' ); }, 'spam' => static function ( $id ) { return false !== wp_spam_comment( $id ); }, 'unspam' => static function ( $id ) { return false !== wp_unspam_comment( $id ); }, 'trash' => static function ( $id ) { return false !== wp_trash_comment( $id ); }, 'untrash' => static function ( $id ) { return false !== wp_untrash_comment( $id ); }, ); } /** * Register all routes. * * @since 0.19.0 */ function desktop_mode_comments_window_register_rest_routes() { register_rest_route( 'desktop-mode/v1', '/comments/bulk', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'desktop_mode_comments_window_rest_bulk', 'permission_callback' => static function () { return current_user_can( 'moderate_comments' ); }, 'args' => array( 'ids' => array( 'required' => true, 'type' => 'array', 'items' => array( 'type' => 'integer' ), ), 'action' => array( 'required' => true, 'type' => 'string', 'enum' => array_keys( desktop_mode_comments_window_bulk_action_map() ), ), ), ) ); register_rest_route( 'desktop-mode/v1', '/comments/reply', array( 'methods' => WP_REST_Server::CREATABLE, 'callback' => 'desktop_mode_comments_window_rest_reply', 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'parent' => array( 'required' => true, 'type' => 'integer', ), 'content' => array( 'required' => true, 'type' => 'string', ), ), ) ); register_rest_route( 'desktop-mode/v1', '/comments/insights/(?P[^/]+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'desktop_mode_comments_window_rest_insights', 'permission_callback' => static function () { return current_user_can( 'moderate_comments' ); }, 'args' => array( 'email' => array( 'required' => true, 'type' => 'string', ), ), ) ); register_rest_route( 'desktop-mode/v1', '/comments/counts', array( 'methods' => WP_REST_Server::READABLE, 'callback' => 'desktop_mode_comments_window_rest_counts', 'permission_callback' => static function () { return current_user_can( 'edit_posts' ); }, ) ); } add_action( 'rest_api_init', 'desktop_mode_comments_window_register_rest_routes' ); /** * Bulk moderation handler. * * @since 0.19.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function desktop_mode_comments_window_rest_bulk( WP_REST_Request $request ) { $ids = array_values( array_filter( array_map( 'intval', (array) $request['ids'] ) ) ); $action = (string) $request['action']; $map = desktop_mode_comments_window_bulk_action_map(); if ( ! isset( $map[ $action ] ) ) { return new WP_Error( 'desktop_mode_comments_invalid_action', __( 'Unknown bulk action.', 'desktop-mode' ), array( 'status' => 400 ) ); } $cb = $map[ $action ]; $processed = array(); $skipped = array(); foreach ( $ids as $id ) { if ( ! current_user_can( 'edit_comment', $id ) ) { $skipped[] = $id; continue; } if ( $cb( $id ) ) { $processed[] = $id; } else { $skipped[] = $id; } } /** * Fires after a Comments-window bulk action runs. * * @since 0.19.0 * * @param string $action Action slug. * @param int[] $processed Ids successfully acted on. * @param int[] $skipped Ids skipped (cap fail or soft error). */ do_action( 'desktop_mode_comments_window_after_bulk', $action, $processed, $skipped ); return new WP_REST_Response( array( 'action' => $action, 'processed' => $processed, 'skipped' => $skipped, 'counts' => desktop_mode_comments_window_counts(), ), 200 ); } /** * Inline-reply handler. Wraps `wp_new_comment` with sane defaults so * the client only needs `{ parent, content }`. * * @since 0.19.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function desktop_mode_comments_window_rest_reply( WP_REST_Request $request ) { $parent_id = (int) $request['parent']; $content = (string) $request['content']; $parent = get_comment( $parent_id ); if ( ! $parent instanceof WP_Comment ) { return new WP_Error( 'desktop_mode_comments_no_parent', __( 'Parent comment not found.', 'desktop-mode' ), array( 'status' => 404 ) ); } if ( '' === trim( wp_strip_all_tags( $content ) ) ) { return new WP_Error( 'desktop_mode_comments_empty_reply', __( 'Reply cannot be empty.', 'desktop-mode' ), array( 'status' => 400 ) ); } $user = wp_get_current_user(); if ( ! $user || ! $user->ID ) { return new WP_Error( 'desktop_mode_comments_unauthenticated', __( 'You must be logged in to reply.', 'desktop-mode' ), array( 'status' => 401 ) ); } $comment_data = array( 'comment_post_ID' => (int) $parent->comment_post_ID, 'comment_parent' => $parent_id, 'user_id' => (int) $user->ID, 'comment_author' => (string) $user->display_name, 'comment_author_email' => (string) $user->user_email, 'comment_author_url' => (string) $user->user_url, 'comment_content' => $content, 'comment_approved' => 1, 'comment_type' => 'comment', ); $new_id = wp_new_comment( wp_slash( $comment_data ), true ); if ( is_wp_error( $new_id ) ) { return $new_id; } $new = get_comment( $new_id ); return new WP_REST_Response( array( 'id' => (int) $new_id, 'parent' => $parent_id, 'content' => $new ? (string) $new->comment_content : $content, 'date_gmt' => $new ? (string) $new->comment_date_gmt : '', 'author' => $user->display_name, 'avatarUrl' => (string) get_avatar_url( (int) $user->ID, array( 'size' => 96 ) ), ), 201 ); } /** * Author insights endpoint — drives the side drawer. * * Returns total/approved/pending/spam counts, oldest/newest comment * timestamps, the linked user id (if the email matches a registered * user), and a 0–100 reliability score. * * @since 0.19.0 * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function desktop_mode_comments_window_rest_insights( WP_REST_Request $request ) { $email = strtolower( urldecode( (string) $request['email'] ) ); if ( '' === $email || ! is_email( $email ) ) { return new WP_Error( 'desktop_mode_comments_invalid_email', __( 'Invalid author email.', 'desktop-mode' ), array( 'status' => 400 ) ); } $counts_by_status = array(); foreach ( array( 'approve', 'hold', 'spam', 'trash' ) as $status ) { $counts_by_status[ $status ] = (int) get_comments( array( 'author_email' => $email, 'status' => $status, 'count' => true, ) ); } $total = array_sum( $counts_by_status ); // Sample the oldest + newest record without loading every row. $oldest = get_comments( array( 'author_email' => $email, 'status' => 'all', 'orderby' => 'comment_date_gmt', 'order' => 'ASC', 'number' => 1, ) ); $newest = get_comments( array( 'author_email' => $email, 'status' => 'all', 'orderby' => 'comment_date_gmt', 'order' => 'DESC', 'number' => 1, ) ); $user = get_user_by( 'email', $email ); $reliability = 100; if ( $total > 0 ) { $bad = $counts_by_status['spam'] + $counts_by_status['trash']; $reliability = (int) round( max( 0, min( 100, 100 - ( $bad / $total ) * 100 ) ) ); } return new WP_REST_Response( array( 'email' => $email, 'total' => $total, 'counts' => $counts_by_status, 'oldest' => isset( $oldest[0] ) ? (string) $oldest[0]->comment_date_gmt : null, 'newest' => isset( $newest[0] ) ? (string) $newest[0]->comment_date_gmt : null, 'userId' => $user ? (int) $user->ID : 0, 'userName' => $user ? (string) $user->display_name : '', 'reliability' => $reliability, 'avatarUrl' => (string) get_avatar_url( $email, array( 'size' => 96 ) ), ), 200 ); } /** * Per-status counts. Used by the dock badge + the "N new" pill. * * @since 0.19.0 * * @return WP_REST_Response */ function desktop_mode_comments_window_rest_counts() { return new WP_REST_Response( desktop_mode_comments_window_counts(), 200 ); } /** * Internal helper — current comment counts as a flat array. * * @since 0.19.0 * * @return array */ function desktop_mode_comments_window_counts() { $counts = wp_count_comments(); return array( 'pending' => (int) $counts->moderated, 'approved' => (int) $counts->approved, 'spam' => (int) $counts->spam, 'trash' => (int) $counts->trash, 'total' => (int) $counts->total_comments, ); }