<?php
/**
* Plugin Name: Pro Hit Tracker
* Description: Tracks unique daily hits per post/page via REST API, with spam protection.
* Version: 1.2
* Author: You
*/
if (!defined('ABSPATH')) exit;
// ==========================
// 0. HELPERS
// ==========================
/**
* Returns the configured post types as a clean array.
* Falls back to ['post', 'page'] if nothing saved yet.
*/
function pht_get_post_types(): array {
$saved = get_option('pht_post_types', '');
if (empty(trim($saved))) {
return ['post', 'page'];
}
return array_values(array_filter(
array_map('sanitize_key', array_map('trim', explode(',', $saved)))
));
}
// ==========================
// 1. REGISTER REST ENDPOINT
// ==========================
add_action('rest_api_init', function () {
register_rest_route('pht/v1', '/track', [
'methods' => 'POST',
'callback' => 'pht_track_hit',
'permission_callback' => '__return_true',
'args' => [
'post_id' => [
'required' => true,
'validate_callback' => fn($v) => is_numeric($v) && (int)$v > 0,
'sanitize_callback' => 'absint',
],
'hp' => [
'required' => false,
'sanitize_callback' => 'sanitize_text_field',
],
],
]);
});
// ==========================
// 2. TRACK HIT CALLBACK
// ==========================
function pht_track_hit(WP_REST_Request $request) {
do_action('litespeed_control_set_nocache', 'pht tracker endpoint');
if (!empty($request->get_param('hp'))) {
return new WP_REST_Response(['status' => 'ok'], 200);
}
$nonce = $request->get_header('X-WP-Nonce');
if (!wp_verify_nonce($nonce, 'wp_rest')) {
return new WP_REST_Response(['error' => 'invalid nonce'], 403);
}
$origin = $request->get_header('origin');
$referer = $request->get_header('referer');
$home = home_url();
$origin_ok = $origin && str_starts_with($origin, $home);
$referer_ok = $referer && str_starts_with($referer, $home);
if (!$origin_ok && !$referer_ok) {
return new WP_REST_Response(['error' => 'forbidden'], 403);
}
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
if (empty($ua) || preg_match('/bot|crawl|spider|slurp|curl|wget|python|go-http/i', $ua)) {
return new WP_REST_Response(['status' => 'bot ignored'], 200);
}
$ip = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0')[0]);
$rate_key = 'pht_rate_' . md5($ip);
$rate_count = (int) get_transient($rate_key);
if ($rate_count >= 60) {
return new WP_REST_Response(['error' => 'rate limit exceeded'], 429);
}
set_transient($rate_key, $rate_count + 1, HOUR_IN_SECONDS);
$post_id = $request->get_param('post_id');
$post = get_post($post_id);
$post_types = pht_get_post_types();
if (!$post || !in_array($post->post_type, $post_types, true) || $post->post_status !== 'publish') {
return new WP_REST_Response(['error' => 'invalid post'], 404);
}
$date = date('Y-m-d');
$hit_key = 'hit_' . md5($post_id . $ip . $date);
if (get_transient($hit_key)) {
return new WP_REST_Response(['status' => 'already counted'], 200);
}
set_transient($hit_key, 1, DAY_IN_SECONDS);
$total = (int) get_post_meta($post_id, 'post_hits', true);
update_post_meta($post_id, 'post_hits', $total + 1);
$daily_key = 'post_hits_daily_' . $date;
$daily = (int) get_post_meta($post_id, $daily_key, true);
update_post_meta($post_id, $daily_key, $daily + 1);
return new WP_REST_Response(['status' => 'counted'], 200);
}
// ==========================
// 3. ENQUEUE TRACKER SCRIPT
// ==========================
add_action('rest_api_init', function () {
register_rest_route('pht/v1', '/nonce', [
'methods' => 'GET',
'callback' => function () {
do_action('litespeed_control_set_nocache', 'pht nonce endpoint');
return new WP_REST_Response(['nonce' => wp_create_nonce('wp_rest')], 200);
},
'permission_callback' => '__return_true',
]);
});
add_action('wp_enqueue_scripts', function () {
if (!is_singular()) return;
$post = get_queried_object();
$post_types = pht_get_post_types();
if (!$post || !in_array($post->post_type, $post_types, true)) return;
wp_register_script('pht-tracker', false, [], null, true);
wp_enqueue_script('pht-tracker');
wp_add_inline_script('pht-tracker', pht_get_tracker_js());
wp_localize_script('pht-tracker', 'phtData', [
'restUrl' => rest_url('pht/v1/track'),
'nonceUrl' => rest_url('pht/v1/nonce'),
'postId' => get_queried_object_id(),
]);
});
function pht_get_tracker_js(): string {
return <<<'JS'
document.addEventListener('DOMContentLoaded', function () {
if (typeof phtData === 'undefined') return;
var sessionKey = 'pht_tracked_' + phtData.postId;
if (sessionStorage.getItem(sessionKey)) return;
fetch(phtData.nonceUrl, { credentials: 'same-origin' })
.then(function (res) { return res.json(); })
.then(function (data) {
if (!data.nonce) throw new Error('no nonce');
return fetch(phtData.restUrl, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': data.nonce,
},
body: JSON.stringify({ post_id: phtData.postId, hp: '' }),
});
})
.then(function (res) {
if (res.ok) sessionStorage.setItem(sessionKey, '1');
})
.catch(function () {});
});
JS;
}
// ==========================
// 4. CLEANUP FUNCTION
// ==========================
function pht_clean_all(): array {
global $wpdb;
$meta_deleted = $wpdb->query(
"DELETE FROM {$wpdb->postmeta}
WHERE meta_key = 'post_hits'
OR meta_key LIKE 'post_hits_daily_%'"
);
$transients_deleted = $wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_hit_%'
OR option_name LIKE '_transient_timeout_hit_%'
OR option_name LIKE '_transient_pht_rate_%'
OR option_name LIKE '_transient_timeout_pht_rate_%'"
);
return [
'meta_rows_deleted' => (int) $meta_deleted,
'transient_rows_deleted' => (int) $transients_deleted,
];
}
// ==========================
// 5. HANDLE CLEANUP ACTION
// ==========================
add_action('admin_post_pht_reset', function () {
if (!current_user_can('manage_options')) wp_die('Unauthorized', 403);
check_admin_referer('pht_reset_action', 'pht_reset_nonce');
$result = pht_clean_all();
wp_redirect(add_query_arg([
'page' => 'pht-settings',
'pht_reset' => 'done',
'pht_meta' => $result['meta_rows_deleted'],
'pht_trans' => $result['transient_rows_deleted'],
], admin_url('options-general.php')));
exit;
});
// ==========================
// 6. SETTINGS PAGE
// ==========================
add_action('admin_menu', function () {
add_options_page(
'Pro Hit Tracker Settings',
'Pro Hit Tracker',
'manage_options',
'pht-settings',
'pht_render_settings_page'
);
});
add_action('admin_init', function () {
register_setting('pht_settings_group', 'pht_post_types', [
'sanitize_callback' => function (string $value): string {
// Sanitize each slug and re-join
$types = array_filter(
array_map('sanitize_key', array_map('trim', explode(',', $value)))
);
return implode(', ', $types);
},
'default' => 'post, page',
]);
});
function pht_render_settings_page(): void {
if (!current_user_can('manage_options')) return;
// Reset notice
if (isset($_GET['pht_reset'], $_GET['pht_meta'], $_GET['pht_trans']) && $_GET['pht_reset'] === 'done') {
$meta = (int) $_GET['pht_meta'];
$trans = (int) $_GET['pht_trans'];
echo '<div class="notice notice-success is-dismissible"><p>';
echo "<strong>Pro Hit Tracker:</strong> Reset complete. Removed {$meta} hit meta rows and {$trans} transient rows.";
echo '</p></div>';
}
$current_types = get_option('pht_post_types', 'post, page');
$action_url = esc_url(admin_url('admin-post.php'));
$reset_nonce = wp_nonce_field('pht_reset_action', 'pht_reset_nonce', true, false);
// Detect all registered public post types for the hint list
$registered = get_post_types(['public' => true], 'objects');
$hints = [];
foreach ($registered as $pt) {
$hints[] = '<code>' . esc_html($pt->name) . '</code> (' . esc_html($pt->label) . ')';
}
?>
<div class="wrap">
<h1>Pro Hit Tracker Settings</h1>
<form method="post" action="options.php">
<?php settings_fields('pht_settings_group'); ?>
<table class="form-table" role="presentation">
<tr>
<th scope="row">
<label for="pht_post_types">Tracked Post Types</label>
</th>
<td>
<input
type="text"
id="pht_post_types"
name="pht_post_types"
value="<?php echo esc_attr($current_types); ?>"
class="regular-text"
placeholder="post, page"
>
<p class="description">
Comma-separated list of post type slugs to track.<br>
Registered public post types on this site:
<?php echo implode(', ', $hints); ?>
</p>
</td>
</tr>
</table>
<?php submit_button('Save Settings'); ?>
</form>
<hr>
<h2>Danger Zone</h2>
<p>This will permanently delete all recorded hits and rate-limit transients.</p>
<form method="post" action="<?php echo $action_url; ?>">
<input type="hidden" name="action" value="pht_reset">
<?php echo $reset_nonce; ?>
<button
type="submit"
class="button button-link-delete"
onclick="return confirm('Reset ALL hit counts and transients? This cannot be undone.')">
♻ Reset All Hits
</button>
</form>
</div>
<?php
}
// ==========================
// 7. ADMIN COLUMNS
// ==========================
// Dynamically register column hooks for all configured post types
add_action('init', function () {
foreach (pht_get_post_types() as $pt) {
$col_filter = $pt === 'page' ? 'manage_pages_columns' : "manage_{$pt}_posts_columns";
$col_action = $pt === 'page' ? 'manage_pages_custom_column' : "manage_{$pt}_posts_custom_column";
$sort_filter = "manage_edit-{$pt}_sortable_columns";
add_filter($col_filter, 'pht_add_hits_column');
add_action($col_action, 'pht_render_hits_column', 10, 2);
add_filter($sort_filter, 'pht_sortable_hits_column');
}
}, 20); // after CPTs are registered
function pht_add_hits_column(array $cols): array {
$cols['post_hits'] = 'Hits';
return $cols;
}
function pht_render_hits_column(string $col, int $id): void {
if ($col === 'post_hits') {
echo number_format((int) get_post_meta($id, 'post_hits', true));
}
}
function pht_sortable_hits_column(array $cols): array {
$cols['post_hits'] = 'post_hits';
return $cols;
}
add_action('pre_get_posts', function (WP_Query $q): void {
if (!is_admin() || !$q->is_main_query()) return;
if ($q->get('orderby') === 'post_hits') {
$q->set('meta_key', 'post_hits');
$q->set('orderby', 'meta_value_num');
}
});
// ==========================
// 8. DASHBOARD WIDGET
// ==========================
add_action('wp_dashboard_setup', function () {
wp_add_dashboard_widget('top_posts_hits', 'Top Posts by Hits', 'pht_render_dashboard_widget');
});
function pht_render_dashboard_widget(): void {
$posts = get_posts([
'post_type' => pht_get_post_types(),
'posts_per_page' => 5,
'meta_key' => 'post_hits',
'orderby' => 'meta_value_num',
'order' => 'DESC',
'post_status' => 'publish',
]);
if (!$posts) {
echo '<p>No data yet.</p>';
} else {
echo '<ul>';
foreach ($posts as $p) {
$hits = number_format((int) get_post_meta($p->ID, 'post_hits', true));
$link = esc_url(get_edit_post_link($p->ID));
$title = esc_html($p->post_title);
echo "<li><a href=\"{$link}\">{$title}</a> — {$hits} hits</li>";
}
echo '</ul>';
}
if (!current_user_can('manage_options')) return;
echo '<hr style="margin: 12px 0;">';
echo '<p style="margin:0"><a href="' . esc_url(admin_url('options-general.php?page=pht-settings')) . '" class="button button-small">⚙️ PHT Settings & Reset</a></p>';
}//
