prefix . 'codlino_hits'; } public static function settings() { return wp_parse_args(get_option('codlino_settings', array()), array('enabled' => 1, 'consent' => 0, 'retention' => 365)); } public static function activate() { global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); dbDelta("CREATE TABLE $table ( id bigint unsigned NOT NULL AUTO_INCREMENT, day date NOT NULL, visitor char(64) NOT NULL, path varchar(512) NOT NULL, referrer varchar(190) NOT NULL DEFAULT '', device varchar(20) NOT NULL DEFAULT 'Desktop', PRIMARY KEY (id), KEY day_visitor (day,visitor) ) " . $wpdb->get_charset_collate() . ';'); if (!wp_next_scheduled('codlino_cleanup')) { wp_schedule_event(time(), 'daily', 'codlino_cleanup'); } } public static function deactivate() { wp_clear_scheduled_hook('codlino_cleanup'); } public static function cleanup() { global $wpdb; $cutoff = wp_date('Y-m-d', time() - absint(self::settings()['retention']) * DAY_IN_SECONDS); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Retention write to a custom table; report cache is invalidated below. $wpdb->query($wpdb->prepare('DELETE FROM %i WHERE day < %s', self::table(), $cutoff)); self::invalidate_reports(); } public static function enqueue() { $s = self::settings(); if (!$s['enabled'] || is_user_logged_in() || is_preview() || is_feed()) { return; } wp_enqueue_script('codlino-tracker', plugins_url('assets/tracker.js', __FILE__), array(), self::VERSION, true); wp_add_inline_script('codlino-tracker', 'window.CodlinoConfig=' . wp_json_encode(array('endpoint' => rest_url('codlino/v1/hit'), 'consent' => (bool)$s['consent'])) . ';', 'before'); } public static function routes() { register_rest_route('codlino/v1', '/hit', array('methods' => 'POST', 'permission_callback' => '__return_true', 'callback' => array(__CLASS__, 'hit'))); } public static function hit($request) { $s = self::settings(); if (!$s['enabled'] || is_user_logged_in() || (!empty($_SERVER['HTTP_DNT']) && $_SERVER['HTTP_DNT'] === '1') || !empty($_SERVER['HTTP_SEC_GPC']) && $_SERVER['HTTP_SEC_GPC'] === '1') { return new WP_REST_Response(null, 204); } $origin = $request->get_header('origin'); if (!$origin) { $origin = $request->get_header('referer'); } if (!$origin || strtolower((string)wp_parse_url($origin, PHP_URL_HOST)) !== strtolower((string)wp_parse_url(home_url(), PHP_URL_HOST))) { return new WP_Error('origin', 'Invalid origin.', array('status' => 403)); } if ($s['consent'] && $request->get_param('consent') !== true) { return new WP_REST_Response(null, 204); } $ua = isset($_SERVER['HTTP_USER_AGENT']) ? substr(sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])), 0, 512) : ''; if (!$ua || preg_match('/bot|crawler|spider|headless|curl|wget/i', $ua)) { return new WP_REST_Response(null, 204); } $path = $request->get_param('path'); if (!is_string($path) || strlen($path) > 512 || substr($path, 0, 1) !== '/' || substr($path, 0, 2) === '//' || preg_match('/[?#\r\n]/', $path)) { return new WP_Error('path', 'Invalid path.', array('status' => 400)); } if (preg_match('~/(wp-admin|wp-login\.php|wp-json)(/|$)~i', $path)) { return new WP_REST_Response(null, 204); } $day = current_time('Y-m-d'); $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''; $ip = filter_var($ip, FILTER_VALIDATE_IP) ?: ''; $visitor = hash_hmac('sha256', $day . '|' . $ip . '|' . $ua, wp_salt('auth')); $key = 'codlino_rate_' . substr($visitor, 0, 32); if (get_transient($key)) { return new WP_REST_Response(null, 204); } set_transient($key, 1, 2); $ref = $request->get_param('referrer'); $host = is_string($ref) ? strtolower((string)wp_parse_url(substr($ref, 0, 2048), PHP_URL_HOST)) : ''; if ($host === strtolower((string)wp_parse_url(home_url(), PHP_URL_HOST))) { $host = ''; } $device = preg_match('/ipad|tablet/i', $ua) ? 'Tablet' : (preg_match('/mobile|android|iphone/i', $ua) ? 'Mobile' : 'Desktop'); global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Validated analytics event stored in the plugin's custom table. $ok = $wpdb->insert(self::table(), array('day' => $day, 'visitor' => $visitor, 'path' => sanitize_text_field($path), 'referrer' => substr(sanitize_text_field($host), 0, 190), 'device' => $device), array('%s','%s','%s','%s','%s')); if ($ok) { self::invalidate_reports(); } return $ok ? new WP_REST_Response(null, 204) : new WP_Error('storage', 'Unable to record visit.', array('status' => 503)); } public static function menu() { $hook = add_menu_page('Codlino Statistics', 'Codlino Statistics', 'manage_options', 'codlino-statistics', array(__CLASS__, 'dashboard'), self::logo_motion_url(), 65); add_action('load-' . $hook, array(__CLASS__, 'load_dashboard')); } public static function admin_assets($hook) { wp_enqueue_style('codlino-branding', plugins_url('assets/branding.css', __FILE__), array(), self::VERSION); if ($hook === 'toplevel_page_codlino-statistics') { wp_enqueue_style('codlino-admin', plugins_url('assets/admin.css', __FILE__), array(), self::VERSION); wp_enqueue_script('codlino-admin', plugins_url('assets/admin.js', __FILE__), array(), self::VERSION, true); } } public static function valid_range($value) { if (!is_scalar($value) || !preg_match('/^\d+$/', (string)$value)) { return 30; } $days = (int)$value; return in_array($days, array(0,1,7,30,90,365), true) ? $days : 30; } public static function range() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only report filter; never changes settings or stored data. $value = isset($_GET['days']) && is_string($_GET['days']) ? sanitize_text_field(wp_unslash($_GET['days'])) : '30'; return self::valid_range($value); } public static function since($days) { $offset = $days === 0 ? 1 : $days - 1; return (new DateTimeImmutable('today', wp_timezone()))->modify('-' . $offset . ' days')->format('Y-m-d'); } public static function until($days) { return (new DateTimeImmutable('today', wp_timezone()))->modify($days === 0 ? '-1 day' : '+0 days')->format('Y-m-d'); } public static function period_label($days) { if ($days === 0) { return self::t('yesterday'); } if ($days === 1) { return self::t('today_period'); } if ($days === 365) { return self::t('year_period'); } return sprintf(self::t('last_days'), $days); } public static function chart_points($trend, $since, $until, $days) { $points = array(); $date = new DateTimeImmutable($since, wp_timezone()); $end = new DateTimeImmutable($until, wp_timezone()); while ($date <= $end) { $day = $date->format('Y-m-d'); $key = $days === 365 ? $date->format('Y-m') : $day; if (!isset($points[$key])) { $points[$key] = 0; } $points[$key] += isset($trend[$day]) ? (int)$trend[$day]->views : 0; $date = $date->modify('+1 day'); } return $points; } public static function save() { if (!current_user_can('manage_options')) { wp_die(esc_html(self::t('denied'))); } check_admin_referer('codlino_settings', 'codlino_settings_nonce'); $retention = isset($_POST['retention']) ? absint($_POST['retention']) : 90; update_option('codlino_settings', array('enabled' => isset($_POST['enabled']) ? 1 : 0, 'consent' => isset($_POST['consent']) ? 1 : 0, 'retention' => max(7, min(365, $retention)))); self::cleanup(); wp_safe_redirect(admin_url('admin.php?page=codlino-statistics&saved=1')); exit; } public static function csv_cell($value) { return preg_match('/^[=+@\-\t\r]/', (string)$value) ? "'" . $value : $value; } public static function export() { if (!current_user_can('manage_options')) { wp_die(esc_html(self::t('denied'))); } check_admin_referer('codlino_export'); global $wpdb; $days = self::range(); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Explicit administrator CSV export must read fresh totals from the custom analytics table. $rows = $wpdb->get_results($wpdb->prepare('SELECT day, COUNT(*) AS views, COUNT(DISTINCT visitor) AS visitors FROM %i WHERE day BETWEEN %s AND %s GROUP BY day ORDER BY day', self::table(), self::since($days), self::until($days)), ARRAY_A); nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="codlino-statistics.csv"'); $out = fopen('php://output', 'w'); echo "\xEF\xBB\xBF"; fputcsv($out, array(self::t('date'), self::t('views'), self::t('sum_visitors')), ',', '"', '\\'); foreach ($rows as $row) { fputcsv($out, array_map(array(__CLASS__, 'csv_cell'), $row), ',', '"', '\\'); } // The response stream is closed by exit; no filesystem files are opened. exit; } private static function listing($title, $rows, $field) { $heading = $field === 'path' ? 'page' : ($field === 'device' ? 'device' : 'source'); $total = 0; foreach ($rows as $row) { $total += (int)$row->views; } echo '

