PluginProbe
Force Refresh / 3.2.0
Force Refresh v3.2.0
3.2.1 3.2.0 3.1.2 3.1.1 3.1.0 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.2.0 2.0.0 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.10.0 2.10.1 2.10.2 2.11.0 2.11.1 2.12.0 All 54 releases
force-refresh / includes / api / classes / class-api-handler-admin-debug-email.php

class-api-handler-admin-debug-email.php in Force Refresh 3.2.0, at includes/api/classes/class-api-handler-admin-debug-email.php

437 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * API handler for sending a debug report email.
4 *
5 * @package ForceRefresh
6 */
7
8 namespace JordanLeven\Plugins\ForceRefresh\Api;
9
10 use JordanLeven\Plugins\ForceRefresh;
11 use JordanLeven\Plugins\ForceRefresh\Api\Api_Handler_Admin;
12 use JordanLeven\Plugins\ForceRefresh\Api\Interfaces\Api_Handler_Admin_Interface;
13 use JordanLeven\Plugins\ForceRefresh\Api\Api_Handler_Admin_Schedule_Refresh_Site;
14 use JordanLeven\Plugins\ForceRefresh\Services\Cdn_Detection_Service;
15 use JordanLeven\Plugins\ForceRefresh\Services\Options_Storage_Service;
16 use JordanLeven\Plugins\ForceRefresh\Services\Versions_Storage_Service;
17
18 /**
19 * Main class controller.
20 */
21 class Api_Handler_Admin_Debug_Email extends Api_Handler_Admin implements Api_Handler_Admin_Interface {
22
23 /**
24 * The path for this endpoint.
25 *
26 * @var string
27 */
28 const ENDPOINT_PATH = '/debug-email';
29
30 /**
31 * The version for this endpoint.
32 *
33 * @var int
34 */
35 const ENDPOINT_VERSION = 1;
36
37 /**
38 * The recipient address for debug reports.
39 *
40 * @var string
41 */
42 const RECIPIENT_EMAIL = 'force-refresh@jordanleven.com';
43
44 /**
45 * Method for registering the endpoints for this class.
46 *
47 * @return void
48 */
49 public function register_routes(): void {
50 self::register_rest_endpoint(
51 self::ENDPOINT_PATH,
52 self::ENDPOINT_VERSION,
53 array(
54 'methods' => \WP_REST_Server::READABLE,
55 'callback' => array( $this, 'get_debug_email' ),
56 'permission_callback' => $this->get_admin_permission_callback(),
57 ),
58 );
59 self::register_rest_endpoint(
60 self::ENDPOINT_PATH,
61 self::ENDPOINT_VERSION,
62 array(
63 'methods' => \WP_REST_Server::CREATABLE,
64 'callback' => array( $this, 'send_debug_email' ),
65 'permission_callback' => $this->get_admin_permission_callback(),
66 ),
67 );
68 }
69
70 /**
71 * Handles the request to return the debug payload.
72 *
73 * @return \WP_REST_Response
74 */
75 public function get_debug_email(): \WP_REST_Response {
76 $current_user = wp_get_current_user();
77
78 return $this->return_api_response(
79 \WP_Http::OK,
80 'Successfully retrieved debug data.',
81 array(
82 'debugData' => $this->get_debug_data_with_keys(),
83 'submitterEmail' => ! empty( $current_user->user_email ) ? $current_user->user_email : null,
84 )
85 );
86 }
87
88 /**
89 * Returns a standardized error response for support topic validation.
90 *
91 * @param int $status_code The HTTP status code.
92 * @param string $message_key The translation key for the error.
93 *
94 * @return \WP_REST_Response
95 */
96 private function get_support_topic_error_response( int $status_code, string $message_key ): \WP_REST_Response {
97 return $this->return_api_response(
98 $status_code,
99 $message_key,
100 array(
101 'field' => 'supportTopicUrl',
102 )
103 );
104 }
105
106 /**
107 * Builds the ordered list of rows for the debug preview UI.
108 *
109 * @return array Array of { key, value } objects.
110 */
111 private function get_debug_data_with_keys(): array {
112 $payload = $this->get_debug_data();
113
114 return array(
115 array(
116 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_SITE_NAME',
117 'value' => $payload['siteName'],
118 ),
119 array(
120 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_SITE_URL',
121 'value' => $payload['siteUrl'],
122 ),
123 array(
124 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_FR_VERSION',
125 'value' => $payload['forceRefreshVersion'],
126 ),
127 array(
128 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_SITE_VERSION',
129 'value' => $payload['siteVersion'],
130 ),
131 array(
132 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_REFRESH_INTERVAL',
133 'value' => sprintf( '%ss', $payload['refreshInterval'] ),
134 ),
135 array(
136 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_WP_VERSION',
137 'value' => $payload['wordPressVersion'],
138 ),
139 array(
140 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_PHP_VERSION',
141 'value' => $payload['phpVersion'],
142 ),
143 ...$this->get_scheduled_refresh_debug_rows( $payload['scheduledRefreshes'] ),
144 array(
145 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_LAST_CRON_RUN',
146 'value' => $payload['lastCronRun'] ?? __( 'Never', 'force-refresh' ),
147 ),
148 array(
149 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_UPDATE_METHOD',
150 'value' => $payload['staticFilePollingEnabled'] ? __( 'Static file', 'force-refresh' ) : __( 'WordPress API', 'force-refresh' ),
151 ),
152 array(
153 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_DETECTED_CDN',
154 'value' => $payload['detectedCdn'] ?? __( 'None detected', 'force-refresh' ),
155 ),
156 );
157 }
158
159 /**
160 * Builds the debug payload from the current WordPress environment.
161 *
162 * @return array The debug payload.
163 */
164 private function get_debug_data(): array {
165 $plugin_data = ForceRefresh\get_force_refresh_plugin_data();
166
167 $scheduled_refreshes = Api_Handler_Admin_Schedule_Refresh_Site::get_scheduled_refreshes();
168 $last_cron_run = Api_Handler_Admin_Schedule_Refresh_Site::get_last_cron_run();
169
170 return array(
171 'siteUrl' => get_bloginfo( 'url' ),
172 'siteName' => get_bloginfo( 'name' ),
173 'wordPressVersion' => ForceRefresh\get_wordpress_version(),
174 'phpVersion' => phpversion(),
175 'forceRefreshVersion' => $plugin_data['Version'],
176 'siteVersion' => Versions_Storage_Service::get_site_version(),
177 'refreshInterval' => Options_Storage_Service::get_refresh_interval(),
178 'scheduledRefreshes' => $this->format_scheduled_refreshes( $scheduled_refreshes ),
179 'lastCronRun' => $this->format_timestamp_utc( $last_cron_run ),
180 'staticFilePollingEnabled' => Options_Storage_Service::get_use_static_file_polling(),
181 'detectedCdn' => Cdn_Detection_Service::get_detected_cdn(),
182 );
183 }
184
185 /**
186 * Build the scheduled refresh rows for the debug modal payload.
187 *
188 * @param array $scheduled_refreshes The formatted scheduled refresh dates.
189 *
190 * @return array
191 */
192 private function get_scheduled_refresh_debug_rows( array $scheduled_refreshes ): array {
193 if ( empty( $scheduled_refreshes ) ) {
194 return array(
195 array(
196 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_SCHEDULED_REFRESHES',
197 'value' => __( 'None', 'force-refresh' ),
198 ),
199 );
200 }
201
202 return array_map(
203 fn( $date, $index ) => array(
204 'key' => 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_LABEL_SCHEDULED_REFRESH',
205 'index' => $index + 1,
206 'value' => $date,
207 ),
208 $scheduled_refreshes,
209 array_keys( $scheduled_refreshes )
210 );
211 }
212
213 /**
214 * Format scheduled refresh timestamps for debug output.
215 *
216 * @param array $scheduled_refreshes The raw scheduled refresh data.
217 *
218 * @return array
219 */
220 private function format_scheduled_refreshes( array $scheduled_refreshes ): array {
221 return array_map(
222 fn( $scheduled_refresh ) => $this->format_timestamp_utc( $scheduled_refresh['timestamp'] ),
223 $scheduled_refreshes
224 );
225 }
226
227 /**
228 * Format a Unix timestamp as a UTC string for debug output.
229 *
230 * @param int|null $timestamp The timestamp to format.
231 *
232 * @return string|null
233 */
234 private function format_timestamp_utc( ?int $timestamp ): ?string {
235 if ( empty( $timestamp ) ) {
236 return null;
237 }
238
239 return gmdate( 'F j, Y \a\t g:i:s A', $timestamp ) . ' UTC';
240 }
241
242 /**
243 * Validates that the provided URL is a WordPress.org support topic URL.
244 *
245 * @param string $support_topic_url The user-provided support topic URL.
246 *
247 * @return bool Whether the URL shape is valid.
248 */
249 private function is_valid_support_topic_url( string $support_topic_url ): bool {
250 $parsed_url = wp_parse_url( $support_topic_url );
251
252 // Support topic has to be on the wordpress.org domain.
253 if ( empty( $parsed_url['host'] ) || 'wordpress.org' !== strtolower( $parsed_url['host'] ) ) {
254 return false;
255 }
256
257 // Support topic has to use http or https.
258 if ( empty( $parsed_url['scheme'] ) || ! in_array( strtolower( $parsed_url['scheme'] ), array( 'http', 'https' ), true ) ) {
259 return false;
260 }
261
262 // Support topic path has to exist.
263 if ( empty( $parsed_url['path'] ) ) {
264 return false;
265 }
266
267 // Support topics have to match the WordPress support topic patterns.
268 return 1 === preg_match( '#^/support/topic/[^/]+/?$#', $parsed_url['path'] );
269 }
270
271 /**
272 * Checks whether the supplied WordPress.org support topic is unresolved.
273 *
274 * @param string $support_topic_url The validated topic URL.
275 *
276 * @return true|\WP_REST_Response True when unresolved, or an error response.
277 */
278 private function validate_support_topic_is_unresolved( string $support_topic_url ) {
279 $response = wp_remote_get(
280 $support_topic_url,
281 array(
282 'redirection' => 3,
283 'timeout' => 10,
284 )
285 );
286
287 if ( is_wp_error( $response ) ) {
288 return $this->get_support_topic_error_response(
289 \WP_Http::BAD_GATEWAY,
290 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_SUPPORT_URL_UNAVAILABLE'
291 );
292 }
293
294 $status_code = wp_remote_retrieve_response_code( $response );
295
296 if ( \WP_Http::OK !== $status_code ) {
297 return $this->get_support_topic_error_response(
298 \WP_Http::BAD_REQUEST,
299 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_SUPPORT_URL_NOT_FOUND'
300 );
301 }
302
303 $response_body = wp_remote_retrieve_body( $response );
304 $normalized = strtolower( preg_replace( '/\s+/', ' ', \wp_strip_all_tags( $response_body ) ) );
305
306 if ( false !== strpos( $normalized, 'status: not resolved' ) ) {
307 return true;
308 }
309
310 if ( false !== strpos( $normalized, 'status: resolved' ) ) {
311 return $this->get_support_topic_error_response(
312 \WP_Http::CONFLICT,
313 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_SUPPORT_URL_RESOLVED'
314 );
315 }
316
317 return $this->get_support_topic_error_response(
318 \WP_Http::BAD_GATEWAY,
319 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_SUPPORT_URL_UNCONFIRMED'
320 );
321 }
322
323 /**
324 * Formats the debug payload as a plain-text email body.
325 *
326 * @param array $payload The debug payload.
327 * @param string $support_topic_url The support topic URL provided by the user.
328 *
329 * @return string The formatted email body.
330 */
331 private function format_email_body( array $payload, string $support_topic_url ): string {
332 return implode(
333 "\n",
334 array(
335 'A Force Refresh debug report was submitted.',
336 '',
337 sprintf( 'Support Topic URL: %s', $support_topic_url ),
338 sprintf( 'Site Name: %s', $payload['siteName'] ),
339 sprintf( 'Site URL: %s', $payload['siteUrl'] ),
340 sprintf( 'Force Refresh Version: %s', $payload['forceRefreshVersion'] ),
341 sprintf( 'Current Site Version: %s', $payload['siteVersion'] ),
342 sprintf( 'Refresh Interval: %ss', $payload['refreshInterval'] ),
343 sprintf( 'WordPress Version: %s', $payload['wordPressVersion'] ),
344 sprintf( 'PHP Version: %s', $payload['phpVersion'] ),
345 ...$this->get_scheduled_refresh_email_lines( $payload['scheduledRefreshes'] ),
346 sprintf( 'Last Cron Run: %s', $payload['lastCronRun'] ?? 'Never' ),
347 sprintf( 'Update Method: %s', $payload['staticFilePollingEnabled'] ? 'Static file' : 'WordPress API' ),
348 sprintf( 'CDN: %s', $payload['detectedCdn'] ?? 'None detected' ),
349 '',
350 sprintf( 'Submitted: %s', gmdate( 'Y-m-d H:i:s T' ) ),
351 )
352 );
353 }
354
355 /**
356 * Build the scheduled refresh lines for the plain-text debug email body.
357 *
358 * @param array $scheduled_refreshes The formatted scheduled refresh dates.
359 *
360 * @return array
361 */
362 private function get_scheduled_refresh_email_lines( array $scheduled_refreshes ): array {
363 if ( empty( $scheduled_refreshes ) ) {
364 return array( 'Scheduled Refreshes: None' );
365 }
366
367 return array_map(
368 fn( $date, $index ) => sprintf( 'Scheduled Refresh %d: %s', $index + 1, $date ),
369 $scheduled_refreshes,
370 array_keys( $scheduled_refreshes )
371 );
372 }
373
374 /**
375 * Handles the request to send a debug report email.
376 *
377 * @param \WP_REST_Request $request The WordPress REST request.
378 *
379 * @return \WP_REST_Response
380 */
381 public function send_debug_email( \WP_REST_Request $request ): \WP_REST_Response {
382 $support_topic_url = esc_url_raw( trim( (string) $request->get_param( 'supportTopicUrl' ) ) );
383
384 if ( empty( $support_topic_url ) ) {
385 return $this->get_support_topic_error_response(
386 \WP_Http::BAD_REQUEST,
387 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_SUPPORT_URL_REQUIRED'
388 );
389 }
390
391 if ( ! $this->is_valid_support_topic_url( $support_topic_url ) ) {
392 return $this->get_support_topic_error_response(
393 \WP_Http::BAD_REQUEST,
394 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_SUPPORT_URL_INVALID'
395 );
396 }
397
398 // Only allow users to submit reports for support topics that are still unresolved.
399 $support_topic_validation = $this->validate_support_topic_is_unresolved( $support_topic_url );
400
401 if ( true !== $support_topic_validation ) {
402 return $support_topic_validation;
403 }
404
405 $payload = $this->get_debug_data();
406 $subject = sprintf( '[Force Refresh] Debug Report — %s', $payload['siteName'] );
407 $body = $this->format_email_body( $payload, $support_topic_url );
408 $current_user = wp_get_current_user();
409 $headers = $current_user->user_email
410 ? array( sprintf( 'Cc: %s', $current_user->user_email ) )
411 : array();
412
413 $sent = wp_mail( self::RECIPIENT_EMAIL, $subject, $body, $headers );
414
415 if ( ! $sent ) {
416 return $this->return_api_response(
417 \WP_Http::INTERNAL_SERVER_ERROR,
418 'ADMIN_TROUBLESHOOTING.DEBUG_MODAL_SEND_FAILED'
419 );
420 }
421
422 return $this->return_api_response(
423 \WP_Http::OK,
424 'Your debug report was sent successfully.'
425 );
426 }
427
428 /**
429 * Method for getting the endpoint for this service.
430 *
431 * @return string The service endpoint.
432 */
433 public static function get_rest_endpoint(): string {
434 return self::get_formatted_rest_endpoint( self::ENDPOINT_PATH, self::ENDPOINT_VERSION );
435 }
436 }
437