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 +149 -13 1.1.21.1.10 View file →
@@ -7,12 +7,32 @@
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 38 * @package OpenStation
@@ -30,9 +50,14 @@
30 50 array(
31 51 'methods' => WP_REST_Server::READABLE,
32 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,
@@ -45,8 +70,84 @@
45 70 }
46 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
@@ -52,10 +153,15 @@
52 153 * @return array|WP_Error
53 154 */
54 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 166 'openstation_comment_not_found',
61 167 __( 'Comment not found.', 'desktop-mode' ),
@@ -62,15 +168,30 @@
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 195 'openstation_comment_forbidden',
75 196 __( 'You do not have permission to view this comment.', 'desktop-mode' ),
76 197 array( 'status' => 403 )
@@ -127,9 +248,8 @@
127 248 }
128 249 }
129 250
130 251 // ----- Parent post -------------------------------------------------
131 - $post = get_post( (int) $comment->comment_post_ID );
132 252 $post_payload = null;
133 253 if ( $post ) {
134 254 $post_author = $post->post_author > 0
135 255 ? get_userdata( (int) $post->post_author )
@@ -157,12 +277,21 @@
157 277 );
158 278 }
159 279
160 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.
161 287 $parent_payload = null;
162 288 if ( (int) $comment->comment_parent > 0 ) {
163 289 $parent_comment = get_comment( (int) $comment->comment_parent );
164 - 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 + ) {
165 294 $parent_payload = array(
166 295 'id' => (int) $parent_comment->comment_ID,
167 296 'authorName' => (string) $parent_comment->comment_author,
168 297 'date' => mysql2date( 'c', $parent_comment->comment_date_gmt, false ),
@@ -174,8 +303,13 @@
174 303 }
175 304 }
176 305
177 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 + //
178 312 // Static SQL literal — must not go through a %s placeholder, which
179 313 // would quote it into an adjacent string literal and break the clause.
180 314 $reply_status_sql = $can_moderate
181 315 ? "comment_approved IN ( '0', '1' )"
@@ -186,12 +320,14 @@
186 320 "SELECT comment_ID, comment_author, comment_author_email,
187 321 comment_date_gmt, comment_content, comment_approved, user_id
188 322 FROM {$wpdb->comments}
189 323 WHERE comment_parent = %d
324 + AND comment_post_ID = %d
190 325 AND {$reply_status_sql}
191 326 ORDER BY comment_date_gmt ASC
192 327 LIMIT 20",
193 - $comment->comment_ID
328 + $comment->comment_ID,
329 + $comment->comment_post_ID
194 330 ),
195 331 ARRAY_A
196 332 );
197 333 $replies = array();