PluginProbe
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code / 5.5.3
Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code v5.5.3
5.5.3 3.1.0 3.2.0 3.2.1 3.3.0 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.6.0 3.7.0 3.8.0 3.8.1 3.8.2 3.8.3 4.0.0 4.0.2 4.0.3 4.0.4 4.0.5 4.1.0 4.1.1 All 78 releases
copy-the-code / includes / analytics / class-rest.php

class-rest.php in Copy Anything to Clipboard for WordPress – Copy Button, Copy Text & Copy Code 5.5.3, at includes/analytics/class-rest.php

491 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Analytics REST API
4 *
5 * Registers analytics REST endpoints (events, stats, trends, summary).
6 *
7 * @package CTC
8 * @since 5.3.0
9 */
10
11 namespace CTC\Analytics;
12
13 use CTC\Analytics\Database;
14 use CTC\Helper;
15
16 /**
17 * Analytics REST API
18 *
19 * @since 5.3.0
20 */
21 class Rest {
22
23 /**
24 * Instance
25 *
26 * @since 5.3.0
27 *
28 * @access private
29 * @var Rest|null
30 */
31 private static $instance;
32
33 /**
34 * REST namespace
35 *
36 * @var string
37 */
38 private $namespace = 'ctc/v1';
39
40 /**
41 * Get instance.
42 *
43 * @since 5.3.0
44 *
45 * @return Rest
46 */
47 public static function get() {
48 if ( null === self::$instance ) {
49 self::$instance = new self();
50 }
51 return self::$instance;
52 }
53
54 /**
55 * Constructor
56 *
57 * @since 5.3.0
58 */
59 public function __construct() {
60 add_action( 'rest_api_init', [ $this, 'register_routes' ] );
61 }
62
63 /**
64 * Register analytics REST routes
65 *
66 * @since 5.3.0
67 * @return void
68 */
69 public function register_routes() {
70 register_rest_route(
71 $this->namespace,
72 '/analytics/events',
73 [
74 'methods' => 'POST',
75 'callback' => [ $this, 'track_event' ],
76 'permission_callback' => '__return_true',
77 ]
78 );
79
80 register_rest_route(
81 $this->namespace,
82 '/analytics/stats',
83 [
84 'methods' => 'GET',
85 'callback' => [ $this, 'get_analytics_stats' ],
86 'permission_callback' => [ $this, 'check_permissions' ],
87 ]
88 );
89
90 register_rest_route(
91 $this->namespace,
92 '/analytics/rules/(?P<id>\d+)/stats',
93 [
94 'methods' => 'GET',
95 'callback' => [ $this, 'get_rule_stats' ],
96 'permission_callback' => [ $this, 'check_permissions' ],
97 'args' => [
98 'id' => [
99 'required' => true,
100 'validate_callback' => function ( $param ) {
101 return is_numeric( $param );
102 },
103 ],
104 ],
105 ]
106 );
107
108 register_rest_route(
109 $this->namespace,
110 '/analytics/trends',
111 [
112 'methods' => 'GET',
113 'callback' => [ $this, 'get_analytics_trends' ],
114 'permission_callback' => [ $this, 'check_permissions' ],
115 ]
116 );
117
118 register_rest_route(
119 $this->namespace,
120 '/analytics/summary',
121 [
122 'methods' => 'GET',
123 'callback' => [ $this, 'get_analytics_summary' ],
124 'permission_callback' => [ $this, 'check_permissions' ],
125 ]
126 );
127 }
128
129 /**
130 * Check permissions for analytics endpoints (admin).
131 *
132 * @since 5.3.0
133 * @return bool
134 */
135 public function check_permissions() {
136 return current_user_can( 'manage_options' );
137 }
138
139 /**
140 * Track copy event.
141 * Writes to ctc_analytics (single table for free and Pro). Accepts full payload from frontend.
142 *
143 * @since 5.3.0
144 * @param \WP_REST_Request $request Request object.
145 * @return \WP_REST_Response|\WP_Error
146 */
147 public function track_event( $request ) {
148 // Basic abuse protection: per-IP rate limiting.
149 if ( $this->is_rate_limited( $request ) ) {
150 return new \WP_Error(
151 'ctc_analytics_rate_limited',
152 __( 'Too many analytics events from this client. Please slow down.', 'ctc' ),
153 [ 'status' => 429 ]
154 );
155 }
156
157 $data = $request->get_json_params();
158
159 $metadata = isset( $data['metadata'] ) && is_array( $data['metadata'] ) ? $data['metadata'] : [];
160 $error_reason = isset( $data['error_reason'] ) ? sanitize_text_field( $data['error_reason'] ) : null;
161 $failure_reason = isset( $data['failure_reason'] ) ? sanitize_text_field( $data['failure_reason'] ) : $error_reason;
162 $source = isset( $data['source'] ) ? sanitize_text_field( $data['source'] ) : ( isset( $metadata['source'] ) ? sanitize_text_field( $metadata['source'] ) : 'global-injector' );
163 $post_id = isset( $data['post_id'] ) ? absint( $data['post_id'] ) : ( isset( $metadata['post_id'] ) ? absint( $metadata['post_id'] ) : null );
164 $post_type = isset( $data['post_type'] ) ? sanitize_text_field( $data['post_type'] ) : ( isset( $metadata['post_type'] ) ? sanitize_text_field( $metadata['post_type'] ) : null );
165 $page_url = isset( $data['page_url'] ) ? esc_url_raw( $data['page_url'] ) : ( isset( $metadata['page_url'] ) ? esc_url_raw( $metadata['page_url'] ) : null );
166
167 $event_data = [
168 'rule_id' => isset( $data['rule_id'] ) ? absint( $data['rule_id'] ) : null,
169 'source' => $source ?: 'global-injector',
170 'success' => isset( $data['success'] ) ? (bool) $data['success'] : true,
171 'failure_reason' => $failure_reason,
172 'post_id' => $post_id,
173 'post_type' => $post_type,
174 'page_url' => $page_url,
175 'device' => isset( $data['device'] ) ? sanitize_text_field( $data['device'] ) : null,
176 'browser' => isset( $data['browser'] ) ? sanitize_text_field( $data['browser'] ) : null,
177 ];
178
179 // Validate payload schema and field lengths.
180 $validation_error = $this->validate_event_payload( $event_data );
181 if ( $validation_error ) {
182 return $validation_error;
183 }
184
185 $db = Database::get();
186 $event_id = $db->insert_event( $event_data );
187
188 if ( false === $event_id ) {
189 return new \WP_Error(
190 'event_insert_failed',
191 __( 'Failed to track event.', 'ctc' ),
192 [ 'status' => 500 ]
193 );
194 }
195
196 return rest_ensure_response(
197 [
198 'success' => true,
199 'event_id' => $event_id,
200 ]
201 );
202 }
203
204 /**
205 * Determine if the current request should be rate limited.
206 *
207 * Uses a simple per-IP counter over a short time window stored in a transient.
208 *
209 * @since 5.4.0
210 * @param \WP_REST_Request $request Request object.
211 * @return bool True when rate limit exceeded.
212 */
213 private function is_rate_limited( $request ) {
214 $ip = $this->get_client_ip();
215
216 // If we cannot determine the IP, skip rate limiting rather than blocking.
217 if ( ! $ip ) {
218 return false;
219 }
220
221 $key = 'ctc_analytics_rate_' . md5( $ip );
222 $window = (int) apply_filters( 'ctc/analytics/rate_limit_window', 60 ); // seconds.
223 $max_requests = (int) apply_filters( 'ctc/analytics/rate_limit_max_requests', 60 ); // events per window.
224
225 $data = get_transient( $key );
226
227 $now = time();
228
229 if ( ! is_array( $data ) || ! isset( $data['count'], $data['expires_at'] ) || $data['expires_at'] <= $now ) {
230 // New window.
231 $data = [
232 'count' => 1,
233 'expires_at' => $now + $window,
234 ];
235 set_transient( $key, $data, $window );
236 return false;
237 }
238
239 if ( $data['count'] >= $max_requests ) {
240 return true;
241 }
242
243 ++$data['count'];
244 set_transient( $key, $data, $data['expires_at'] - $now );
245
246 return false;
247 }
248
249 /**
250 * Get best-effort client IP for rate limiting.
251 *
252 * @since 5.4.0
253 * @return string Client IP or empty string on failure.
254 */
255 private function get_client_ip() {
256 $ip = '';
257
258 if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
259 // If multiple IPs, take the first one.
260 $parts = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
261 $ip = trim( $parts[0] );
262 } elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
263 $ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
264 }
265
266 /**
267 * Filter the IP used for analytics rate limiting.
268 *
269 * @since 5.4.0
270 *
271 * @param string $ip Client IP address.
272 */
273 $ip = apply_filters( 'ctc/analytics/client_ip', $ip );
274
275 return $ip;
276 }
277
278 /**
279 * Validate analytics event payload.
280 *
281 * Ensures types and maximum lengths to protect the database schema
282 * and avoid obviously invalid data.
283 *
284 * @since 5.4.0
285 * @param array $event_data Normalized event data.
286 * @return \WP_Error|null
287 */
288 private function validate_event_payload( array $event_data ) {
289 // rule_id must be null or positive integer.
290 if ( null !== $event_data['rule_id'] && ( ! is_int( $event_data['rule_id'] ) || $event_data['rule_id'] < 0 ) ) {
291 return new \WP_Error(
292 'ctc_analytics_invalid_rule_id',
293 __( 'Invalid rule ID for analytics event.', 'ctc' ),
294 [ 'status' => 400 ]
295 );
296 }
297
298 // Source bounded length.
299 if ( isset( $event_data['source'] ) && strlen( (string) $event_data['source'] ) > 64 ) {
300 return new \WP_Error(
301 'ctc_analytics_invalid_source',
302 __( 'Invalid analytics event source.', 'ctc' ),
303 [ 'status' => 400 ]
304 );
305 }
306
307 // Failure reason bounded length.
308 if ( isset( $event_data['failure_reason'] ) && strlen( (string) $event_data['failure_reason'] ) > 255 ) {
309 return new \WP_Error(
310 'ctc_analytics_invalid_failure_reason',
311 __( 'Failure reason is too long for analytics event.', 'ctc' ),
312 [ 'status' => 400 ]
313 );
314 }
315
316 // Device / browser bounded length.
317 foreach ( [ 'device', 'browser' ] as $field ) {
318 if ( isset( $event_data[ $field ] ) && strlen( (string) $event_data[ $field ] ) > 100 ) {
319 return new \WP_Error(
320 'ctc_analytics_invalid_' . $field,
321 __( 'Analytics event contains invalid user agent metadata.', 'ctc' ),
322 [ 'status' => 400 ]
323 );
324 }
325 }
326
327 // Page URL bounded length.
328 if ( isset( $event_data['page_url'] ) && strlen( (string) $event_data['page_url'] ) > 2048 ) {
329 return new \WP_Error(
330 'ctc_analytics_invalid_page_url',
331 __( 'Analytics event URL is too long.', 'ctc' ),
332 [ 'status' => 400 ]
333 );
334 }
335
336 return null;
337 }
338
339 /**
340 * Get analytics summary (total_30d, total_all) from ctc_analytics.
341 *
342 * @since 5.3.0
343 * @param \WP_REST_Request $request Request object.
344 * @return \WP_REST_Response
345 */
346 public function get_analytics_summary( $request ) {
347 $db = Database::get();
348 $now = Helper::mysql_now();
349 $date_30d = Helper::mysql_date_ago( '-30 days' );
350 $total_30d = $db->get_total_copies( $date_30d, $now, null );
351 $date_epoch = Helper::mysql_epoch();
352 $total_all = $db->get_total_copies( $date_epoch, $now, null );
353 $response = [
354 'total_30d' => (int) $total_30d,
355 'total_all' => (int) $total_all,
356 ];
357 $response = apply_filters( 'ctc/analytics/summary_response', $response, $request );
358 return rest_ensure_response( $response );
359 }
360
361 /**
362 * Get analytics stats.
363 *
364 * @since 5.3.0
365 * @param \WP_REST_Request $request Request object.
366 * @return \WP_REST_Response
367 */
368 public function get_analytics_stats( $request ) {
369 $date_from = $request->get_param( 'date_from' );
370 $date_to = $request->get_param( 'date_to' );
371 $rule_id = $request->get_param( 'rule_id' );
372
373 if ( ! $date_from ) {
374 $date_from = Helper::mysql_date_ago( '-30 days' );
375 }
376 if ( ! $date_to ) {
377 $date_to = Helper::mysql_now();
378 }
379
380 $db = Database::get();
381
382 $total_copies = $db->get_total_copies( $date_from, $date_to, $rule_id ? absint( $rule_id ) : null );
383 $active_rules = $db->get_active_rules_count( $date_from, $date_to );
384 $top_rules = $db->get_top_rules( 1, $date_from, $date_to );
385 $top_rule = null;
386 if ( ! empty( $top_rules ) ) {
387 $top_rule_data = $top_rules[0];
388 $top_rule_post = get_post( $top_rule_data['rule_id'] );
389 if ( $top_rule_post ) {
390 $top_rule = [
391 'id' => $top_rule_data['rule_id'],
392 'name' => $top_rule_post->post_title,
393 'count' => (int) $top_rule_data['count'],
394 ];
395 }
396 }
397
398 $now = Helper::mysql_now();
399 $ts = time();
400 $date_24h_ago = Helper::mysql_date_ago( '-24 hours', $ts );
401 $date_48h_ago = Helper::mysql_date_ago( '-48 hours', $ts );
402 $current_24h = $db->get_total_copies( $date_24h_ago, $now, $rule_id ? absint( $rule_id ) : null );
403 $previous_24h = $db->get_total_copies( $date_48h_ago, $date_24h_ago, $rule_id ? absint( $rule_id ) : null );
404 $change_percent = $db->calculate_change_percent( $current_24h, $previous_24h );
405
406 return rest_ensure_response(
407 [
408 'success' => true,
409 'total_copies' => $total_copies,
410 'active_rules' => $active_rules,
411 'top_rule' => $top_rule,
412 'change_percent' => $change_percent,
413 ]
414 );
415 }
416
417 /**
418 * Get rule-specific stats.
419 *
420 * @since 5.3.0
421 * @param \WP_REST_Request $request Request object.
422 * @return \WP_REST_Response|\WP_Error
423 */
424 public function get_rule_stats( $request ) {
425 $rule_id = absint( $request->get_param( 'id' ) );
426
427 $post = get_post( $rule_id );
428 if ( ! $post || 'copy-to-clipboard' !== $post->post_type ) {
429 return new \WP_Error(
430 'rule_not_found',
431 __( 'Rule not found.', 'ctc' ),
432 [ 'status' => 404 ]
433 );
434 }
435
436 $date_from = $request->get_param( 'date_from' );
437 $date_to = $request->get_param( 'date_to' );
438
439 if ( ! $date_from ) {
440 $date_from = Helper::mysql_date_ago( '-30 days' );
441 }
442 if ( ! $date_to ) {
443 $date_to = Helper::mysql_now();
444 }
445
446 $db = Database::get();
447 $stats = $db->get_rule_stats( $rule_id, $date_from, $date_to );
448
449 return rest_ensure_response(
450 [
451 'success' => true,
452 'stats' => $stats,
453 ]
454 );
455 }
456
457 /**
458 * Get analytics trends (timeline data).
459 *
460 * @since 5.3.0
461 * @param \WP_REST_Request $request Request object.
462 * @return \WP_REST_Response
463 */
464 public function get_analytics_trends( $request ) {
465 $date_from = $request->get_param( 'date_from' );
466 $date_to = $request->get_param( 'date_to' );
467 $group_by = $request->get_param( 'group_by' ) ?: 'hour';
468
469 if ( ! in_array( $group_by, [ 'hour', 'day' ], true ) ) {
470 $group_by = 'hour';
471 }
472
473 if ( ! $date_from ) {
474 $date_from = Helper::mysql_date_ago( '-24 hours' );
475 }
476 if ( ! $date_to ) {
477 $date_to = Helper::mysql_now();
478 }
479
480 $db = Database::get();
481 $trends = $db->get_trends( $date_from, $date_to, $group_by );
482
483 return rest_ensure_response(
484 [
485 'success' => true,
486 'trends' => $trends,
487 ]
488 );
489 }
490 }
491