PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
← All changes | includes/my-wordpress/comment-stats.php +179 -42 0.9.81.1.10 View file →
@@ -1,7 +1,7 @@
1 1 <?php
2 2 /**
3 - * Desktop Mode — My WordPress: per-comment dossier endpoint.
3 + * OpenStation — My WordPress: per-comment dossier endpoint.
4 4 *
5 5 * `GET /desktop-mode/v1/comment-stats/<id>` returns the rendered
6 6 * comment + author + parent post + thread context + reply tree +
7 7 * a count of how active the author has been across the site. Powers
@@ -7,16 +7,36 @@
7 7 * a count of how active the author has been across the site. Powers
8 8 * the right preview pane in the My WordPress folder when a comment
9 9 * is selected.
10 10 *
11 - * Permissions:
12 - * - The comment must be readable by the current user (approved,
13 - * OR the user can `moderate_comments`, OR they're the comment
14 - * author).
11 + * Permissions, in gate order:
12 + * - The route wears the module's authorization gate,
13 + * `openstation_my_wordpress_user_can_use()` — `edit_posts` by
14 + * default, filterable. (That gate does *not* decide WP Explorer's
15 + * window or launcher; the app declares its own capabilities. See
16 + * the helper's docblock in `window.php`.)
17 + * - The caller must be able to read the comment's parent post —
18 + * see `openstation_my_wordpress_can_read_comment_post()` — so a
19 + * low-capability author cannot read comments on posts they can't
20 + * otherwise see: private, sealed behind a password, or of a post
21 + * type with no readable front end at all.
22 + * - Past those two gates, the comment itself must be visible —
23 + * `openstation_my_wordpress_comment_is_visible()`: approved, OR the
24 + * user can `moderate_comments`, OR they're the comment author.
25 + * - The thread around it is scoped to the post those two gates just
26 + * authorized: `comment_post_ID` and `comment_parent` are independent
27 + * columns, so "the parent of a readable comment" is not by itself a
28 + * readable comment. Within that post the two thread members are
29 + * filtered differently:
30 + * - the parent runs the same visibility test as the requested
31 + * comment (approved, moderator, or own);
32 + * - replies are approved only — plus pending ones for a
33 + * moderator, as a moderation aid. Spam and trash never ship,
34 + * and there is no own-reply exception.
15 35 * - Author email / IP / user-agent only ship to viewers with
16 36 * `moderate_comments`.
17 37 *
18 - * @package WPDesktopMode
38 + * @package OpenStation
19 39 */
20 40
21 41 defined( 'ABSPATH' ) || exit;
22 42
@@ -22,17 +42,22 @@
22 42
23 43 /**
24 44 * Register the route.
25 45 */
26 -function desktop_mode_my_wordpress_register_comment_stats_route() {
46 +function openstation_my_wordpress_register_comment_stats_route() {
27 47 register_rest_route(
28 48 'desktop-mode/v1',
29 49 '/comment-stats/(?P<id>\d+)',
30 50 array(
31 51 'methods' => WP_REST_Server::READABLE,
32 - 'callback' => 'desktop_mode_my_wordpress_comment_stats_callback',
52 + 'callback' => 'openstation_my_wordpress_comment_stats_callback',
33 53 'permission_callback' => static function () {
34 - return is_user_logged_in();
54 + // The dossier is an author's tool, so it wears the
55 + // module's authorization gate rather than a gate of its
56 + // own. Bare is_user_logged_in() let any subscriber read
57 + // it (OPENSTA-155). WP Explorer's window and launcher
58 + // are gated separately, by the app's own capabilities.
59 + return openstation_my_wordpress_user_can_use();
35 60 },
36 61 'args' => array(
37 62 'id' => array(
38 63 'required' => true,
@@ -42,37 +67,133 @@
42 67 ),
43 68 )
44 69 );
45 70 }
46 -add_action( 'rest_api_init', 'desktop_mode_my_wordpress_register_comment_stats_route' );
71 +add_action( 'rest_api_init', 'openstation_my_wordpress_register_comment_stats_route' );
47 72
48 73 /**
74 + * Whether the current user may read the post a comment belongs to.
75 + *
76 + * Three gates, in order:
77 + *
78 + * 1. **No parent at all.** An orphaned comment (`comment_post_ID` of
79 + * 0, or a post since deleted) has nothing to authorize against, so
80 + * it is moderators-only.
81 + * 2. **A sealed parent** needs `edit_post` — the escape hatch
82 + * `WP_REST_Posts_Controller::check_password_required()` grants.
83 + * Note `post_password_required()` reads the `wp-postpass` cookie:
84 + * a caller who has already entered the password is *not* looking at
85 + * a sealed post, and falls through to the gates below like any
86 + * other reader.
87 + * 3. **A parent whose post type has no readable front end** needs
88 + * `edit_post` too. This is the branch `read_post` alone misses:
89 + * `map_meta_cap()` resolves `read_post` on a *published* post to
90 + * the type's `read` capability, which is plain `read` on any post
91 + * type registered with `map_meta_cap`, and every logged-in user
92 + * holds it. So a published post of an internal post type — a
93 + * plugin's submission log, queue or internal note, none of which
94 + * a visitor can open — would otherwise read like a public post.
95 + * `openstation_ai_can_read_post()` takes the same position.
96 + *
97 + * Anything else is Core's `read_post`, matching
98 + * `WP_REST_Comments_Controller::check_read_post_permission()`. Unlike
99 + * the AI sibling this keeps `read_post` as the floor for viewable types
100 + * rather than short-circuiting publicly viewable posts: that helper
101 + * serves search, which is about public content, while this one mirrors
102 + * Core's single-comment read.
103 + *
104 + * @param WP_Post|null $post Parent post, or null when it no longer exists.
105 + * @return bool
106 + */
107 +function openstation_my_wordpress_can_read_comment_post( $post ) {
108 + if ( ! $post ) {
109 + return current_user_can( 'moderate_comments' );
110 + }
111 + if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) {
112 + return false;
113 + }
114 + $post_type = get_post_type_object( $post->post_type );
115 + if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) {
116 + return current_user_can( 'edit_post', $post->ID );
117 + }
118 + return current_user_can( 'read_post', $post->ID );
119 +}
120 +
121 +/**
122 + * Whether a comment's own moderation state lets the current user see it.
123 + *
124 + * The parent-post gate above decides whether the caller may see comments
125 + * on that post at all; this decides whether they may see *this* comment:
126 + * approved ones are public, anything pending, spam or trashed is for
127 + * moderators and for the person who wrote it.
128 + *
129 + * Applied to the requested comment and to the thread parent alike —
130 + * the parent is reached by id, not by a query that filters on status,
131 + * so without this its excerpt would ship whatever its status. Replies
132 + * are NOT run through it: their query filters on status in SQL, with
133 + * narrower rules (see the replies section of the callback).
134 + *
135 + * @param WP_Comment $comment Comment to test.
136 + * @return bool
137 + */
138 +function openstation_my_wordpress_comment_is_visible( $comment ) {
139 + if ( '1' === (string) $comment->comment_approved ) {
140 + return true;
141 + }
142 + if ( current_user_can( 'moderate_comments' ) ) {
143 + return true;
144 + }
145 + $author_id = (int) $comment->user_id;
146 + return $author_id > 0 && (int) get_current_user_id() === $author_id;
147 +}
148 +
149 +/**
49 150 * Aggregator callback.
50 151 *
51 152 * @param WP_REST_Request $request REST request.
52 153 * @return array|WP_Error
53 154 */
54 -function desktop_mode_my_wordpress_comment_stats_callback( $request ) {
155 +function openstation_my_wordpress_comment_stats_callback( $request ) {
55 156 global $wpdb;
157 + // `\d+` matches 0 and absint() keeps it, so the zero has to be
158 + // refused here: get_comment( 0 ) falls back to $GLOBALS['comment'],
159 + // which would answer /comment-stats/0 with whatever comment another
160 + // plugin happened to leave in the global instead of the documented
161 + // 404. Same hazard as get_post( 0 ) below, one level up.
56 162 $comment_id = (int) $request->get_param( 'id' );
57 - $comment = get_comment( $comment_id );
163 + $comment = $comment_id > 0 ? get_comment( $comment_id ) : null;
58 164 if ( ! $comment ) {
59 165 return new WP_Error(
60 - 'desktop_mode_comment_not_found',
166 + 'openstation_comment_not_found',
61 167 __( 'Comment not found.', 'desktop-mode' ),
62 168 array( 'status' => 404 )
63 169 );
64 170 }
65 171
172 + // Object-level authorization: refuse when the caller can't read
173 + // the comment's parent post (OPENSTA-155). Fetched once here and
174 + // reused for the parent-post payload below. The explicit zero
175 + // check matters: get_post( 0 ) falls back to the global post, so
176 + // an orphaned comment would be authorized against whatever post
177 + // happened to be global instead of hitting the moderators-only
178 + // branch.
179 + $post = $comment->comment_post_ID
180 + ? get_post( (int) $comment->comment_post_ID )
181 + : null;
182 + if ( ! openstation_my_wordpress_can_read_comment_post( $post ) ) {
183 + return new WP_Error(
184 + 'openstation_comment_forbidden',
185 + __( 'You do not have permission to view this comment.', 'desktop-mode' ),
186 + array( 'status' => 403 )
187 + );
188 + }
189 +
66 190 $can_moderate = current_user_can( 'moderate_comments' );
67 191 $is_approved = '1' === (string) $comment->comment_approved;
68 - $is_self = is_user_logged_in()
69 - && (int) get_current_user_id() === (int) $comment->user_id
70 - && (int) $comment->user_id > 0;
71 192
72 - if ( ! $is_approved && ! $can_moderate && ! $is_self ) {
193 + if ( ! openstation_my_wordpress_comment_is_visible( $comment ) ) {
73 194 return new WP_Error(
74 - 'desktop_mode_comment_forbidden',
195 + 'openstation_comment_forbidden',
75 196 __( 'You do not have permission to view this comment.', 'desktop-mode' ),
76 197 array( 'status' => 403 )
77 198 );
78 199 }
@@ -77,21 +198,22 @@
77 198 );
78 199 }
79 200
80 201 // ----- Comment body ------------------------------------------------
81 - $content_filtered = apply_filters( 'comment_text', $comment->comment_content, $comment, array() );
202 + /** This filter is documented in wp-includes/comment-template.php */
203 + $content_filtered = apply_filters( 'comment_text', $comment->comment_content, $comment, array() ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- Core's filter, applied so comment bodies render as they do everywhere else.
82 204 $body = array(
83 - 'id' => (int) $comment->comment_ID,
84 - 'parent' => (int) $comment->comment_parent,
85 - 'date' => mysql2date( 'c', $comment->comment_date_gmt, false ),
86 - 'status' => $is_approved
205 + 'id' => (int) $comment->comment_ID,
206 + 'parent' => (int) $comment->comment_parent,
207 + 'date' => mysql2date( 'c', $comment->comment_date_gmt, false ),
208 + 'status' => $is_approved
87 209 ? 'approved'
88 210 : ( '0' === (string) $comment->comment_approved
89 211 ? 'pending'
90 212 : (string) $comment->comment_approved ),
91 - 'rendered' => (string) $content_filtered,
213 + 'rendered' => (string) $content_filtered,
92 214 'rendered_raw' => (string) $comment->comment_content,
93 - 'editLink' => $can_moderate
215 + 'editLink' => $can_moderate
94 216 ? esc_url_raw(
95 217 admin_url(
96 218 'comment.php?action=editcomment&c=' . $comment->comment_ID
97 219 )
@@ -120,31 +242,30 @@
120 242 }
121 243 if ( $author['userId'] > 0 ) {
122 244 $user = get_userdata( $author['userId'] );
123 245 if ( $user ) {
124 - $author['displayName'] = $user->display_name;
125 - $author['profileLink'] = get_author_posts_url( $user->ID );
246 + $author['displayName'] = $user->display_name;
247 + $author['profileLink'] = get_author_posts_url( $user->ID );
126 248 }
127 249 }
128 250
129 251 // ----- Parent post -------------------------------------------------
130 - $post = get_post( (int) $comment->comment_post_ID );
131 252 $post_payload = null;
132 253 if ( $post ) {
133 - $post_author = $post->post_author > 0
254 + $post_author = $post->post_author > 0
134 255 ? get_userdata( (int) $post->post_author )
135 256 : null;
136 257 $post_payload = array(
137 - 'id' => (int) $post->ID,
138 - 'title' => get_the_title( $post ),
139 - 'link' => (string) get_permalink( $post ),
258 + 'id' => (int) $post->ID,
259 + 'title' => get_the_title( $post ),
260 + 'link' => (string) get_permalink( $post ),
140 261 'editLink' => current_user_can( 'edit_post', $post->ID )
141 262 ? (string) get_edit_post_link( $post->ID, 'raw' )
142 263 : '',
143 - 'status' => (string) $post->post_status,
144 - 'type' => (string) $post->post_type,
145 - 'date' => mysql2date( 'c', $post->post_date_gmt, false ),
146 - 'author' => $post_author
264 + 'status' => (string) $post->post_status,
265 + 'type' => (string) $post->post_type,
266 + 'date' => mysql2date( 'c', $post->post_date_gmt, false ),
267 + 'author' => $post_author
147 268 ? array(
148 269 'id' => (int) $post_author->ID,
149 270 'name' => $post_author->display_name,
150 271 'avatarUrl' => (string) get_avatar_url(
@@ -156,17 +277,26 @@
156 277 );
157 278 }
158 279
159 280 // ----- Parent comment (if this is a reply) -------------------------
281 + // Only the thread above it on the SAME post, and only if its own
282 + // status allows. The gates at the top authorized one post and one
283 + // comment; `comment_post_ID` and `comment_parent` are independent
284 + // columns, and wp_insert_comment() will happily write a parent that
285 + // lives on another post, so an excerpt from an unreadable post could
286 + // otherwise ride in here on a readable comment.
160 287 $parent_payload = null;
161 288 if ( (int) $comment->comment_parent > 0 ) {
162 289 $parent_comment = get_comment( (int) $comment->comment_parent );
163 - if ( $parent_comment ) {
290 + if ( $parent_comment
291 + && (int) $parent_comment->comment_post_ID === (int) $comment->comment_post_ID
292 + && openstation_my_wordpress_comment_is_visible( $parent_comment )
293 + ) {
164 294 $parent_payload = array(
165 - 'id' => (int) $parent_comment->comment_ID,
295 + 'id' => (int) $parent_comment->comment_ID,
166 296 'authorName' => (string) $parent_comment->comment_author,
167 - 'date' => mysql2date( 'c', $parent_comment->comment_date_gmt, false ),
168 - 'excerpt' => wp_trim_words(
297 + 'date' => mysql2date( 'c', $parent_comment->comment_date_gmt, false ),
298 + 'excerpt' => wp_trim_words(
169 299 wp_strip_all_tags( $parent_comment->comment_content ),
170 300 40
171 301 ),
172 302 );
@@ -173,8 +303,13 @@
173 303 }
174 304 }
175 305
176 306 // ----- Replies (direct children) -----------------------------------
307 + // Scoped to the authorized post for the same reason as the parent
308 + // above: `comment_parent` alone would pull in a comment stored
309 + // against a post the caller cannot read. A reply on another post is
310 + // not a reply to this thread anyway.
311 + //
177 312 // Static SQL literal — must not go through a %s placeholder, which
178 313 // would quote it into an adjacent string literal and break the clause.
179 314 $reply_status_sql = $can_moderate
180 315 ? "comment_approved IN ( '0', '1' )"
@@ -185,12 +320,14 @@
185 320 "SELECT comment_ID, comment_author, comment_author_email,
186 321 comment_date_gmt, comment_content, comment_approved, user_id
187 322 FROM {$wpdb->comments}
188 323 WHERE comment_parent = %d
324 + AND comment_post_ID = %d
189 325 AND {$reply_status_sql}
190 326 ORDER BY comment_date_gmt ASC
191 327 LIMIT 20",
192 - $comment->comment_ID
328 + $comment->comment_ID,
329 + $comment->comment_post_ID
193 330 ),
194 331 ARRAY_A
195 332 );
196 333 $replies = array();
@@ -250,9 +387,9 @@
250 387 * @param array $payload Stats payload.
251 388 * @param int $comment_id Comment id.
252 389 */
253 390 return apply_filters(
254 - 'desktop_mode_my_wordpress_comment_stats',
391 + 'openstation_my_wordpress_comment_stats',
255 392 $payload,
256 393 $comment_id
257 394 );
258 395 }