logger = $logger; } /** * Register the routes under wppilot/v1. * * @param callable $permission The backend callback-token check. */ public function register_routes( callable $permission ): void { register_rest_route( 'wppilot/v1', '/comments', array( 'methods' => 'GET', 'callback' => array( $this, 'rest_list' ), 'permission_callback' => $permission, 'args' => array( 'status' => array( 'validate_callback' => static fn( $value ): bool => is_string( $value ) && in_array( $value, self::STATUSES, true ), ), 'limit' => array( 'validate_callback' => static fn( $value ): bool => is_numeric( $value ) && (int) $value >= 1 && (int) $value <= self::MAX_ITEMS, ), 'ids' => array( 'validate_callback' => static fn( $value ): bool => null !== self::parse_ids( $value ), ), ), ) ); register_rest_route( 'wppilot/v1', '/comments/status', array( 'methods' => 'POST', 'callback' => array( $this, 'rest_set_status' ), 'permission_callback' => $permission, ) ); } /** * GET /comments — counts per status, and the newest comments of one status * (default: waiting for moderation). With `ids`, exactly those comments in * whatever status they are now — how a prepared change is re-read. * * @param \WP_REST_Request $request `status` (one of STATUSES), `limit` (1–200) * or `ids` (comma-separated, at most 200). */ public function rest_list( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $denied = $this->deny_unless_moderator(); if ( $denied ) { return $denied; } $status = (string) ( $request->get_param( 'status' ) ?? 'unapproved' ); $limit = (int) ( $request->get_param( 'limit' ) ?? self::MAX_ITEMS ); $limit = max( 1, min( self::MAX_ITEMS, $limit ) ); $ids = self::parse_ids( $request->get_param( 'ids' ) ); $counts = wp_count_comments(); $comments = get_comments( null === $ids || array() === $ids ? array( 'status' => self::query_status( $status ), 'number' => $limit, 'orderby' => 'comment_date_gmt', 'order' => 'DESC', ) : array( 'comment__in' => $ids, 'status' => 'any', 'number' => count( $ids ), ) ); $comments = is_array( $comments ) ? $comments : array(); $approved_authors = $this->authors_with_approved_comments( $comments ); $out = array(); foreach ( $comments as $comment ) { $out[] = self::describe( $comment, $approved_authors ); } return new \WP_REST_Response( array( 'counts' => array( 'unapproved' => (int) ( $counts->moderated ?? 0 ), 'approved' => (int) ( $counts->approved ?? 0 ), 'spam' => (int) ( $counts->spam ?? 0 ), 'trash' => (int) ( $counts->trash ?? 0 ), ), 'status' => $status, 'comments' => $out, 'site_locale' => (string) get_locale(), 'akismet_active' => defined( 'AKISMET_VERSION' ), 'empty_trash_days' => self::empty_trash_days(), ), 200 ); } /** * POST /comments/status — move each listed comment from `from` to `to`, * only while it is still in `from`. * * Per comment, one of: `changed` (now in `to`), `skipped` (it was no longer * in `from`; its current status is reported), `not_found`, `forbidden`, * `refused` (a move to a trash that is switched off) or `failed` (WordPress * did not end up with `to`). * * @param \WP_REST_Request $request `changes`: list of {id, from, to}. */ public function rest_set_status( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { $denied = $this->deny_unless_moderator(); if ( $denied ) { return $denied; } $changes = self::validate_changes( $request->get_param( 'changes' ) ); if ( $changes instanceof \WP_Error ) { return $changes; } $results = array(); $tally = array( 'changed' => 0, 'skipped' => 0, 'not_found' => 0, 'forbidden' => 0, 'refused' => 0, 'failed' => 0, ); foreach ( $changes as $change ) { $result = $this->apply_change( $change['id'], $change['from'], $change['to'] ); $results[] = $result; $tally[ $result['result'] ] = ( $tally[ $result['result'] ] ?? 0 ) + 1; } $this->logger->info( sprintf( 'Comment moderation: %d changed, %d skipped, %d not found, %d refused, %d failed.', $tally['changed'], $tally['skipped'], $tally['not_found'], $tally['refused'] + $tally['forbidden'], $tally['failed'] ) ); return new \WP_REST_Response( array( 'results' => $results, 'counts' => $tally, ), 200 ); } /** * The request's changes, validated whole: 1–200 of `{id, from, to}` with a * positive id, two different known statuses, and no id twice. * * @param mixed $raw The `changes` parameter. * @return array|\WP_Error */ public static function validate_changes( mixed $raw ): array|\WP_Error { if ( ! is_array( $raw ) || array() === $raw || count( $raw ) > self::MAX_ITEMS ) { return self::bad_request( sprintf( 'changes must list 1 to %d comments.', self::MAX_ITEMS ) ); } $out = array(); $seen = array(); foreach ( $raw as $entry ) { $id = is_array( $entry ) && is_numeric( $entry['id'] ?? null ) ? (int) $entry['id'] : 0; $from = is_array( $entry ) ? ( $entry['from'] ?? null ) : null; $to = is_array( $entry ) ? ( $entry['to'] ?? null ) : null; if ( $id <= 0 || ! in_array( $from, self::STATUSES, true ) || ! in_array( $to, self::STATUSES, true ) || $from === $to ) { return self::bad_request( 'Each change needs a comment id and two different statuses.' ); } if ( isset( $seen[ $id ] ) ) { return self::bad_request( 'A comment may appear only once.' ); } $seen[ $id ] = true; $out[] = array( 'id' => $id, 'from' => (string) $from, 'to' => (string) $to, ); } return $out; } /** * Move one comment, if it is still where the caller saw it. * * @param int $id Comment id. * @param string $from The status the caller expects it to be in. * @param string $to The status to move it to. * @return array{id: int, result: string, status: string|null} */ private function apply_change( int $id, string $from, string $to ): array { $comment = get_comment( $id ); if ( ! $comment ) { return self::result( $id, 'not_found', null ); } if ( ! current_user_can( 'edit_comment', $id ) ) { return self::result( $id, 'forbidden', self::status_of( $id ) ); } $current = self::status_of( $id ); if ( $current !== $from ) { return self::result( $id, 'skipped', $current ); } if ( 'trash' === $to && self::empty_trash_days() <= 0 ) { // wp_trash_comment() deletes permanently when the trash is off. return self::result( $id, 'refused', $current ); } self::move( $id, $current, $to ); $after = self::status_of( $id ); return self::result( $id, $after === $to ? 'changed' : 'failed', $after ); } /** * Move a comment through WordPress's own functions, so their hooks fire. * * Leaving spam or the trash goes through `wp_unspam_comment` / * `wp_untrash_comment` first, which restore the status WordPress saved when * the comment went in; only if that is not the requested one is the status * then set directly. * * @param int $id Comment id. * @param string $current Its status now. * @param string $to Where it goes. */ private static function move( int $id, string $current, string $to ): void { if ( 'spam' === $to ) { wp_spam_comment( $id ); return; } if ( 'trash' === $to ) { wp_trash_comment( $id ); return; } if ( 'spam' === $current ) { wp_unspam_comment( $id ); } elseif ( 'trash' === $current ) { wp_untrash_comment( $id ); } if ( self::status_of( $id ) !== $to ) { wp_set_comment_status( $id, 'approved' === $to ? 'approve' : 'hold' ); } } /** * What the backend may know about one comment. * * The author's email is reduced to its domain; the IP address and user * agent are not returned at all. Text is plain and capped. * * @param \WP_Comment $comment The comment. * @param array $approved_authors Lower-cased emails with an approved comment. * @return array */ public static function describe( $comment, array $approved_authors ): array { $raw = (string) $comment->comment_content; $text = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $raw ) ) ); $email = strtolower( trim( (string) $comment->comment_author_email ) ); $at = strrpos( $email, '@' ); $post_id = (int) $comment->comment_post_ID; $post = $post_id > 0 ? get_post( $post_id ) : null; $akismet = (string) get_comment_meta( (int) $comment->comment_ID, 'akismet_result', true ); $type = (string) $comment->comment_type; return array( 'id' => (int) $comment->comment_ID, 'post_id' => $post_id, 'post_title' => $post ? (string) get_the_title( $post ) : '', 'post_type' => $post ? (string) $post->post_type : '', 'post_date_gmt' => $post ? (string) $post->post_date_gmt : '', 'author' => (string) $comment->comment_author, 'author_email_domain' => false === $at ? '' : substr( $email, $at + 1 ), 'author_url' => (string) $comment->comment_author_url, 'author_is_user' => (int) $comment->user_id > 0, 'author_has_approved' => '' !== $email && isset( $approved_authors[ $email ] ), 'date_gmt' => (string) $comment->comment_date_gmt, 'type' => '' === $type ? 'comment' : $type, 'parent' => (int) $comment->comment_parent, 'content' => self::truncate( $text, self::EXCERPT_CHARS ), 'content_length' => self::length( $text ), 'link_count' => self::link_count( $raw ), 'akismet' => 'true' === $akismet ? 'spam' : ( 'false' === $akismet ? 'ham' : null ), 'status' => self::status_of( (int) $comment->comment_ID ), ); } /** * How many links a comment's stored text carries: anchors, BBCode `[url]` * tags and bare web addresses, each counted once. * * @param string $raw The comment as stored. */ public static function link_count( string $raw ): int { $anchors = preg_match_all( '/]*href\s*=/i', $raw ); $bbcode = preg_match_all( '/\[url[=\]]/i', $raw ); // A bare address outside an anchor or a BBCode tag. $stripped = (string) preg_replace( array( '/]*>.*?<\/a>/is', '/\[url[=\]].*?\[\/url\]/is' ), ' ', $raw ); $bare = preg_match_all( '#\b(?:https?://|www\.)[^\s<>"\']+#i', $stripped ); return (int) $anchors + (int) $bbcode + (int) $bare; } /** * Which of these comments' authors already have an approved comment — the * check WordPress itself uses for "comment author must have a previously * approved comment". One query for the whole page. * * @param array $comments Comments being listed. * @return array Lower-cased email => true. */ private function authors_with_approved_comments( array $comments ): array { global $wpdb; $emails = array(); foreach ( $comments as $comment ) { $email = strtolower( trim( (string) $comment->comment_author_email ) ); if ( '' !== $email ) { $emails[ $email ] = true; } } if ( array() === $emails ) { return array(); } $list = array_keys( $emails ); $placeholders = implode( ', ', array_fill( 0, count( $list ), '%s' ) ); $table = $wpdb->comments; // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- one bounded lookup per request; the table name comes from $wpdb and the placeholders are generated for exactly the values passed. $rows = (array) $wpdb->get_col( $wpdb->prepare( "SELECT DISTINCT LOWER(comment_author_email) FROM {$table} WHERE comment_approved = '1' AND comment_author_email IN ({$placeholders})", ...$list ) ); // phpcs:enable $out = array(); foreach ( $rows as $row ) { $out[ (string) $row ] = true; } return $out; } /** * `ids` as a list of positive integers: null when malformed or over the * limit, an empty list when absent. * * @param mixed $raw Comma-separated ids, or an array of them. * @return array|null */ public static function parse_ids( mixed $raw ): ?array { if ( null === $raw || '' === $raw ) { return array(); } $parts = is_array( $raw ) ? $raw : explode( ',', (string) $raw ); $ids = array(); foreach ( $parts as $part ) { if ( ! is_numeric( $part ) || (int) $part <= 0 ) { return null; } $ids[ (int) $part ] = (int) $part; } return count( $ids ) > self::MAX_ITEMS ? null : array_values( $ids ); } /** A comment's status in this API's vocabulary, or null when WordPress has none for it. */ private static function status_of( int $id ): ?string { $status = wp_get_comment_status( $id ); return is_string( $status ) && in_array( $status, self::STATUSES, true ) ? $status : null; } /** The `status` argument get_comments() expects for one of ours. */ private static function query_status( string $status ): string { $map = array( 'unapproved' => 'hold', 'approved' => 'approve', 'spam' => 'spam', 'trash' => 'trash', ); return $map[ $status ] ?? 'hold'; } /** Days until WordPress empties the trash; 0 means a trashed comment is deleted at once. */ private static function empty_trash_days(): int { return defined( 'EMPTY_TRASH_DAYS' ) ? (int) EMPTY_TRASH_DAYS : 30; } /** @return array{id: int, result: string, status: string|null} */ private static function result( int $id, string $result, ?string $status ): array { return array( 'id' => $id, 'result' => $result, 'status' => $status, ); } private static function truncate( string $text, int $max ): string { if ( function_exists( 'mb_substr' ) ) { return mb_strlen( $text ) > $max ? mb_substr( $text, 0, $max ) : $text; } return strlen( $text ) > $max ? substr( $text, 0, $max ) : $text; } private static function length( string $text ): int { return function_exists( 'mb_strlen' ) ? mb_strlen( $text ) : strlen( $text ); } private function deny_unless_moderator(): ?\WP_Error { if ( current_user_can( 'moderate_comments' ) ) { return null; } return new \WP_Error( 'wppilot_forbidden', 'The connected user cannot moderate comments.', array( 'status' => 403 ) ); } private static function bad_request( string $message ): \WP_Error { return new \WP_Error( 'wppilot_bad_request', $message, array( 'status' => 400 ) ); } }