prefix . self::TABLE; } /* ----------------------------------------------------------------------- */ /* Activation / désactivation */ /* ----------------------------------------------------------------------- */ public static function activate() { self::create_table(); if (!get_option(self::SALT_OPTION)) { // Sel propre au site, non exporté, pour anonymiser IP/UA de façon stable. add_option(self::SALT_OPTION, wp_generate_password(64, true, true), '', false); } if (!wp_next_scheduled(self::CRON_HOOK)) { wp_schedule_event(time() + DAY_IN_SECONDS, 'daily', self::CRON_HOOK); } update_option(self::DB_VERSION_OPTION, self::DB_VERSION, false); } public static function deactivate() { $timestamp = wp_next_scheduled(self::CRON_HOOK); if ($timestamp) { wp_unschedule_event($timestamp, self::CRON_HOOK); } } public static function create_table() { global $wpdb; $table = self::table_name(); $charset_collate = $wpdb->get_charset_collate(); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $sql = "CREATE TABLE {$table} ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, created_at DATETIME NOT NULL, consent_id VARCHAR(64) NOT NULL DEFAULT '', purposes_version SMALLINT UNSIGNED NOT NULL DEFAULT 0, choice TEXT NOT NULL, ip_hash CHAR(64) NOT NULL DEFAULT '', ua_hash CHAR(64) NOT NULL DEFAULT '', PRIMARY KEY (id), KEY consent_id (consent_id), KEY created_at (created_at) ) {$charset_collate};"; dbDelta($sql); } /* ----------------------------------------------------------------------- */ /* Endpoint REST public */ /* ----------------------------------------------------------------------- */ public static function register_routes() { register_rest_route(self::REST_NAMESPACE, '/' . self::REST_ROUTE, [ 'methods' => 'POST', 'callback' => [__CLASS__, 'handle_log'], // Public : le visiteur n'est pas authentifié et la page peut être servie // depuis un cache (WP Rocket/Cloudflare), ce qui périmerait un nonce. 'permission_callback' => '__return_true', ]); } public static function handle_log(WP_REST_Request $request) { $params = $request->get_json_params(); if (!is_array($params)) { $params = $request->get_params(); } $choice = self::sanitize_choice(isset($params['choice']) ? $params['choice'] : null); if (empty($choice)) { return new WP_Error('cmp410gone_invalid', 'Invalid consent payload', ['status' => 400]); } $consent_id = isset($params['id']) ? preg_replace('/[^A-Za-z0-9\-]/', '', substr((string)$params['id'], 0, 64)) : ''; $version = isset($params['v']) ? (int)$params['v'] : 0; global $wpdb; $inserted = $wpdb->insert( self::table_name(), [ 'created_at' => gmdate('Y-m-d H:i:s'), 'consent_id' => $consent_id, 'purposes_version' => max(0, min(65535, $version)), 'choice' => wp_json_encode($choice), 'ip_hash' => self::hash_value(self::client_ip()), 'ua_hash' => self::hash_value(isset($_SERVER['HTTP_USER_AGENT']) ? (string)$_SERVER['HTTP_USER_AGENT'] : ''), ], ['%s', '%s', '%d', '%s', '%s', '%s'] ); if (false === $inserted) { return new WP_Error('cmp410gone_db', 'Could not store consent', ['status' => 500]); } return rest_ensure_response(['ok' => true]); } private static function sanitize_choice($raw) { if (!is_array($raw)) { return []; } $out = []; foreach (self::SIGNALS as $key) { if (isset($raw[$key]) && in_array($raw[$key], ['granted', 'denied'], true)) { $out[$key] = $raw[$key]; } } // Un choix valide déclare au moins les signaux non essentiels. return count($out) >= 4 ? $out : []; } private static function hash_value($value) { $value = (string)$value; if ($value === '') { return ''; } $salt = (string)get_option(self::SALT_OPTION, ''); return hash('sha256', $salt . '|' . $value); } private static function client_ip() { // On ne conserve jamais l'IP en clair : seul son hachage salé est stocké. return isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : ''; } /* ----------------------------------------------------------------------- */ /* Purge automatique */ /* ----------------------------------------------------------------------- */ public static function prune() { global $wpdb; $table = self::table_name(); $cutoff = gmdate('Y-m-d H:i:s', time() - (self::RETENTION_DAYS * DAY_IN_SECONDS)); $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE created_at < %s", $cutoff)); } /* ----------------------------------------------------------------------- */ /* Écran d'administration */ /* ----------------------------------------------------------------------- */ public static function admin_menu() { add_submenu_page( 'options-general.php', __('🍪 Consent log', '410gone-consent-manager'), __('🍪 Consent log', '410gone-consent-manager'), 'manage_options', self::PAGE_SLUG, [__CLASS__, 'render_page'] ); } public static function render_page() { if (!current_user_can('manage_options')) { return; } global $wpdb; $table = self::table_name(); $exists = ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table); echo '

' . esc_html__('🍪 Consent log', '410gone-consent-manager') . '

'; if (!$exists) { echo '

' . esc_html__('The consent log table does not exist yet. Deactivate and reactivate the plugin to create it.', '410gone-consent-manager') . '

'; return; } $total = (int)$wpdb->get_var("SELECT COUNT(*) FROM {$table}"); $filters = self::read_filters(); list($where_sql, $params) = self::build_where($filters); $has_filters = ('' !== $filters['from'] || '' !== $filters['to'] || '' !== $filters['cid']); $limit = 200; // Filter form (GET). $base_url = admin_url('options-general.php?page=' . self::PAGE_SLUG); echo '
'; echo ''; echo ''; echo ''; echo ''; echo ' '; if ($has_filters) { echo '' . esc_html__('Reset', '410gone-consent-manager') . ''; } echo '
'; // Count line (total, and filtered count when a filter is active). if ($has_filters) { $count_sql = "SELECT COUNT(*) FROM {$table} {$where_sql}"; $matching = (int)$wpdb->get_var($params ? $wpdb->prepare($count_sql, $params) : $count_sql); echo '

' . sprintf( /* translators: 1: matching entries, 2: total entries. */ esc_html__('Matching entries: %1$s of %2$s stored proofs.', '410gone-consent-manager'), '' . esc_html(number_format_i18n($matching)) . '', '' . esc_html(number_format_i18n($total)) . '' ) . '

'; } else { echo '

' . sprintf( /* translators: %s: number of stored consent proofs. */ esc_html__('Stored consent proofs: %s', '410gone-consent-manager'), '' . esc_html(number_format_i18n($total)) . '' ) . '

'; } // Export keeps the current filters. $export_args = ['action' => 'cmp410gone_export_log']; if ('' !== $filters['from']) { $export_args['cmp_from'] = $filters['from']; } if ('' !== $filters['to']) { $export_args['cmp_to'] = $filters['to']; } if ('' !== $filters['cid']) { $export_args['cmp_cid'] = $filters['cid']; } $export_url = wp_nonce_url(add_query_arg($export_args, admin_url('admin-post.php')), 'cmp410gone_export_log'); echo '

' . esc_html__('Export CSV', '410gone-consent-manager') . '

'; $sql = "SELECT created_at, consent_id, purposes_version, choice FROM {$table} {$where_sql} ORDER BY id DESC LIMIT {$limit}"; $rows = $wpdb->get_results($params ? $wpdb->prepare($sql, $params) : $sql); echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; if (empty($rows)) { echo ''; } else { foreach ($rows as $row) { echo ''; echo ''; echo ''; echo ''; echo ''; echo ''; } } echo '
' . esc_html__('Date (UTC)', '410gone-consent-manager') . '' . esc_html__('Consent ID', '410gone-consent-manager') . '' . esc_html__('Purposes v.', '410gone-consent-manager') . '' . esc_html__('Choice', '410gone-consent-manager') . '
' . esc_html__('No matching entry.', '410gone-consent-manager') . '
' . esc_html($row->created_at) . '' . esc_html($row->consent_id) . '' . esc_html($row->purposes_version) . '' . self::render_choice($row->choice) . '
'; echo '

' . sprintf( /* translators: %s: row limit. */ esc_html__('Showing up to %s most recent matching entries. Refine the filters or use the CSV export for the full set.', '410gone-consent-manager'), esc_html(number_format_i18n($limit)) ) . '

'; echo ''; } /** * Rend le choix sous forme de lignes « signal : valeur » plutôt que du JSON brut. */ private static function render_choice($json) { $choice = json_decode((string)$json, true); if (!is_array($choice) || empty($choice)) { return '' . esc_html__('(unreadable)', '410gone-consent-manager') . ''; } $out = ''; return $out; } /** * Lit et valide les filtres depuis la requête ($_GET), sans casser en cas * d'absence. Les dates doivent être au format AAAA-MM-JJ. */ private static function read_filters() { $from = isset($_GET['cmp_from']) ? sanitize_text_field(wp_unslash($_GET['cmp_from'])) : ''; $to = isset($_GET['cmp_to']) ? sanitize_text_field(wp_unslash($_GET['cmp_to'])) : ''; $cid = isset($_GET['cmp_cid']) ? sanitize_text_field(wp_unslash($_GET['cmp_cid'])) : ''; if ('' !== $from && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $from)) { $from = ''; } if ('' !== $to && !preg_match('/^\d{4}-\d{2}-\d{2}$/', $to)) { $to = ''; } $cid = substr($cid, 0, 64); return ['from' => $from, 'to' => $to, 'cid' => $cid]; } /** * Construit la clause WHERE + les paramètres liés à passer à $wpdb->prepare(). */ private static function build_where($filters) { global $wpdb; $where = []; $params = []; if ('' !== $filters['from']) { $where[] = 'created_at >= %s'; $params[] = $filters['from'] . ' 00:00:00'; } if ('' !== $filters['to']) { $where[] = 'created_at <= %s'; $params[] = $filters['to'] . ' 23:59:59'; } if ('' !== $filters['cid']) { $where[] = 'consent_id LIKE %s'; $params[] = '%' . $wpdb->esc_like($filters['cid']) . '%'; } $where_sql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; return [$where_sql, $params]; } public static function export_csv() { if (!current_user_can('manage_options')) { wp_die(esc_html__('Insufficient permissions.', '410gone-consent-manager')); } check_admin_referer('cmp410gone_export_log'); global $wpdb; $table = self::table_name(); // Même filtrage que l'écran d'affichage, pour un export cohérent avec la vue. $filters = self::read_filters(); list($where_sql, $params) = self::build_where($filters); $sql = "SELECT created_at, consent_id, purposes_version, choice, ip_hash, ua_hash FROM {$table} {$where_sql} ORDER BY id DESC"; $rows = $wpdb->get_results($params ? $wpdb->prepare($sql, $params) : $sql, ARRAY_A); nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename=cmp410gone-consent-log.csv'); $out = fopen('php://output', 'w'); fputcsv($out, ['created_at', 'consent_id', 'purposes_version', 'choice', 'ip_hash', 'ua_hash']); if (!empty($rows)) { foreach ($rows as $row) { fputcsv($out, array_map([__CLASS__, 'csv_guard'], $row)); } } fclose($out); exit; } /** * Neutralise l'injection de formule : un tableur interprète une cellule * débutant par = + - @ comme une formule. On la préfixe d'une apostrophe. */ private static function csv_guard($value) { $value = (string)$value; if ($value !== '' && in_array($value[0], ['=', '+', '-', '@'], true)) { return "'" . $value; } return $value; } }