Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.59% covered (warning)
69.59%
309 / 444
58.82% covered (warning)
58.82%
20 / 34
CRAP
0.00% covered (danger)
0.00%
0 / 1
Consent_Log_Controller
69.75% covered (warning)
69.75%
309 / 443
58.82% covered (warning)
58.82%
20 / 34
290.69
0.00% covered (danger)
0.00%
0 / 1
 init
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 get_table_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 uninstall
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 deactivate
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 maybe_create_table
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 create_table
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 1
2
 check_read_permission
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_rate_limit_window
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_rate_limit_max
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 rate_limit_key
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 reserve_rate_limit_slot
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
 reserve_rate_limit_slot_db
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 seconds_until_window_reset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rate_limited_response
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 validate_url
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
8
 validate_uuid
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 sanitize_consent_types
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 create_consent_log
85.71% covered (warning)
85.71%
30 / 35
0.00% covered (danger)
0.00%
0 / 1
5.07
 get_consent_log_ip_address
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_ip_mode
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 format_ip_address_for_log
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 truncate_ip_address
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
8.30
 get_consent_logs
85.71% covered (warning)
85.71%
24 / 28
0.00% covered (danger)
0.00%
0 / 1
5.07
 get_client_ip
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 get_log_versions
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 truncate_log_version
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_create_consent_schema
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 get_consent_logs_schema
100.00% covered (success)
100.00%
79 / 79
100.00% covered (success)
100.00%
1 / 1
1
 cleanup_expired_logs
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
4
 purge_expired_rate_limit_transients
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 schedule_cleanup
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 unschedule_cleanup
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 drop_table
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Consent Log REST controller.
4 * Handles cookie consent logging for GDPR compliance.
5 *
6 * @package automattic/jetpack-cookie-consent
7 */
8
9namespace Automattic\Jetpack\CookieConsent;
10
11use Automattic\Jetpack\IP\Utils as IP_Utils;
12use WP_Error;
13use WP_REST_Controller;
14use WP_REST_Request;
15use WP_REST_Response;
16use WP_REST_Server;
17
18defined( 'ABSPATH' ) || exit;
19
20/**
21 * REST API Consent Log controller class.
22 */
23class Consent_Log_Controller extends WP_REST_Controller {
24
25    /**
26     * Singleton instance.
27     *
28     * @var Consent_Log_Controller
29     */
30    private static $instance = null;
31
32    /**
33     * Resolved `log` config injected via init(), or empty when constructed
34     * directly (as the existing unit tests do) without going through init().
35     *
36     * @var array
37     */
38    private $log_config = array();
39
40    /**
41     * Endpoint namespace.
42     *
43     * @var string
44     */
45    protected $namespace = 'jetpack/v4/cookie-consent';
46
47    /**
48     * Route base.
49     *
50     * @var string
51     */
52    protected $rest_base = 'consent-log';
53
54    /**
55     * Database table name (without prefix).
56     *
57     * @var string
58     */
59    private const TABLE_NAME = 'jetpack_cookie_consent_logs';
60
61    /**
62     * Database version.
63     *
64     * @var string
65     */
66    private const DB_VERSION = '0.0.3';
67
68    /**
69     * Default retention period in days.
70     *
71     * @var int
72     */
73    private const DEFAULT_RETENTION_DAYS = 30;
74
75    /**
76     * Default rate-limit window in seconds for the public create route.
77     *
78     * @var int
79     */
80    private const RATE_LIMIT_WINDOW = 60;
81
82    /**
83     * Default maximum create requests allowed per IP within RATE_LIMIT_WINDOW.
84     *
85     * @var int
86     */
87    private const RATE_LIMIT_MAX = 100;
88
89    /**
90     * Object-cache group for the per-IP rate-limit counters.
91     *
92     * @var string
93     */
94    private const RATE_LIMIT_GROUP = 'jetpack_cookie_consent_rate_limit';
95
96    /**
97     * Maximum stored length for the consent URL.
98     *
99     * @var int
100     */
101    private const MAX_URL_LENGTH = 1024;
102
103    /**
104     * Cleanup cron hook name.
105     *
106     * @var string
107     */
108    private const CLEANUP_HOOK = 'jetpack_cookie_consent_cleanup_consent_logs';
109
110    /**
111     * Database version option name.
112     *
113     * @var string
114     */
115    private const DB_VERSION_OPTION = 'jetpack_cookie_consent_consent_log_db_version';
116
117    /**
118     * Initialize the controller: create the table, schedule cleanup,
119     * register REST routes, and wire the cleanup cron callback.
120     *
121     * @param array $log_config Resolved `log` config from Config_Schema::resolve(); only
122     *                          `ip_mode` and `retention_days` are read here. The
123     *                          `policy_version`/`banner_version` entries are sourced
124     *                          separately via Cookie_Consent::get_log_versions().
125     * @return Consent_Log_Controller
126     */
127    public static function init( array $log_config = array() ) {
128        if ( null === self::$instance ) {
129            self::$instance = new self();
130        }
131
132        $instance             = self::$instance;
133        $instance->log_config = $log_config;
134
135        $instance->maybe_create_table();
136        $instance->schedule_cleanup();
137
138        add_action( 'rest_api_init', array( $instance, 'register_routes' ) );
139        add_action( self::CLEANUP_HOOK, array( $instance, 'cleanup_expired_logs' ) );
140
141        Consent_Log_Privacy::init();
142
143        return $instance;
144    }
145
146    /**
147     * Get the full table name with WordPress prefix.
148     *
149     * @return string
150     */
151    public static function get_table_name() {
152        global $wpdb;
153        return $wpdb->prefix . self::TABLE_NAME;
154    }
155
156    /**
157     * Remove scheduled events and optionally delete persisted consent logs.
158     *
159     * Consumers should call this from their uninstall hook. Consent logs are
160     * retained by default because they may be compliance records; pass true only
161     * when the consuming plugin has decided uninstall should delete them.
162     *
163     * @since $$next-version$$
164     *
165     * @param bool $delete_consent_logs Whether to drop the consent-log table.
166     */
167    public static function uninstall( $delete_consent_logs = false ) {
168        self::deactivate();
169
170        if ( $delete_consent_logs ) {
171            self::drop_table();
172        }
173    }
174
175    /**
176     * Remove scheduled consent-log cleanup hooks.
177     *
178     * Consumers should call this from their deactivation hook when they want to
179     * stop background cleanup while retaining consent-log data.
180     *
181     * @since $$next-version$$
182     */
183    public static function deactivate() {
184        self::unschedule_cleanup();
185
186        // Privacy filters are registered statically in init() regardless of the
187        // singleton, so unhook them unconditionally (before the instance guard).
188        Consent_Log_Privacy::deactivate();
189
190        if ( null === self::$instance ) {
191            return;
192        }
193
194        remove_action( 'rest_api_init', array( self::$instance, 'register_routes' ) );
195        remove_action( self::CLEANUP_HOOK, array( self::$instance, 'cleanup_expired_logs' ) );
196    }
197
198    /**
199     * Create or upgrade the database table if needed.
200     */
201    public function maybe_create_table() {
202        $installed_version = get_option( self::DB_VERSION_OPTION, '0' );
203
204        if ( version_compare( $installed_version, self::DB_VERSION, '<' ) ) {
205            $this->create_table();
206            update_option( self::DB_VERSION_OPTION, self::DB_VERSION );
207        }
208    }
209
210    /**
211     * Create the consent logs database table.
212     */
213    private function create_table() {
214        global $wpdb;
215        $table_name      = self::get_table_name();
216        $charset_collate = $wpdb->get_charset_collate();
217
218        $sql = "CREATE TABLE {$table_name} (
219            id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
220            consent_id varchar(36) DEFAULT NULL,
221            event_type varchar(50) NOT NULL,
222            user_id bigint(20) UNSIGNED NOT NULL DEFAULT 0,
223            ip_address varchar(64) DEFAULT NULL,
224            url text DEFAULT NULL,
225            consent_types longtext DEFAULT NULL,
226            policy_version varchar(191) NOT NULL DEFAULT '1',
227            banner_version varchar(191) NOT NULL DEFAULT '1',
228            date_created datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
229            date_created_gmt datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
230            PRIMARY KEY (id),
231            KEY consent_id (consent_id),
232            KEY user_id (user_id),
233            KEY event_type (event_type),
234            KEY date_created_gmt (date_created_gmt)
235        ) {$charset_collate};";
236
237        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
238        dbDelta( $sql );
239    }
240
241    /**
242     * Register REST API routes.
243     */
244    public function register_routes() {
245        // Create consent log.
246        register_rest_route(
247            $this->namespace,
248            '/' . $this->rest_base,
249            array(
250                0        => array(
251                    'methods'             => WP_REST_Server::CREATABLE,
252                    'callback'            => array( $this, 'create_consent_log' ),
253                    // Public, unauthenticated route â€” anonymous visitors submit consent. Abuse is
254                    // bounded by the per-IP rate limit reserved inside the handler (see
255                    // create_consent_log()), which can count each request exactly once and carry
256                    // 429 response headers, neither of which a permission_callback can do reliably.
257                    'permission_callback' => '__return_true',
258                    'args'                => array(
259                        'consent_id'    => array(
260                            'type'              => 'string',
261                            'description'       => __( 'Optional unique consent identifier (UUID v4 format).', 'jetpack-cookie-consent' ),
262                            'validate_callback' => array( $this, 'validate_uuid' ),
263                            'sanitize_callback' => 'sanitize_text_field',
264                        ),
265                        'event_type'    => array(
266                            'type'              => 'string',
267                            'description'       => __( 'Type of consent event: accept_all, accept_selected, reject_all, auto_granted, or opt-out.', 'jetpack-cookie-consent' ),
268                            'required'          => true,
269                            'enum'              => array( 'accept_all', 'accept_selected', 'reject_all', 'auto_granted', 'opt-out' ),
270                            'validate_callback' => 'rest_validate_request_arg',
271                            'sanitize_callback' => 'sanitize_text_field',
272                        ),
273                        'url'           => array(
274                            'type'              => 'string',
275                            'description'       => __( 'URL where consent was given.', 'jetpack-cookie-consent' ),
276                            'validate_callback' => array( $this, 'validate_url' ),
277                            'sanitize_callback' => 'esc_url_raw',
278                        ),
279                        'consent_types' => array(
280                            'type'              => 'object',
281                            'description'       => __( 'Consent status for different cookie types (e.g., {"functional": true, "analytics": false, "marketing": true}).', 'jetpack-cookie-consent' ),
282                            'validate_callback' => 'rest_validate_request_arg',
283                            'sanitize_callback' => array( $this, 'sanitize_consent_types' ),
284                        ),
285                    ),
286                ),
287                'schema' => array( $this, 'get_create_consent_schema' ),
288            )
289        );
290
291        // Get consent logs (admin only).
292        register_rest_route(
293            $this->namespace,
294            '/' . $this->rest_base,
295            array(
296                0        => array(
297                    'methods'             => WP_REST_Server::READABLE,
298                    'callback'            => array( $this, 'get_consent_logs' ),
299                    'permission_callback' => array( $this, 'check_read_permission' ),
300                    'args'                => array(
301                        'user_id'  => array(
302                            'type'              => 'integer',
303                            'description'       => __( 'Filter by WordPress user ID.', 'jetpack-cookie-consent' ),
304                            'validate_callback' => 'rest_validate_request_arg',
305                            'sanitize_callback' => 'absint',
306                        ),
307                        'before'   => array(
308                            'type'              => 'string',
309                            'format'            => 'date-time',
310                            'description'       => __( 'Filter logs created before this date (ISO 8601 format).', 'jetpack-cookie-consent' ),
311                            'validate_callback' => 'rest_validate_request_arg',
312                            'sanitize_callback' => 'sanitize_text_field',
313                        ),
314                        'after'    => array(
315                            'type'              => 'string',
316                            'format'            => 'date-time',
317                            'description'       => __( 'Filter logs created after this date (ISO 8601 format).', 'jetpack-cookie-consent' ),
318                            'validate_callback' => 'rest_validate_request_arg',
319                            'sanitize_callback' => 'sanitize_text_field',
320                        ),
321                        'page'     => array(
322                            'type'              => 'integer',
323                            'description'       => __( 'Current page of the collection.', 'jetpack-cookie-consent' ),
324                            'default'           => 1,
325                            'validate_callback' => 'rest_validate_request_arg',
326                            'sanitize_callback' => 'absint',
327                        ),
328                        'per_page' => array(
329                            'type'              => 'integer',
330                            'description'       => __( 'Maximum number of items to return (max 100).', 'jetpack-cookie-consent' ),
331                            'default'           => 50,
332                            'validate_callback' => 'rest_validate_request_arg',
333                            'sanitize_callback' => 'absint',
334                        ),
335                    ),
336                ),
337                'schema' => array( $this, 'get_consent_logs_schema' ),
338            )
339        );
340    }
341
342    /**
343     * Check read permission for consent logs.
344     *
345     * @return bool
346     */
347    public function check_read_permission() {
348        return current_user_can( 'manage_privacy_options' );
349    }
350
351    /**
352     * Filterable rate-limit window in seconds.
353     *
354     * @return int
355     */
356    private function get_rate_limit_window() {
357        /**
358         * Filters the rate-limit window (seconds) for the consent-log create route.
359         *
360         * @param int $window Window length in seconds.
361         */
362        $window = (int) apply_filters( 'jetpack_cookie_consent_rate_limit_window', self::RATE_LIMIT_WINDOW );
363        return $window > 0 ? $window : self::RATE_LIMIT_WINDOW;
364    }
365
366    /**
367     * Filterable maximum create requests per IP within the window.
368     *
369     * @return int
370     */
371    private function get_rate_limit_max() {
372        /**
373         * Filters the maximum consent-log create requests per IP within the window.
374         *
375         * @param int $max Maximum number of requests.
376         */
377        $max = (int) apply_filters( 'jetpack_cookie_consent_rate_limit_max', self::RATE_LIMIT_MAX );
378        return $max > 0 ? $max : self::RATE_LIMIT_MAX;
379    }
380
381    /**
382     * Counter key for the per-IP rate-limit window.
383     *
384     * The current fixed-window slot is folded into the key, so each window is a distinct,
385     * self-expiring bucket and the counter never needs a separate reset step. A missing IP
386     * collapses to a single shared "unknown" bucket so it still can't be flooded.
387     *
388     * @param string|false $ip Client IP address.
389     * @return string
390     */
391    private function rate_limit_key( $ip ) {
392        $bucket = is_string( $ip ) && '' !== $ip ? $ip : 'unknown';
393        $window = $this->get_rate_limit_window();
394        $slot   = (int) floor( time() / $window );
395        return 'jp_cc_rl_' . md5( $bucket ) . '_' . $slot;
396    }
397
398    /**
399     * Reserve one slot in the current per-IP rate-limit window.
400     *
401     * The check and increment happen as a single atomic operation so a burst of concurrent
402     * requests from one IP can't all read an under-limit count and slip past together (the
403     * read-then-write race a plain transient counter would have). Two backends:
404     *
405     * - Persistent object cache: wp_cache_add() seeds the counter + TTL and wp_cache_incr()
406     *   counts, both atomic at the cache server (Redis/Memcached INCR).
407     * - Plain DB (WordPress default): a single atomic UPDATE that only increments while under
408     *   the cap. The row lock serializes concurrent writers, so the limit holds exactly.
409     *
410     * @param string|false $ip Client IP address.
411     * @return bool True if the request fits within the limit; false if the limit is reached.
412     */
413    private function reserve_rate_limit_slot( $ip ) {
414        $key    = $this->rate_limit_key( $ip );
415        $window = $this->get_rate_limit_window();
416        $max    = $this->get_rate_limit_max();
417
418        if ( wp_using_ext_object_cache() ) {
419            wp_cache_add( $key, 0, self::RATE_LIMIT_GROUP, $window );
420            $count = wp_cache_incr( $key, 1, self::RATE_LIMIT_GROUP );
421            return is_int( $count ) && $count <= $max;
422        }
423
424        return $this->reserve_rate_limit_slot_db( $key, $window, $max );
425    }
426
427    /**
428     * DB-backed atomic slot reservation for sites without a persistent object cache.
429     *
430     * Stores the counter as a regular (non-autoloaded) transient row and increments it with a
431     * single atomic statement that bumps the value only while it's below the cap. add_option()
432     * is an atomic insert-if-absent, so concurrent first requests can't double-seed; the
433     * UPDATE's row lock then serializes the increments. The companion timeout row lets WP core
434     * (and purge_expired_rate_limit_transients()) reclaim the rows once the window passes.
435     *
436     * @param string $key    Rate-limit counter key (already window-scoped).
437     * @param int    $window Window length in seconds.
438     * @param int    $max    Maximum requests allowed in the window.
439     * @return bool True if a slot was reserved; false if the limit is reached.
440     */
441    private function reserve_rate_limit_slot_db( $key, $window, $max ) {
442        global $wpdb;
443
444        $value_option   = '_transient_' . $key;
445        $timeout_option = '_transient_timeout_' . $key;
446
447        // Seed the window once. add_option() is a no-op (returns false) if the row already
448        // exists, so a concurrent seeder can't reset an in-progress count. Not autoloaded.
449        add_option( $timeout_option, (string) ( time() + $window ), '', false );
450        add_option( $value_option, '0', '', false );
451
452        $updated = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
453            $wpdb->prepare(
454                "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s AND CAST( option_value AS UNSIGNED ) < %d",
455                $value_option,
456                $max
457            )
458        );
459
460        return 1 === (int) $updated;
461    }
462
463    /**
464     * Seconds remaining until the current fixed-window slot resets.
465     *
466     * @return int
467     */
468    private function seconds_until_window_reset() {
469        $window = $this->get_rate_limit_window();
470        return $window - ( time() % $window );
471    }
472
473    /**
474     * Build the 429 response for a rate-limited request, with the conventional headers.
475     *
476     * Returned from the handler (not a permission_callback) so it can carry headers â€” a
477     * WP_Error would have its `headers` data key dropped by core.
478     *
479     * @return WP_REST_Response
480     */
481    private function rate_limited_response() {
482        $retry_after = $this->seconds_until_window_reset();
483
484        $response = new WP_REST_Response(
485            array(
486                'code'    => 'rest_too_many_requests',
487                'message' => __( 'Too many requests.', 'jetpack-cookie-consent' ),
488                'data'    => array( 'status' => 429 ),
489            ),
490            429
491        );
492
493        $response->header( 'Retry-After', (string) $retry_after );
494        $response->header( 'RateLimit-Limit', (string) $this->get_rate_limit_max() );
495        $response->header( 'RateLimit-Remaining', '0' );
496        $response->header( 'RateLimit-Reset', (string) $retry_after );
497
498        return $response;
499    }
500
501    /**
502     * Validate the consent URL: must be a well-formed http(s) URL within the length cap.
503     *
504     * @param mixed           $value   The value to validate.
505     * @param WP_REST_Request $request The request object.
506     * @param string          $param   The parameter name.
507     * @return bool|WP_Error True if valid, WP_Error otherwise.
508     */
509    public function validate_url( $value, $request, $param ) {
510        // Allow empty values since url is optional.
511        if ( empty( $value ) ) {
512            return true;
513        }
514
515        // The URL is the visitor's own page address, stored only for the audit log and
516        // never fetched server-side, so validate it cheaply: a string within the length
517        // cap that parses as an http(s) URL with a host. wp_http_validate_url() is avoided
518        // on purpose â€” it's an SSRF guard for outbound requests and would run a DNS lookup
519        // and reject legitimate same-site URLs (mapped domains, private-IP dev/staging
520        // hosts, non-standard ports) on this high-frequency public endpoint.
521        $scheme = is_string( $value ) ? wp_parse_url( $value, PHP_URL_SCHEME ) : null;
522        $host   = is_string( $value ) ? wp_parse_url( $value, PHP_URL_HOST ) : null;
523        if (
524            ! is_string( $value )
525            || strlen( $value ) > self::MAX_URL_LENGTH
526            || ! in_array( strtolower( (string) $scheme ), array( 'http', 'https' ), true )
527            || empty( $host )
528        ) {
529            return new WP_Error(
530                'rest_invalid_param',
531                sprintf(
532                    /* translators: %s: parameter name */
533                    __( '%s must be a valid URL.', 'jetpack-cookie-consent' ),
534                    $param
535                ),
536                array( 'status' => 400 )
537            );
538        }
539
540        return true;
541    }
542
543    /**
544     * Validate UUID format.
545     *
546     * @param mixed           $value   The value to validate.
547     * @param WP_REST_Request $request The request object.
548     * @param string          $param   The parameter name.
549     * @return bool|WP_Error True if valid, WP_Error otherwise.
550     */
551    public function validate_uuid( $value, $request, $param ) {
552        // Allow empty values since consent_id is optional.
553        if ( empty( $value ) ) {
554            return true;
555        }
556
557        // Use WordPress built-in UUID validation.
558        if ( ! wp_is_uuid( $value ) ) {
559            return new WP_Error(
560                'rest_invalid_param',
561                sprintf(
562                    /* translators: %s: parameter name */
563                    __( '%s must be a valid UUID format.', 'jetpack-cookie-consent' ),
564                    $param
565                ),
566                array( 'status' => 400 )
567            );
568        }
569
570        return true;
571    }
572
573    /**
574     * Sanitize consent types object.
575     *
576     * @param mixed $value The value to sanitize.
577     * @return array|null
578     */
579    public function sanitize_consent_types( $value ) {
580        if ( ! is_array( $value ) ) {
581            return null;
582        }
583
584        $allowed_types = array_map(
585            static function ( $category ) {
586                return $category['key'];
587            },
588            Cookie_Consent::get_current_consent_categories()
589        );
590
591        $sanitized = array();
592        foreach ( $value as $key => $status ) {
593            $sanitized_key = sanitize_key( $key );
594            // Only allow types in the allowed list.
595            if ( in_array( $sanitized_key, $allowed_types, true ) ) {
596                $sanitized[ $sanitized_key ] = rest_sanitize_boolean( $status );
597            }
598        }
599
600        return $sanitized;
601    }
602
603    /**
604     * Create a consent log entry.
605     *
606     * @param WP_REST_Request $request Request object.
607     * @return WP_REST_Response|WP_Error
608     */
609    public function create_consent_log( WP_REST_Request $request ) {
610        global $wpdb;
611
612        // Resolve the client IP once, for both the rate-limit counter and storage.
613        $ip = $this->get_client_ip();
614
615        // Atomically reserve a per-IP rate-limit slot before doing any work. This lives in the
616        // handler (not a permission_callback, which WP can invoke more than once per request) so
617        // each request counts exactly once and the 429 can carry response headers. It counts
618        // attempts by design â€” an abuse/DoS guard should â€” and reserving up front (rather than
619        // after the insert) is what makes the check-and-increment atomic and race-free.
620        if ( ! $this->reserve_rate_limit_slot( $ip ) ) {
621            return $this->rate_limited_response();
622        }
623
624        // Generate UUID if consent_id is not provided.
625        $consent_id = $request->get_param( 'consent_id' );
626        if ( empty( $consent_id ) ) {
627            $consent_id = wp_generate_uuid4();
628        }
629
630        $current_time_gmt   = gmdate( 'Y-m-d H:i:s' );
631        $current_time_local = wp_date( 'Y-m-d H:i:s' );
632
633        // Get consent types and encode as JSON.
634        $consent_types = $request->get_param( 'consent_types' );
635        $consent_json  = ! empty( $consent_types ) ? wp_json_encode( $consent_types, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ) : null;
636        $log_versions  = $this->get_log_versions();
637
638        $data = array(
639            'consent_id'       => $consent_id,
640            'event_type'       => $request->get_param( 'event_type' ),
641            'user_id'          => get_current_user_id(),
642            'ip_address'       => $this->get_consent_log_ip_address( $ip ),
643            'url'              => $request->get_param( 'url' ),
644            'consent_types'    => $consent_json,
645            'policy_version'   => $log_versions['policy_version'],
646            'banner_version'   => $log_versions['banner_version'],
647            'date_created'     => $current_time_local,
648            'date_created_gmt' => $current_time_gmt,
649        );
650
651        $result = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
652            self::get_table_name(),
653            $data,
654            array( '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
655        );
656
657        if ( false === $result ) {
658            return new WP_Error(
659                'database_error',
660                __( 'Failed to create consent log.', 'jetpack-cookie-consent' ),
661                array( 'status' => 500 )
662            );
663        }
664
665        return rest_ensure_response( array( 'consent_id' => $consent_id ) );
666    }
667
668    /**
669     * Get the IP address value to persist for the consent log.
670     *
671     * @param string|null $ip_address Resolved client IP address.
672     * @return string|null
673     */
674    private function get_consent_log_ip_address( $ip_address = null ) {
675        // Guard against a null IP: format_ip_address_for_log() would otherwise hash the empty
676        // string or hand null to wp_privacy_anonymize_ip() (which returns 0.0.0.0). The 'drop'
677        // mode itself is already handled by that method's default branch.
678        if ( null === $ip_address ) {
679            return null;
680        }
681
682        return $this->format_ip_address_for_log( $ip_address, $this->get_ip_mode() );
683    }
684
685    /**
686     * Get the configured IP address handling mode.
687     *
688     * Prefers the log config injected via init(); falls back to Cookie_Consent's
689     * global config for controller instances constructed directly (as the unit
690     * tests do) without going through init().
691     *
692     * @return string
693     */
694    private function get_ip_mode() {
695        $ip_mode = $this->log_config['ip_mode'] ?? Cookie_Consent::get_config()['log']['ip_mode'];
696        $ip_mode = sanitize_key( $ip_mode );
697
698        if ( ! in_array( $ip_mode, Config_Schema::ip_modes(), true ) ) {
699            return Config_Schema::default_ip_mode();
700        }
701
702        return $ip_mode;
703    }
704
705    /**
706     * Format an IP address for the configured log storage mode.
707     *
708     * @param string $ip_address Valid IP address.
709     * @param string $ip_mode    IP address handling mode.
710     * @return string|null
711     */
712    private function format_ip_address_for_log( $ip_address, $ip_mode ) {
713        switch ( $ip_mode ) {
714            case 'raw':
715                return $ip_address;
716
717            case 'hash':
718                // 64-char hex digest; the ip_address column must stay at least varchar(64) to hold it.
719                return hash_hmac( 'sha256', $ip_address, wp_salt( 'auth' ) );
720
721            case 'truncate':
722                return $this->truncate_ip_address( $ip_address );
723
724            case 'drop':
725            default:
726                return null;
727        }
728    }
729
730    /**
731     * Truncate an IP address so the full address is not persisted.
732     *
733     * @param string $ip_address Valid IP address.
734     * @return string|null
735     */
736    private function truncate_ip_address( $ip_address ) {
737        if ( function_exists( 'wp_privacy_anonymize_ip' ) ) {
738            return wp_privacy_anonymize_ip( $ip_address );
739        }
740
741        if ( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
742            $octets    = explode( '.', $ip_address );
743            $octets[3] = '0';
744            return implode( '.', $octets );
745        }
746
747        if ( filter_var( $ip_address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ) {
748            $packed = inet_pton( $ip_address );
749            if ( false === $packed ) {
750                return null;
751            }
752
753            $bytes = unpack( 'C*', $packed );
754            if ( false === $bytes ) {
755                return null;
756            }
757
758            for ( $i = 9; $i <= 16; $i++ ) {
759                $bytes[ $i ] = 0;
760            }
761
762            $truncated = inet_ntop( pack( 'C*', ...array_values( $bytes ) ) );
763            return false === $truncated ? null : $truncated;
764        }
765
766        return null;
767    }
768
769    /**
770     * Get consent logs with filtering and pagination.
771     *
772     * @param WP_REST_Request $request Request object.
773     * @return WP_REST_Response|WP_Error
774     */
775    public function get_consent_logs( WP_REST_Request $request ) {
776        global $wpdb;
777        $table_name = self::get_table_name();
778
779        // Build WHERE clause.
780        $where  = array( '1=1' );
781        $values = array();
782
783        if ( $request->get_param( 'user_id' ) ) {
784            $where[]  = 'user_id = %d';
785            $values[] = $request->get_param( 'user_id' );
786        }
787
788        if ( $request->get_param( 'after' ) ) {
789            $where[]  = 'date_created_gmt >= %s';
790            $values[] = gmdate( 'Y-m-d H:i:s', strtotime( $request->get_param( 'after' ) ) );
791        }
792
793        if ( $request->get_param( 'before' ) ) {
794            $where[]  = 'date_created_gmt <= %s';
795            $values[] = gmdate( 'Y-m-d H:i:s', strtotime( $request->get_param( 'before' ) ) );
796        }
797
798        // Pagination. Clamp to safe lower bounds so per_page=0 can't divide by zero
799        // and page=0 can't produce a negative OFFSET.
800        $page     = max( 1, (int) $request->get_param( 'page' ) );
801        $per_page = max( 1, min( (int) $request->get_param( 'per_page' ), 100 ) ); // 1-100 per page.
802        $offset   = ( $page - 1 ) * $per_page;
803
804        // Count total.
805        $where_clause = implode( ' AND ', $where );
806        $count_query  = "SELECT COUNT(*) FROM {$table_name} WHERE {$where_clause}";
807        if ( ! empty( $values ) ) {
808            $count_query = $wpdb->prepare( $count_query, ...$values ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
809        }
810        $total = (int) $wpdb->get_var( $count_query ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
811
812        // Get records.
813        $query    = "SELECT * FROM {$table_name} WHERE {$where_clause} ORDER BY date_created_gmt DESC LIMIT %d OFFSET %d";
814        $values[] = $per_page;
815        $values[] = $offset;
816        $results  = $wpdb->get_results( $wpdb->prepare( $query, ...$values ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
817
818        $response = rest_ensure_response( $results );
819        $response->header( 'X-WP-Total', (string) $total );
820        $response->header( 'X-WP-TotalPages', (string) ceil( $total / $per_page ) );
821
822        return $response;
823    }
824
825    /**
826     * Get the client IP address.
827     *
828     * Delegates to the jetpack-ip package, which defaults to REMOTE_ADDR and only
829     * trusts a forwarded header when the site has explicitly configured a trusted
830     * proxy header â€” avoiding the spoofable-header pitfall of reading X-Forwarded-For
831     * unconditionally.
832     *
833     * @return string|null
834     */
835    private function get_client_ip() {
836        $ip = IP_Utils::get_ip();
837        return is_string( $ip ) && '' !== $ip ? $ip : null;
838    }
839
840    /**
841     * Get configured log versions for proof-of-consent records.
842     *
843     * @return array
844     */
845    private function get_log_versions() {
846        $log_versions = Cookie_Consent::get_log_versions();
847
848        return array(
849            'policy_version' => $this->truncate_log_version( $log_versions['policy_version'] ),
850            'banner_version' => $this->truncate_log_version( $log_versions['banner_version'] ),
851        );
852    }
853
854    /**
855     * Truncate a normalized log version to the storage column length.
856     *
857     * Values arrive already sanitized and non-empty from
858     * Cookie_Consent::get_log_versions(); this only enforces the varchar(191)
859     * column limit. Use multibyte-aware truncation when available.
860     *
861     * @param string $version Normalized version value.
862     * @return string
863     */
864    private function truncate_log_version( $version ) {
865        if ( function_exists( 'mb_substr' ) ) {
866            return mb_substr( $version, 0, 191 );
867        }
868
869        return substr( $version, 0, 191 );
870    }
871
872    /**
873     * Get the schema for the create consent endpoint.
874     *
875     * @return array
876     */
877    public function get_create_consent_schema() {
878        $schema = array(
879            '$schema'    => 'http://json-schema.org/draft-04/schema#',
880            'title'      => 'create_consent_log',
881            'type'       => 'object',
882            'properties' => array(
883                'consent_id' => array(
884                    'description' => __( 'The unique consent identifier.', 'jetpack-cookie-consent' ),
885                    'type'        => 'string',
886                    'context'     => array( 'view' ),
887                    'readonly'    => true,
888                ),
889            ),
890        );
891
892        return $this->add_additional_fields_schema( $schema );
893    }
894
895    /**
896     * Get the schema for the get consent logs endpoint.
897     *
898     * @return array
899     */
900    public function get_consent_logs_schema() {
901        $schema = array(
902            '$schema' => 'http://json-schema.org/draft-04/schema#',
903            'title'   => 'consent_logs',
904            'type'    => 'array',
905            'items'   => array(
906                'type'       => 'object',
907                'properties' => array(
908                    'id'               => array(
909                        'description' => __( 'The consent log ID.', 'jetpack-cookie-consent' ),
910                        'type'        => 'integer',
911                        'context'     => array( 'view' ),
912                        'readonly'    => true,
913                    ),
914                    'consent_id'       => array(
915                        'description' => __( 'The unique consent identifier.', 'jetpack-cookie-consent' ),
916                        'type'        => 'string',
917                        'context'     => array( 'view' ),
918                        'readonly'    => true,
919                    ),
920                    'event_type'       => array(
921                        'description' => __( 'Type of consent event.', 'jetpack-cookie-consent' ),
922                        'type'        => 'string',
923                        'context'     => array( 'view' ),
924                        'readonly'    => true,
925                    ),
926                    'user_id'          => array(
927                        'description' => __( 'The WordPress user ID.', 'jetpack-cookie-consent' ),
928                        'type'        => 'integer',
929                        'context'     => array( 'view' ),
930                        'readonly'    => true,
931                    ),
932                    'ip_address'       => array(
933                        'description' => __( 'The stored client IP address value.', 'jetpack-cookie-consent' ),
934                        'type'        => array( 'string', 'null' ),
935                        'context'     => array( 'view' ),
936                        'readonly'    => true,
937                    ),
938                    'url'              => array(
939                        'description' => __( 'URL where consent was given.', 'jetpack-cookie-consent' ),
940                        'type'        => 'string',
941                        'context'     => array( 'view' ),
942                        'readonly'    => true,
943                    ),
944                    'consent_types'    => array(
945                        'description' => __( 'Consent status for different cookie types as JSON string.', 'jetpack-cookie-consent' ),
946                        'type'        => 'string',
947                        'context'     => array( 'view' ),
948                        'readonly'    => true,
949                    ),
950                    'policy_version'   => array(
951                        'description' => __( 'Policy version in effect when consent was captured.', 'jetpack-cookie-consent' ),
952                        'type'        => 'string',
953                        'context'     => array( 'view' ),
954                        'readonly'    => true,
955                    ),
956                    'banner_version'   => array(
957                        'description' => __( 'Banner version in effect when consent was captured.', 'jetpack-cookie-consent' ),
958                        'type'        => 'string',
959                        'context'     => array( 'view' ),
960                        'readonly'    => true,
961                    ),
962                    'date_created'     => array(
963                        'description' => __( 'Date created in local time.', 'jetpack-cookie-consent' ),
964                        'type'        => 'string',
965                        'format'      => 'date-time',
966                        'context'     => array( 'view' ),
967                        'readonly'    => true,
968                    ),
969                    'date_created_gmt' => array(
970                        'description' => __( 'Date created in GMT.', 'jetpack-cookie-consent' ),
971                        'type'        => 'string',
972                        'format'      => 'date-time',
973                        'context'     => array( 'view' ),
974                        'readonly'    => true,
975                    ),
976                ),
977            ),
978        );
979
980        return $schema;
981    }
982
983    /**
984     * Cleanup expired consent logs (older than retention period).
985     * Deletes in batches to avoid performance issues with large datasets.
986     */
987    public function cleanup_expired_logs() {
988        global $wpdb;
989
990        // The retention period comes from the log config injected via init(). The
991        // dedicated filter stays as a back-compat override point for sites that do
992        // not own that init() call; filter_var + the guard below sanitize whatever
993        // it returns.
994        $retention_days = $this->log_config['retention_days'] ?? self::DEFAULT_RETENTION_DAYS;
995
996        /**
997         * Filters the consent-log retention period, in days.
998         *
999         * @since $$next-version$$
1000         *
1001         * @param int $retention_days Retention period in days from the injected log config.
1002         */
1003        $retention_days = filter_var( apply_filters( 'jetpack_cookie_consent_log_retention_days', $retention_days ), FILTER_VALIDATE_INT );
1004
1005        if ( false === $retention_days || $retention_days <= 0 ) {
1006            $retention_days = self::DEFAULT_RETENTION_DAYS;
1007        }
1008
1009        // Calculate cutoff date in UTC.
1010        $cutoff_timestamp = time() - ( $retention_days * DAY_IN_SECONDS );
1011        $cutoff_date      = gmdate( 'Y-m-d H:i:s', $cutoff_timestamp );
1012
1013        $table_name = self::get_table_name();
1014        $batch_size = 1000;
1015
1016        // Delete in batches until all expired records are removed.
1017        do {
1018            $deleted = $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1019                $wpdb->prepare(
1020                    'DELETE FROM %i WHERE date_created_gmt < %s LIMIT %d',
1021                    $table_name,
1022                    $cutoff_date,
1023                    $batch_size
1024                )
1025            );
1026
1027            // Avoid infinite loop in case of errors.
1028            if ( false === $deleted ) {
1029                break;
1030            }
1031        } while ( $deleted === $batch_size );
1032
1033        $this->purge_expired_rate_limit_transients();
1034    }
1035
1036    /**
1037     * Purge expired rate-limit transients left behind in the options table.
1038     *
1039     * DB-backed transients are only deleted lazily (on read after expiry), so a flood
1040     * of distinct IPs can leave many stale rows in wp_options. When a persistent object
1041     * cache is in use, transients live there and expire on their own, so there's nothing
1042     * to purge.
1043     */
1044    private function purge_expired_rate_limit_transients() {
1045        if ( wp_using_ext_object_cache() ) {
1046            return;
1047        }
1048
1049        global $wpdb;
1050
1051        $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
1052            $wpdb->prepare(
1053                "DELETE a, b FROM {$wpdb->options} a
1054                JOIN {$wpdb->options} b ON b.option_name = CONCAT( '_transient_', SUBSTRING( a.option_name, LENGTH( '_transient_timeout_' ) + 1 ) )
1055                WHERE a.option_name LIKE %s
1056                AND a.option_value < %d",
1057                $wpdb->esc_like( '_transient_timeout_jp_cc_rl_' ) . '%',
1058                time()
1059            )
1060        );
1061    }
1062
1063    /**
1064     * Schedule daily cleanup event using WP-Cron.
1065     */
1066    public function schedule_cleanup() {
1067        // Only schedule if not already scheduled.
1068        if ( ! wp_next_scheduled( self::CLEANUP_HOOK ) ) {
1069            wp_schedule_event( time(), 'daily', self::CLEANUP_HOOK );
1070        }
1071    }
1072
1073    /**
1074     * Unschedule cleanup event.
1075     * This method can be called on plugin deactivation.
1076     */
1077    public static function unschedule_cleanup() {
1078        wp_clear_scheduled_hook( self::CLEANUP_HOOK );
1079    }
1080
1081    /**
1082     * Drop the consent logs table and clear its schema-version option.
1083     */
1084    private static function drop_table() {
1085        global $wpdb;
1086        $table_name = self::get_table_name();
1087
1088        $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table_name ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.DirectDatabaseQuery.SchemaChange
1089        delete_option( self::DB_VERSION_OPTION );
1090    }
1091}