' . esc_html(self::t($title)) . '

' . esc_html(count($rows)) . '
'; if (!$rows) { self::empty_state(); echo '
'; return; } echo '
'; foreach ($rows as $row) { $value = $row->$field; if ($field === 'device') { $value = self::t($value); } if (!$value) { $value = self::t('direct'); } $share = $total ? round((int)$row->views / $total * 100) : 0; echo ''; } echo '
' . esc_html(self::t($heading)) . '' . esc_html(self::t('views')) . '
' . esc_html($value) . '' . esc_html(number_format_i18n((int)$row->views)) . '
'; } private static function empty_state() { echo '

' . esc_html(self::t('empty')) . '

' . esc_html(self::t('empty_hint')) . '

'; } public static function invalidate_reports() { wp_cache_set('generation', microtime(true), 'codlino-statistics'); } public static function report($since, $until) { global $wpdb; $generation = wp_cache_get('generation', 'codlino-statistics'); $key = 'report_' . md5($since . '|' . $until . '|' . current_time('Y-m-d') . '|' . (string)$generation); $report = wp_cache_get($key, 'codlino-statistics'); if (false !== $report) { return $report; } $table = self::table(); // Custom table queries are necessary: WordPress has no analytics table API. Results are cached together below. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $totals = $wpdb->get_row($wpdb->prepare('SELECT COUNT(*) AS views, COUNT(DISTINCT visitor) AS visitors FROM %i WHERE day BETWEEN %s AND %s', $table, $since, $until)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $today = $wpdb->get_var($wpdb->prepare('SELECT COUNT(*) FROM %i WHERE day = %s', $table, current_time('Y-m-d'))); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $trend = $wpdb->get_results($wpdb->prepare('SELECT day, COUNT(*) AS views FROM %i WHERE day BETWEEN %s AND %s GROUP BY day ORDER BY day', $table, $since, $until), OBJECT_K); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $pages = $wpdb->get_results($wpdb->prepare('SELECT path, COUNT(*) AS views FROM %i WHERE day BETWEEN %s AND %s GROUP BY path ORDER BY views DESC LIMIT 10', $table, $since, $until)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $refs = $wpdb->get_results($wpdb->prepare('SELECT referrer, COUNT(*) AS views FROM %i WHERE day BETWEEN %s AND %s GROUP BY referrer ORDER BY views DESC LIMIT 10', $table, $since, $until)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery $devices = $wpdb->get_results($wpdb->prepare('SELECT device, COUNT(*) AS views FROM %i WHERE day BETWEEN %s AND %s GROUP BY device ORDER BY views DESC', $table, $since, $until)); $report = compact('totals', 'today', 'trend', 'pages', 'refs', 'devices'); wp_cache_set($key, $report, 'codlino-statistics', 30); return $report; } public static function dashboard_notice() { // These query flags display fixed, escaped messages only; they do not mutate data. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if (isset($_GET['language_expired'])) { return 'language_expired'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended if (isset($_GET['language_invalid'])) { return 'language_invalid'; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended return isset($_GET['saved']) ? 'saved' : ''; } public static function dashboard() { if (!current_user_can('manage_options')) { return; } $codlino_days = self::range(); $codlino_since = self::since($codlino_days); $codlino_until = self::until($codlino_days); $codlino_s = self::settings(); $codlino_report = self::report($codlino_since, $codlino_until); $codlino_totals = $codlino_report['totals']; $codlino_today = $codlino_report['today']; $codlino_trend = $codlino_report['trend']; $codlino_pages = $codlino_report['pages']; $codlino_refs = $codlino_report['refs']; $codlino_devices = $codlino_report['devices']; require __DIR__ . '/includes/dashboard.php'; } } register_activation_hook(__FILE__, array('Codlino_Statistics', 'activate')); register_deactivation_hook(__FILE__, array('Codlino_Statistics', 'deactivate')); add_action('wp_enqueue_scripts', array('Codlino_Statistics', 'enqueue')); add_action('rest_api_init', array('Codlino_Statistics', 'routes')); add_action('admin_menu', array('Codlino_Statistics', 'menu')); add_action('admin_enqueue_scripts', array('Codlino_Statistics', 'admin_assets')); add_action('admin_post_codlino_save', array('Codlino_Statistics', 'save')); add_action('admin_post_codlino_export', array('Codlino_Statistics', 'export')); add_action('codlino_cleanup', array('Codlino_Statistics', 'cleanup')); add_action('admin_post_codlino_language', array('Codlino_Statistics', 'save_language'));