PluginProbe
Code Snippets / 4.0.0-beta.2
Code Snippets v4.0.0-beta.2
4.0.0-beta.2 3.10.2 3.10.1 3.10.0 3.10.0-beta.2 3.10.0-beta.1 4.0.0-beta.1 3.9.6 trunk 2.10.0 2.10.1 2.12.0 2.12.1 2.13.0 2.13.1 2.13.2 2.13.3 2.14.0 2.14.1 2.14.2 2.14.3 2.14.4 2.14.5 2.14.6 3.0.0 All 65 releases
code-snippets / php / REST_API / Feedback / Feedback_REST_Controller.php

Feedback_REST_Controller.php in Code Snippets 4.0.0-beta.2, at php/REST_API/Feedback/Feedback_REST_Controller.php

360 lines 10.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Code_Snippets\REST_API\Feedback;
4
5 use Code_Snippets\Admin\Feedback_Panel;
6 use Code_Snippets\Client\Feedback_Client;
7 use Code_Snippets\REST_API\REST_Controller;
8 use Code_Snippets\Utils\System_Info;
9 use WP_Error;
10 use WP_REST_Request;
11 use WP_REST_Response;
12 use WP_REST_Server;
13 use function Code_Snippets\code_snippets;
14
15 /**
16 * Accepts feedback reports from the panel and forwards them to the cloud.
17 *
18 * The browser never talks to the cloud directly: the site's credential stays on the server,
19 * and the environment attached to a report is collected here rather than being trusted from
20 * the request.
21 *
22 * @package Code_Snippets
23 */
24 class Feedback_REST_Controller extends REST_Controller {
25
26 /**
27 * Current API version.
28 */
29 public const VERSION = 1;
30
31 /**
32 * The base of this controller's route.
33 */
34 public const BASE_ROUTE = 'feedback';
35
36 /**
37 * Kinds of report the panel can send.
38 */
39 private const REPORT_TYPES = [ 'bug', 'feature', 'feedback' ];
40
41 /**
42 * How long, in seconds, a reporter waits between reports.
43 */
44 private const THROTTLE_TIMEOUT = 20;
45
46 /**
47 * Shortest search term worth sending to the cloud.
48 */
49 private const MIN_SEARCH_LENGTH = 4;
50
51 /**
52 * Most captured JavaScript errors to attach to a report.
53 */
54 private const MAX_JS_ERRORS = 10;
55
56 /**
57 * Shortest title that summarizes anything.
58 */
59 private const MIN_TITLE_LENGTH = 8;
60
61 /**
62 * Shortest free-text answer that describes anything.
63 */
64 private const MIN_TEXT_LENGTH = 20;
65
66 /**
67 * Client used to reach the cloud.
68 *
69 * @var Feedback_Client
70 */
71 private Feedback_Client $client;
72
73 /**
74 * Class constructor.
75 *
76 * @param Feedback_Client $client Client used to reach the cloud.
77 */
78 public function __construct( Feedback_Client $client ) {
79 $this->client = $client;
80
81 parent::__construct();
82 }
83
84 /**
85 * Register the reporting routes.
86 *
87 * @return void
88 */
89 public function register_routes() {
90 register_rest_route(
91 $this->namespace,
92 self::BASE_ROUTE,
93 [
94 'methods' => WP_REST_Server::CREATABLE,
95 'callback' => [ $this, 'send_report' ],
96 'permission_callback' => [ $this, 'permission_callback' ],
97 ]
98 );
99
100 register_rest_route(
101 $this->namespace,
102 self::BASE_ROUTE . '/search',
103 [
104 'methods' => WP_REST_Server::READABLE,
105 'callback' => [ $this, 'search_reports' ],
106 'permission_callback' => [ $this, 'permission_callback' ],
107 'args' => [
108 'q' => [
109 'description' => __( 'Report title to look for.', 'code-snippets' ),
110 'type' => 'string',
111 'required' => true,
112 ],
113 ],
114 ]
115 );
116 }
117
118 /**
119 * Determine whether the request may report feedback.
120 *
121 * The setting is checked alongside the capability so that switching the reporter off
122 * closes these routes rather than leaving a route to the cloud open behind a hidden panel.
123 *
124 * @param WP_REST_Request $request Incoming HTTP request.
125 *
126 * @return bool
127 */
128 public function permission_callback( WP_REST_Request $request ): bool {
129 return Feedback_Panel::is_enabled() && code_snippets()->current_user_can();
130 }
131
132 /**
133 * Offer reports resembling the title being typed.
134 *
135 * @param WP_REST_Request $request Incoming HTTP request.
136 *
137 * @return WP_REST_Response
138 */
139 public function search_reports( WP_REST_Request $request ): WP_REST_Response {
140 $query = trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) );
141
142 $results = self::text_length( $query ) < self::MIN_SEARCH_LENGTH
143 ? []
144 : $this->client->search_reports( $query );
145
146 return new WP_REST_Response( [ 'results' => $results ], 200 );
147 }
148
149 /**
150 * Validate a report and forward it to the cloud.
151 *
152 * @param WP_REST_Request $request Incoming HTTP request.
153 *
154 * @return WP_REST_Response|WP_Error
155 */
156 public function send_report( WP_REST_Request $request ) {
157 $invalid = $this->validate_report( $request );
158
159 if ( $invalid ) {
160 return $invalid;
161 }
162
163 $user = wp_get_current_user();
164 $throttle_key = 'code_snippets_feedback_' . $user->ID;
165
166 if ( get_transient( $throttle_key ) ) {
167 return new WP_Error(
168 'code_snippets_feedback_throttled',
169 __( 'That report was just sent. Wait a moment before sending another.', 'code-snippets' ),
170 [ 'status' => 429 ]
171 );
172 }
173
174 $response = $this->client->send_report(
175 $this->build_payload( $request ),
176 $this->get_idempotency_key( $request )
177 );
178
179 if ( is_wp_error( $response ) ) {
180 // The transport's own message names the cause — a blocked outbound request, a
181 // certificate problem, a timeout — which is the only way anyone can act on this.
182 return new WP_Error(
183 'code_snippets_feedback_transport',
184 sprintf(
185 /* translators: %s: error message describing why the request failed. */
186 __( 'Could not reach the reporting service: %s', 'code-snippets' ),
187 $response->get_error_message()
188 ),
189 [ 'status' => 502 ]
190 );
191 }
192
193 if ( $response['status'] < 200 || $response['status'] >= 300 ) {
194 return $this->translate_cloud_error( $response );
195 }
196
197 set_transient( $throttle_key, 1, self::THROTTLE_TIMEOUT );
198
199 return new WP_REST_Response(
200 [
201 'sent' => true,
202 'reference' => isset( $response['body']['reference'] ) ? sanitize_text_field( $response['body']['reference'] ) : '',
203 'url' => isset( $response['body']['url'] ) ? esc_url_raw( $response['body']['url'] ) : '',
204 ],
205 200
206 );
207 }
208
209 /**
210 * Count the characters in a value, as the panel counts them.
211 *
212 * The panel measures with JavaScript's string length, so counting bytes here would let
213 * a report through that the panel refused, and would measure non-Latin scripts against
214 * a limit several times longer than intended.
215 *
216 * @param string $value Value to measure.
217 *
218 * @return int
219 */
220 private static function text_length( string $value ): int {
221 return (int) preg_match_all( '/./us', $value );
222 }
223
224 /**
225 * Check a report says enough to be acted on.
226 *
227 * @param WP_REST_Request $request Incoming HTTP request.
228 *
229 * @return WP_Error|null Error describing the first problem found, or null when there is none.
230 */
231 private function validate_report( WP_REST_Request $request ): ?WP_Error {
232 $type = sanitize_key( (string) $request->get_param( 'type' ) );
233
234 if ( ! in_array( $type, self::REPORT_TYPES, true ) ) {
235 return new WP_Error(
236 'code_snippets_feedback_type',
237 __( 'Choose what kind of feedback this is.', 'code-snippets' ),
238 [ 'status' => 400 ]
239 );
240 }
241
242 if ( self::text_length( trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) ) ) < self::MIN_TITLE_LENGTH ) {
243 return new WP_Error(
244 'code_snippets_feedback_title',
245 __( 'Give the report a title of at least 8 characters.', 'code-snippets' ),
246 [ 'status' => 400 ]
247 );
248 }
249
250 if ( self::text_length( trim( sanitize_textarea_field( (string) $request->get_param( 'description' ) ) ) ) < self::MIN_TEXT_LENGTH ) {
251 return new WP_Error(
252 'code_snippets_feedback_description',
253 __( 'Describe the problem in a bit more detail.', 'code-snippets' ),
254 [ 'status' => 400 ]
255 );
256 }
257
258 if ( 'bug' === $type && self::text_length( trim( sanitize_textarea_field( (string) $request->get_param( 'steps' ) ) ) ) < self::MIN_TEXT_LENGTH ) {
259 return new WP_Error(
260 'code_snippets_feedback_steps',
261 __( 'List the steps that reproduce the bug.', 'code-snippets' ),
262 [ 'status' => 400 ]
263 );
264 }
265
266 return null;
267 }
268
269 /**
270 * Assemble the report sent to the cloud.
271 *
272 * @param WP_REST_Request $request Incoming HTTP request.
273 *
274 * @return array<string, mixed>
275 */
276 private function build_payload( WP_REST_Request $request ): array {
277 $user = wp_get_current_user();
278 $isolation = (array) $request->get_param( 'isolation' );
279 $browser = (array) $request->get_param( 'browser' );
280 $js_errors = array_slice( (array) $request->get_param( 'js_errors' ), 0, self::MAX_JS_ERRORS );
281
282 $name = trim( sanitize_text_field( (string) $request->get_param( 'name' ) ) );
283 $email = sanitize_email( (string) $request->get_param( 'email' ) );
284
285 $payload = [
286 'report' => [
287 'type' => sanitize_key( (string) $request->get_param( 'type' ) ),
288 'title' => trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) ),
289 'description' => trim( sanitize_textarea_field( (string) $request->get_param( 'description' ) ) ),
290 'steps' => trim( sanitize_textarea_field( (string) $request->get_param( 'steps' ) ) ),
291 'comments' => trim( sanitize_textarea_field( (string) $request->get_param( 'comments' ) ) ),
292 'isolation' => [
293 'plugin_only' => ! empty( $isolation['plugin_only'] ),
294 'blank_theme' => ! empty( $isolation['blank_theme'] ),
295 'reproducible' => ! empty( $isolation['reproducible'] ),
296 ],
297 'page_url' => esc_url_raw( (string) $request->get_param( 'page_url' ) ),
298 ],
299 'reporter' => [
300 'name' => $name ? $name : $user->display_name,
301 'email' => $email ? $email : $user->user_email,
302 'role' => implode( ', ', $user->roles ),
303 ],
304 'environment' => System_Info::get_system_info(),
305 'browser' => [
306 'user_agent' => isset( $browser['userAgent'] ) ? sanitize_text_field( $browser['userAgent'] ) : '',
307 'viewport' => isset( $browser['viewport'] ) ? sanitize_text_field( $browser['viewport'] ) : '',
308 'screen' => isset( $browser['screen'] ) ? sanitize_text_field( $browser['screen'] ) : '',
309 'language' => isset( $browser['language'] ) ? sanitize_text_field( $browser['language'] ) : '',
310 ],
311 'js_errors' => array_map( 'sanitize_textarea_field', $js_errors ),
312 'submitted_at' => gmdate( 'c' ),
313 ];
314
315 return apply_filters( 'code_snippets_feedback_payload', $payload );
316 }
317
318 /**
319 * Reduce the key naming a submission to the characters the cloud accepts.
320 *
321 * @param WP_REST_Request $request Incoming HTTP request.
322 *
323 * @return string
324 */
325 private function get_idempotency_key( WP_REST_Request $request ): string {
326 $key = sanitize_text_field( (string) $request->get_param( 'idempotency_key' ) );
327 $key = substr( preg_replace( '/[^A-Za-z0-9\-]/', '', $key ), 0, 64 );
328
329 return $key ? $key : wp_generate_uuid4();
330 }
331
332 /**
333 * Describe a report the cloud refused.
334 *
335 * A refusal the reporter can act on is passed through with its own status and wording;
336 * anything else is reported as a problem reaching the service.
337 *
338 * @param array{status: int, body: array<string, mixed>} $response Response from the cloud.
339 *
340 * @return WP_Error
341 */
342 private function translate_cloud_error( array $response ): WP_Error {
343 $is_client_error = $response['status'] >= 400 && $response['status'] < 500;
344
345 $message = $is_client_error && isset( $response['body']['message'] )
346 ? sanitize_text_field( $response['body']['message'] )
347 : __( 'The reporting service could not accept this report. Try again shortly.', 'code-snippets' );
348
349 $code = isset( $response['body']['code'] )
350 ? 'cloud_' . sanitize_key( $response['body']['code'] )
351 : 'code_snippets_feedback_rejected';
352
353 return new WP_Error(
354 $code,
355 $message,
356 [ 'status' => $is_client_error ? $response['status'] : 502 ]
357 );
358 }
359 }
360