Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.72% covered (warning)
87.72%
100 / 114
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Consent_Log_Privacy
88.50% covered (warning)
88.50%
100 / 113
70.00% covered (warning)
70.00%
7 / 10
18.49
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 deactivate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 register_exporter
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 register_eraser
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 user_id_for_email
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_rows
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 export
100.00% covered (success)
100.00%
44 / 44
100.00% covered (success)
100.00%
1 / 1
3
 erase
88.46% covered (warning)
88.46%
23 / 26
0.00% covered (danger)
0.00%
0 / 1
6.06
 anonymize_rows
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 delete_rows
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * GDPR personal-data exporter/eraser for the consent log.
4 *
5 * @package automattic/jetpack-cookie-consent
6 */
7
8namespace Automattic\Jetpack\CookieConsent;
9
10defined( 'ABSPATH' ) || exit;
11
12/**
13 * Registers WordPress personal-data exporter/eraser callbacks for the
14 * cookie-consent log table, matched by email -> user_id.
15 */
16class Consent_Log_Privacy {
17
18    /**
19     * Export group id / exporter+eraser registration key.
20     *
21     * @var string
22     */
23    private const GROUP_ID = 'jetpack-cookie-consent';
24
25    /**
26     * Rows processed per page.
27     *
28     * @var int
29     */
30    private const PER_PAGE = 100;
31
32    /**
33     * Register the exporter and eraser filters.
34     */
35    public static function init() {
36        add_filter( 'wp_privacy_personal_data_exporters', array( __CLASS__, 'register_exporter' ) );
37        add_filter( 'wp_privacy_personal_data_erasers', array( __CLASS__, 'register_eraser' ) );
38    }
39
40    /**
41     * Remove the exporter and eraser filters.
42     *
43     * Mirrors init(); booted from Consent_Log_Controller::deactivate() so a
44     * consumer that deactivates within the request stops exposing the consent
45     * log to core's privacy tools.
46     */
47    public static function deactivate() {
48        remove_filter( 'wp_privacy_personal_data_exporters', array( __CLASS__, 'register_exporter' ) );
49        remove_filter( 'wp_privacy_personal_data_erasers', array( __CLASS__, 'register_eraser' ) );
50    }
51
52    /**
53     * Register the exporter.
54     *
55     * @param array $exporters Registered exporters.
56     * @return array
57     */
58    public static function register_exporter( $exporters ) {
59        $exporters[ self::GROUP_ID ] = array(
60            'exporter_friendly_name' => __( 'Cookie Consent Log', 'jetpack-cookie-consent' ),
61            'callback'               => array( __CLASS__, 'export' ),
62        );
63        return $exporters;
64    }
65
66    /**
67     * Register the eraser.
68     *
69     * @param array $erasers Registered erasers.
70     * @return array
71     */
72    public static function register_eraser( $erasers ) {
73        $erasers[ self::GROUP_ID ] = array(
74            'eraser_friendly_name' => __( 'Cookie Consent Log', 'jetpack-cookie-consent' ),
75            'callback'             => array( __CLASS__, 'erase' ),
76        );
77        return $erasers;
78    }
79
80    /**
81     * Resolve an email to a WordPress user id, or 0 if none.
82     *
83     * @param string $email Email address.
84     * @return int
85     */
86    private static function user_id_for_email( $email ) {
87        $user = get_user_by( 'email', $email );
88        return $user ? (int) $user->ID : 0;
89    }
90
91    /**
92     * Fetch a page of consent rows for a user id.
93     *
94     * @param int $user_id User id.
95     * @param int $page    Page number (1-based).
96     * @return array Array of associative row arrays.
97     */
98    private static function get_rows( $user_id, $page ) {
99        global $wpdb;
100        $table  = Consent_Log_Controller::get_table_name();
101        $offset = ( max( 1, $page ) - 1 ) * self::PER_PAGE;
102        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
103        // Cast to array: get_results() returns null on a DB error, which would fatal
104        // on the count()/foreach in export().
105        return (array) $wpdb->get_results(
106            $wpdb->prepare(
107                "SELECT * FROM {$table} WHERE user_id = %d ORDER BY id LIMIT %d OFFSET %d",
108                $user_id,
109                self::PER_PAGE,
110                $offset
111            ),
112            ARRAY_A
113        );
114        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
115    }
116
117    /**
118     * Personal-data exporter callback.
119     *
120     * @param string $email Email address being exported.
121     * @param int    $page  Page number (1-based).
122     * @return array { data: array, done: bool }
123     */
124    public static function export( $email, $page = 1 ) {
125        $user_id = self::user_id_for_email( $email );
126        if ( ! $user_id ) {
127            return array(
128                'data' => array(),
129                'done' => true,
130            );
131        }
132
133        $rows = self::get_rows( $user_id, $page );
134        $data = array();
135        foreach ( $rows as $row ) {
136            $data[] = array(
137                'group_id'    => self::GROUP_ID,
138                'group_label' => __( 'Cookie Consent Log', 'jetpack-cookie-consent' ),
139                'item_id'     => 'consent-log-' . $row['id'],
140                'data'        => array(
141                    array(
142                        'name'  => __( 'Consent ID', 'jetpack-cookie-consent' ),
143                        'value' => $row['consent_id'],
144                    ),
145                    array(
146                        'name'  => __( 'Event', 'jetpack-cookie-consent' ),
147                        'value' => $row['event_type'],
148                    ),
149                    array(
150                        'name'  => __( 'IP Address', 'jetpack-cookie-consent' ),
151                        'value' => $row['ip_address'],
152                    ),
153                    array(
154                        'name'  => __( 'URL', 'jetpack-cookie-consent' ),
155                        'value' => $row['url'],
156                    ),
157                    array(
158                        'name'  => __( 'Consent Types', 'jetpack-cookie-consent' ),
159                        'value' => $row['consent_types'],
160                    ),
161                    array(
162                        'name'  => __( 'Date (GMT)', 'jetpack-cookie-consent' ),
163                        'value' => $row['date_created_gmt'],
164                    ),
165                ),
166            );
167        }
168
169        return array(
170            'data' => $data,
171            'done' => count( $rows ) < self::PER_PAGE,
172        );
173    }
174
175    /**
176     * Personal-data eraser callback.
177     *
178     * Default mode anonymizes matched rows (clears IP, zeroes user_id) to keep
179     * an auditable proof-of-consent count. Filter to 'delete' for hard removal.
180     *
181     * @param string $email Email address being erased.
182     * @param int    $page  Page number (1-based).
183     * @return array { items_removed: bool, items_retained: bool, messages: array, done: bool }
184     */
185    public static function erase( $email, $page = 1 ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable -- $page is required by the WP eraser callback signature; we intentionally always fetch offset 0.
186        $response = array(
187            'items_removed'  => false,
188            'items_retained' => false,
189            'messages'       => array(),
190            'done'           => true,
191        );
192
193        $user_id = self::user_id_for_email( $email );
194        if ( ! $user_id ) {
195            return $response;
196        }
197
198        // Always fetch from offset 0 (page 1), ignoring the $page argument.
199        // Each pass anonymizes/deletes the rows it touches, removing them from
200        // the "WHERE user_id = N" result set. The next core-driven pass therefore
201        // sees the next unprocessed batch at offset 0. When fewer than PER_PAGE
202        // rows remain, done = true and iteration converges.
203        $rows = self::get_rows( $user_id, 1 );
204        if ( empty( $rows ) ) {
205            return $response;
206        }
207
208        $ids = wp_list_pluck( $rows, 'id' );
209
210        /**
211         * Filters how consent-log rows are erased: 'anonymize' (default) or 'delete'.
212         *
213         * @param string $mode    Erase mode.
214         * @param int    $user_id The user id whose rows are being erased.
215         */
216        $mode = apply_filters( 'jetpack_cookie_consent_erase_mode', 'anonymize', $user_id );
217
218        $written = 'delete' === $mode
219            ? self::delete_rows( $ids )
220            : self::anonymize_rows( $ids );
221
222        if ( false === $written ) {
223            // The UPDATE/DELETE failed. Report honestly (nothing removed) and stop
224            // iterating: leaving done = true terminates core's erasure loop instead
225            // of re-requesting the same un-erased rows (get_rows always reads offset 0).
226            $response['messages'][] = __( 'Cookie consent records could not be erased due to a database error.', 'jetpack-cookie-consent' );
227            return $response;
228        }
229
230        if ( 'delete' !== $mode ) {
231            $response['items_retained'] = true;
232            $response['messages'][]     = __( 'Cookie consent records were anonymized and retained for consent accountability.', 'jetpack-cookie-consent' );
233        }
234
235        $response['items_removed'] = true;
236        $response['done']          = count( $rows ) < self::PER_PAGE;
237
238        return $response;
239    }
240
241    /**
242     * Anonymize rows: clear the IP and detach the user.
243     *
244     * @param int[] $ids Row ids.
245     * @return int|false Rows affected, or false on error.
246     */
247    private static function anonymize_rows( $ids ) {
248        global $wpdb;
249        $table        = Consent_Log_Controller::get_table_name();
250        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
251        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
252        return $wpdb->query(
253            $wpdb->prepare(
254                "UPDATE {$table} SET ip_address = NULL, user_id = 0 WHERE id IN ({$placeholders})",
255                ...array_map( 'intval', $ids )
256            )
257        );
258        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
259    }
260
261    /**
262     * Hard-delete rows.
263     *
264     * @param int[] $ids Row ids.
265     * @return int|false Rows affected, or false on error.
266     */
267    private static function delete_rows( $ids ) {
268        global $wpdb;
269        $table        = Consent_Log_Controller::get_table_name();
270        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
271        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
272        return $wpdb->query(
273            $wpdb->prepare(
274                "DELETE FROM {$table} WHERE id IN ({$placeholders})",
275                ...array_map( 'intval', $ids )
276            )
277        );
278        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
279    }
280}