PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.1
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / REST / Feedback.php

Feedback.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.8.1, at includes/REST/Feedback.php

292 lines 9.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- live reaction analytics writes; cache would defeat the purpose.
3 namespace WPDeveloper\BetterDocs\REST;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit;
7 }
8
9 use WP_REST_Request;
10 use WPDeveloper\BetterDocs\Core\BaseAPI;
11
12 class Feedback extends BaseAPI {
13 /**
14 * The reaction/feedback beacon is public (logged-out visitors react), so it is
15 * gated by a wp_rest nonce the frontend sends via the X-WP-Nonce header —
16 * mirroring the analytics view beacon ({@see REST\AnalyticsTracker}). Without
17 * this it inherited BaseAPI::permission_check() (return true) and could be
18 * scripted anonymously to forge reaction counts and flood the Pro feedback
19 * table (one row per call via the betterdocs_feedback_recorded action). A light
20 * salted-IP throttle bounds abuse even if a nonce is harvested from a page.
21 */
22 public function permission_check( $request = null ) {
23 if ( ! $request instanceof WP_REST_Request ) {
24 return false;
25 }
26
27 $nonce = $request->get_header( 'x_wp_nonce' );
28 if ( empty( $nonce ) ) {
29 $nonce = $request->get_param( '_wpnonce' );
30 }
31 if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) {
32 return false;
33 }
34
35 // Defense-in-depth: cap reactions per client (salted IP hash, raw IP never
36 // stored) so a harvested nonce can't be scripted into a table flood.
37 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
38 $key = 'bd_feedback_rl_' . substr( wp_hash( $ip ), 0, 20 );
39 $hits = (int) get_transient( $key );
40 if ( $hits >= 120 ) {
41 return new \WP_Error( 'bd_feedback_throttled', __( 'Too many reactions — please try again in a moment.', 'betterdocs' ), [ 'status' => 429 ] );
42 }
43 set_transient( $key, $hits + 1, 10 * MINUTE_IN_SECONDS );
44
45 return true;
46 }
47
48 /**
49 * @return mixed
50 */
51 public function register() {
52 $this->post(
53 '/feedback/(?P<id>\d+)',
54 [ $this, 'save' ],
55 [
56 'id' => [
57 'type' => 'integer',
58 'validate_callback' => function ( $param, $request, $key ) {
59 return ! empty( $param ) && is_numeric( $param ) && get_post( $param ) !== null;
60 },
61 'required' => false,
62 'default' => null
63 ],
64 'feelings' => [
65 'type' => 'string',
66 'validate_callback' => function ( $param, $request, $key ) {
67 $allowed_feelings = [ 'happy', 'sad', 'normal' ];
68 return in_array( $param, $allowed_feelings );
69 },
70 'required' => true
71 ]
72 ]
73 );
74
75 $this->register_field(
76 'docs',
77 'word_count',
78 [
79 'get_callback' => [ $this, 'get_word_count' ]
80 ]
81 );
82
83 $this->register_field(
84 'docs',
85 'total_views',
86 [
87 'get_callback' => [ $this, 'get_total_views' ]
88 ]
89 );
90
91 $this->register_field(
92 'docs',
93 'reactions',
94 [
95 'get_callback' => [ $this, 'get_reaction_count' ]
96 ]
97 );
98
99 $this->register_field(
100 'docs',
101 'author_info',
102 [
103 'get_callback' => [ $this, 'get_author_info' ]
104 ]
105 );
106
107 $this->register_field(
108 'docs',
109 'doc_category_info',
110 [
111 'get_callback' => [ $this, 'get_doc_category_info' ]
112 ]
113 );
114
115 $this->register_field(
116 'docs',
117 'doc_tag_info',
118 [
119 'get_callback' => [ $this, 'get_doc_tag_info' ]
120 ]
121 );
122
123 // $this->register_field( 'docs', 'author_list', [
124 // 'get_callback' => [$this, 'get_author_list']
125 // ] );
126 }
127
128 public function get_author_list( $object, $field_name, $request ) {
129 $args = [
130 'fields' => [
131 'ID',
132 'user_login',
133 'display_name'
134 ]
135 ];
136 $users = get_users( $args );
137 return $users;
138 }
139
140 public function analytics_by_post_id( $post_id ) {
141 global $wpdb;
142
143 return $wpdb->get_results(
144 $wpdb->prepare(
145 "SELECT
146 sum(impressions) as totalViews,
147 sum(unique_visit) as totalUniqueViews,
148 sum(happy + sad + normal) as totalReactions,
149 sum(happy) as totalHappy,
150 sum(normal) as totalNormal,
151 sum(sad) as totalSad
152 FROM {$wpdb->prefix}betterdocs_analytics
153 WHERE post_id = %d",
154 (int) $post_id
155 )
156 );
157 }
158
159 public function get_word_count( $object, $field_name, $request ) {
160 return str_word_count( trim( wp_strip_all_tags( get_post_field( 'post_content', $object['id'] ) ) ) );
161 }
162
163 public function get_total_views( $object, $field_name, $request ) {
164 $analytics = $this->analytics_by_post_id( $object['id'] );
165
166 if ( ! empty( $analytics ) ) {
167 return isset( $analytics[0]->totalViews ) ? $analytics[0]->totalViews : 0;
168 } else {
169 return 0;
170 }
171 }
172
173 public function get_reaction_count( $object, $field_name, $request ) {
174 $analytics = $this->analytics_by_post_id( $object['id'] );
175
176 if ( ! empty( $analytics ) ) {
177 return [
178 'happy' => isset( $analytics[0]->totalHappy ) ? $analytics[0]->totalHappy : 0,
179 'normal' => isset( $analytics[0]->totalNormal ) ? $analytics[0]->totalNormal : 0,
180 'sad' => isset( $analytics[0]->totalSad ) ? $analytics[0]->totalSad : 0
181 ];
182 } else {
183 return [
184 'happy' => 0,
185 'normal' => 0,
186 'sad' => 0
187 ];
188 }
189 }
190
191 public function save( WP_REST_Request $request ) {
192 global $wpdb;
193 $docs_id = isset( $request['id'] ) ? (int) $request['id'] : null;
194 $valid_feelings = [ 'happy', 'normal', 'sad' ];
195 $requested_feeling = isset( $request['feelings'] ) ? (string) $request['feelings'] : 'happy';
196 $feelings = in_array( $requested_feeling, $valid_feelings, true ) ? $requested_feeling : 'happy';
197 $analytics_table = $wpdb->prefix . 'betterdocs_analytics';
198 if ( $docs_id !== null && get_post( $docs_id ) && get_option( 'betterdocs_db_version' ) == true ) {
199 // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $analytics_table = $wpdb->prefix + literal; per-request reaction lookup, no cache layer applies.
200 $post_id = $wpdb->get_results(
201 $wpdb->prepare(
202 "SELECT * FROM {$analytics_table} WHERE created_at = %s AND post_id = %d",
203 gmdate( 'Y-m-d' ),
204 $docs_id
205 )
206 );
207 // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
208
209 if ( ! empty( $post_id ) ) {
210 $feelings_increment = (int) $post_id[0]->{$feelings} + 1;
211 // $feelings is validated above against $valid_feelings allowlist — safe to interpolate as column identifier.
212 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $analytics_table = $wpdb->prefix + literal; per-request reaction counter, no cache layer applies.
213 $insert = $wpdb->query(
214 $wpdb->prepare(
215 "UPDATE {$analytics_table} SET {$feelings} = %d WHERE created_at = %s AND post_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
216 $feelings_increment,
217 gmdate( 'Y-m-d' ),
218 $docs_id
219 )
220 );
221 } else {
222 // $feelings is validated above against $valid_feelings allowlist — safe to interpolate as column identifier.
223 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter -- $analytics_table = $wpdb->prefix + literal; per-request reaction counter, no cache layer applies.
224 $insert = $wpdb->query(
225 $wpdb->prepare(
226 "INSERT INTO {$analytics_table} ( post_id, {$feelings}, created_at ) VALUES ( %d, %d, %s )", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
227 $docs_id,
228 1,
229 gmdate( 'Y-m-d' )
230 )
231 );
232 }
233
234 if ( $insert == true ) {
235 /**
236 * Fires after a reaction is recorded into the daily aggregate.
237 * Pro hooks this to write a per-item row into the feedback inbox
238 * table (betterdocs_analytics_feedback).
239 *
240 * @param int $docs_id Doc post id.
241 * @param string $feelings happy|sad|normal.
242 */
243 do_action( 'betterdocs_feedback_recorded', (int) $docs_id, $feelings );
244 return true;
245 }
246 }
247 return false;
248 }
249
250 public function get_author_info( $object, $field_name, $request ) {
251 $author_id = isset( $object['author'] ) ? $object['author'] : '';
252 if ( ! empty( $author_id ) ) {
253 return [
254 'name' => get_the_author_meta( 'display_name', $author_id ),
255 'author_nicename' => get_the_author_meta( 'nicename', $author_id ),
256 'author_url' => get_author_posts_url( $author_id )
257 ];
258 }
259 return [];
260 }
261
262 public function get_doc_category_info( $object, $field_name, $request ) {
263 $category_term_names = [];
264 $doc_categories = ! empty( $object['doc_category'] ) ? $object['doc_category'] : [];
265 foreach ( $doc_categories as $doc_category_id ) {
266 array_push(
267 $category_term_names,
268 [
269 'term_name' => get_term( $doc_category_id )->name,
270 'term_url' => get_term_link( $doc_category_id )
271 ]
272 );
273 }
274 return $category_term_names;
275 }
276
277 public function get_doc_tag_info( $object, $field_name, $request ) {
278 $doc_tag_term_names = [];
279 $doc_tags = ! empty( $object['doc_tag'] ) ? $object['doc_tag'] : [];
280 foreach ( $doc_tags as $tag_id ) {
281 array_push(
282 $doc_tag_term_names,
283 [
284 'term_name' => get_term( $tag_id )->name,
285 'term_url' => get_term_link( $tag_id )
286 ]
287 );
288 }
289 return $doc_tag_term_names;
290 }
291 }
292