André Amorim

Crafting Web Experiences

//

Save page hits of WordPress Custom Post Types

<?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.')">
                &#x267B; 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> &mdash; {$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>';
}

Published date:

Modified date: