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)) . '
| ' . esc_html(self::t($heading)) . ' | ' . esc_html(self::t('views')) . ' |
|---|---|
| ' . esc_html($value) . ' | ' . esc_html(number_format_i18n((int)$row->views)) . ' |
' . esc_html(self::t('empty_hint')) . '