prefix plus a fixed suffix from a private helper (never user input); all runtime values go through $wpdb->prepare() placeholders. /** * Internal link click tracking. * * Deliberate privacy rules: only the date, the source post, the target post and * the anchor are stored. No IP address, no cookies, no user identifiers. * Retention is capped by a setting; the feature is disabled by default. */ class Linkbraid_Clicks { private static function table() { global $wpdb; return $wpdb->prefix . 'lbraid_click_data'; } public static function enabled() { return (bool) get_option('lbraid_click_tracking', 0); } public static function retention_days() { $days = (int) get_option('lbraid_click_retention_days', 90); return in_array($days, [30, 90, 180, 365], true) ? $days : 90; } /** Records a click. Internal URLs pointing at published content only. */ public static function record($source_post_id, $href, $anchor) { global $wpdb; if (!self::enabled()) return false; $url = Linkbraid_Content::resolve_url((string) $href); if (!$url) return false; $target = (int) url_to_postid($url); if (!$target || get_post_status($target) !== 'publish') return false; $source = absint($source_post_id); $ok = false !== $wpdb->insert(self::table(), [ 'clicked_at' => current_time('mysql'), 'source_post_id' => $source, 'target_post_id' => $target, 'anchor_text' => mb_substr(sanitize_text_field((string) $anchor), 0, 300), ]); if ($ok && wp_rand(1, 50) === 1) self::cleanup(); return $ok; } /** Removes data older than the retention period. */ public static function cleanup() { global $wpdb; $table = self::table(); if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)))) return; $cutoff = gmdate('Y-m-d H:i:s', strtotime(current_time('mysql')) - self::retention_days() * DAY_IN_SECONDS); $wpdb->query($wpdb->prepare("DELETE FROM $table WHERE clicked_at < %s", $cutoff)); } /** Daily click series for the last $days days (zero-filled). */ public static function series($days = 30) { global $wpdb; $days = max(7, min(90, (int) $days)); $start = gmdate('Y-m-d', strtotime(current_time('Y-m-d')) - ($days - 1) * DAY_IN_SECONDS); $rows = $wpdb->get_results($wpdb->prepare( 'SELECT DATE(clicked_at) AS d, COUNT(*) AS c FROM ' . self::table() . ' WHERE clicked_at >= %s GROUP BY DATE(clicked_at)', $start), ARRAY_A); $map = []; foreach ($rows as $row) $map[$row['d']] = (int) $row['c']; $out = []; for ($i = 0; $i < $days; $i++) { $date = gmdate('Y-m-d', strtotime($start) + $i * DAY_IN_SECONDS); $out[] = ['d' => $date, 'c' => $map[$date] ?? 0]; } return $out; } public static function total($days = 30) { global $wpdb; $start = gmdate('Y-m-d', strtotime(current_time('Y-m-d')) - (max(1, (int) $days) - 1) * DAY_IN_SECONDS); return (int) $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM ' . self::table() . ' WHERE clicked_at >= %s', $start)); } }