- WordPress core y plugins - Tema Twenty Twenty-Four configurado - Plugin allow-unfiltered-html.php simplificado - .gitignore configurado para excluir wp-config.php y uploads 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
760 lines
32 KiB
PHP
Executable File
760 lines
32 KiB
PHP
Executable File
<?php
|
|
/*
|
|
Plugin Name: IP View Limit (Anti-Incognito) for WordPress
|
|
Description: Limita vistas únicas por IP con reinicio periódico, barra de conteo y exclusiones por URL/regex/categorías. Evita evasiones por incógnito registrando por IP. Incluye debug detallado y exclusión de bots/proxies.
|
|
Version: 1.3.1
|
|
Author: PODC4 Desarrollo
|
|
License: GPLv2 or later
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) exit;
|
|
|
|
class IP_View_Limit_Plugin {
|
|
private static $instance = null;
|
|
|
|
private $table_counters;
|
|
private $table_pages;
|
|
private $opts_key = 'ipvl_options';
|
|
|
|
// Logging
|
|
private $log_file;
|
|
private $log_max_mb = 5;
|
|
|
|
private $bar_markup = '';
|
|
private $bar_printed = false;
|
|
|
|
public static function instance() {
|
|
if (self::$instance === null) self::$instance = new self();
|
|
return self::$instance;
|
|
}
|
|
|
|
private function __construct() {
|
|
global $wpdb;
|
|
$this->table_counters = $wpdb->prefix . 'rcp_paginas_vistas_recuento';
|
|
$this->table_pages = $wpdb->prefix . 'rcp_paginas_vistas';
|
|
$this->log_file = plugin_dir_path(__FILE__) . 'ip_view_limit_debug.txt';
|
|
|
|
register_activation_hook(__FILE__, [$this, 'activate']);
|
|
register_uninstall_hook(__FILE__, ['IP_View_Limit_Plugin', 'uninstall']);
|
|
|
|
add_action('admin_menu', [$this, 'admin_menu']);
|
|
add_action('admin_init', [$this, 'register_settings']);
|
|
|
|
add_action('template_redirect', [$this, 'enforce_limit'], 1);
|
|
|
|
add_action('template_redirect', [$this, 'prepare_bar'], 20); // calcula %count% y %limit%
|
|
add_action('wp_body_open', [$this, 'output_bar_markup'], 0); // imprime la barra
|
|
add_action('wp_footer', [$this, 'output_bar_markup'], 0); // fallback si el theme no llama wp_body_open
|
|
|
|
add_action('wp_head', [$this, 'print_custom_css']);
|
|
}
|
|
|
|
/* ===================== DEFAULTS / SETTINGS ===================== */
|
|
|
|
public function default_options() {
|
|
return [
|
|
'enabled' => 1,
|
|
'apply_to_logged' => 0,
|
|
'limit' => 3,
|
|
'reset_days' => 1,
|
|
'redirect_url' => '/suscripcion-vip/',
|
|
'excluded_exact' => "/login/\n/register/\n/suscripcion-vip/\n/blog/",
|
|
'excluded_regex' => "#^/(?:buscar-|buscador-apus)#\n#^/wp-(?:admin|login|json)/#\n#^/$#\n#\\.(?:jpg|jpeg|png|gif|webp|svg|css|js)$#i",
|
|
// NUEVO: regex o lista (se permite "blog|otra categoria" sin delimitadores)
|
|
'excluded_cats_regex' => "",
|
|
'show_bar' => 1,
|
|
'bar_html' => '<div id="ipvl-bar" class="ipvl-bar"><a href="/suscripcion-vip">Te restan (%count%) consultas de %limit%. Hazte VIP ahora.</a></div>',
|
|
'bar_css' => "/* IP View Limit Bar */\n.ipvl-bar{position:sticky;top:0;z-index:9999;background:#fff3cd;border-bottom:1px solid #ffeeba;padding:8px;text-align:center;font:14px/1.3 system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}\n.ipvl-bar a{text-decoration:none}\n",
|
|
'debug' => 1,
|
|
// PROXIES / BOTS
|
|
'trust_proxy_headers' => 1,
|
|
'trusted_proxy_ips' => "",
|
|
'ignore_bots_regex' =>
|
|
"#(googlebot|googleother|bingbot|duckduckbot|slurp|baiduspider|yandex(bot)?|ahrefsbot|semrush(bot)?|mj12bot|facebookexternalhit|meta-externalagent|twitterbot|telegrambot|whatsapp|slackbot|discordbot|linkedinbot|pinterest|bitlybot|python-requests|curl|wget|awariobot|oai-searchbot|gptbot|chatgpt-user|barkrowler|mediapartners-google|adsbot-google|applebot|screaming\\s?frog|simplepie|feedfetcher|uptimerobot|statuscake|monitors?bot)#i",
|
|
];
|
|
}
|
|
|
|
public function activate() {
|
|
global $wpdb;
|
|
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
|
$charset_collate = $wpdb->get_charset_collate();
|
|
|
|
$sql1 = "CREATE TABLE {$this->table_counters} (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
ip VARBINARY(16) NOT NULL,
|
|
period_start DATETIME NOT NULL,
|
|
page_count INT UNSIGNED NOT NULL DEFAULT 0,
|
|
last_seen DATETIME NOT NULL,
|
|
PRIMARY KEY (id),
|
|
KEY ip_period (ip, period_start)
|
|
) $charset_collate;";
|
|
|
|
$sql2 = "CREATE TABLE {$this->table_pages} (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
counter_id BIGINT UNSIGNED NOT NULL,
|
|
post_id BIGINT UNSIGNED NOT NULL,
|
|
first_seen DATETIME NOT NULL,
|
|
PRIMARY KEY (id),
|
|
UNIQUE KEY counter_post (counter_id, post_id),
|
|
KEY post_id (post_id)
|
|
) $charset_collate;";
|
|
|
|
$this->log('activate.start', [
|
|
'tables' => [$this->table_counters, $this->table_pages],
|
|
'charset' => $charset_collate
|
|
]);
|
|
|
|
dbDelta($sql1);
|
|
$this->db_error_snapshot('activate.create_counters');
|
|
|
|
dbDelta($sql2);
|
|
$this->db_error_snapshot('activate.create_pages');
|
|
|
|
$opts = get_option($this->opts_key);
|
|
if (!is_array($opts)) {
|
|
add_option($this->opts_key, $this->default_options(), '', 'no');
|
|
$this->log('activate.options_default_set', ['key' => $this->opts_key]);
|
|
} else {
|
|
$this->log('activate.options_exist', ['key' => $this->opts_key]);
|
|
}
|
|
|
|
$this->log('activate.done', []);
|
|
}
|
|
|
|
public static function uninstall() {
|
|
$self = self::instance();
|
|
delete_option($self->opts_key);
|
|
$self->log('uninstall.options_deleted', ['key' => $self->opts_key]);
|
|
}
|
|
|
|
private function get_options() {
|
|
$opts = get_option($this->opts_key, []);
|
|
$defaults = $this->default_options();
|
|
return wp_parse_args($opts, $defaults);
|
|
}
|
|
|
|
public function admin_menu() {
|
|
add_options_page('IP View Limit', 'IP View Limit', 'manage_options', 'ip-view-limit', [$this, 'settings_page']);
|
|
}
|
|
|
|
public function register_settings() {
|
|
register_setting($this->opts_key, $this->opts_key, [$this, 'sanitize_options']);
|
|
add_settings_section('ipvl_main', 'Configuración', '__return_false', $this->opts_key);
|
|
|
|
$fields = [
|
|
['enabled', 'Habilitar', 'checkbox'],
|
|
['apply_to_logged', 'Aplicar también a usuarios autenticados', 'checkbox'],
|
|
['limit', 'Páginas únicas permitidas por periodo', 'number'],
|
|
['reset_days', 'Días para reiniciar conteo', 'number'],
|
|
['redirect_url', 'URL de redirección al alcanzar el límite', 'text'],
|
|
['excluded_exact', 'Exclusiones por URL exacta (una por línea)', 'textarea'],
|
|
['excluded_regex', 'Exclusiones por Regex de URL (una por línea)', 'textarea'],
|
|
['excluded_cats_regex', 'Exclusiones por categorías (regex de NOMBRE o SLUG; una por línea)', 'textarea'],
|
|
['show_bar', 'Mostrar barra de conteo', 'checkbox'],
|
|
['bar_html', 'HTML de la barra (usa %count% y %limit%)', 'textarea'],
|
|
['bar_css', 'CSS personalizado de la barra', 'textarea'],
|
|
// Debug / Proxies / Bots
|
|
['debug', 'Habilitar debug a archivo .txt', 'checkbox'],
|
|
['trust_proxy_headers', 'Confiar en cabeceras de proxy (X-Forwarded-For / CF-Connecting-IP)', 'checkbox'],
|
|
['trusted_proxy_ips', 'Lista de IPs de proxy de confianza (una por línea; vacío = confiar en todos)', 'textarea'],
|
|
['ignore_bots_regex', 'Regex de User-Agent para ignorar bots (no contabilizar vistas)', 'textarea'],
|
|
];
|
|
|
|
foreach ($fields as $f) {
|
|
add_settings_field($f[0], $f[1], [$this, 'field_render'], $this->opts_key, 'ipvl_main', ['key' => $f[0], 'type' => $f[2]]);
|
|
}
|
|
}
|
|
|
|
public function sanitize_options($input) {
|
|
$opts = $this->get_options();
|
|
$opts['enabled'] = !empty($input['enabled']) ? 1 : 0;
|
|
$opts['apply_to_logged'] = !empty($input['apply_to_logged']) ? 1 : 0;
|
|
$opts['limit'] = max(0, intval($input['limit'] ?? $opts['limit']));
|
|
$opts['reset_days'] = max(1, intval($input['reset_days'] ?? $opts['reset_days']));
|
|
$opts['redirect_url'] = esc_url_raw($input['redirect_url'] ?? $opts['redirect_url']);
|
|
$opts['excluded_exact'] = trim(wp_unslash($input['excluded_exact'] ?? $opts['excluded_exact']));
|
|
$opts['excluded_regex'] = trim(wp_unslash($input['excluded_regex'] ?? $opts['excluded_regex']));
|
|
$opts['excluded_cats_regex'] = trim(wp_unslash($input['excluded_cats_regex'] ?? ($opts['excluded_cats_regex'] ?? '')));
|
|
$opts['show_bar'] = !empty($input['show_bar']) ? 1 : 0;
|
|
$opts['bar_html'] = wp_kses_post($input['bar_html'] ?? $opts['bar_html']);
|
|
$opts['bar_css'] = wp_kses_post($input['bar_css'] ?? $opts['bar_css']);
|
|
$opts['debug'] = !empty($input['debug']) ? 1 : 0;
|
|
$opts['trust_proxy_headers'] = !empty($input['trust_proxy_headers']) ? 1 : 0;
|
|
$opts['trusted_proxy_ips'] = trim(wp_unslash($input['trusted_proxy_ips'] ?? $opts['trusted_proxy_ips']));
|
|
$opts['ignore_bots_regex'] = trim(wp_unslash($input['ignore_bots_regex'] ?? $opts['ignore_bots_regex']));
|
|
|
|
$this->log('settings.saved', [
|
|
'opts' => [
|
|
'enabled' => $opts['enabled'],
|
|
'apply_to_logged' => $opts['apply_to_logged'],
|
|
'limit' => $opts['limit'],
|
|
'reset_days' => $opts['reset_days'],
|
|
'redirect_url' => $opts['redirect_url'],
|
|
'show_bar' => $opts['show_bar'],
|
|
'debug' => $opts['debug'],
|
|
'trust_proxy_headers' => $opts['trust_proxy_headers']
|
|
]
|
|
]);
|
|
|
|
return $opts;
|
|
}
|
|
|
|
public function field_render($args) {
|
|
$key = $args['key'];
|
|
$type = $args['type'];
|
|
$opts = $this->get_options();
|
|
$val = $opts[$key] ?? '';
|
|
|
|
if ($type === 'checkbox') {
|
|
echo '<label><input type="checkbox" name="' . esc_attr($this->opts_key) . '[' . esc_attr($key) . ']" value="1" ' . checked($val, 1, false) . '> Sí</label>';
|
|
} elseif ($type === 'number') {
|
|
echo '<input type="number" min="0" step="1" class="regular-text" name="' . esc_attr($this->opts_key) . '[' . esc_attr($key) . ']" value="' . esc_attr($val) . '">';
|
|
} elseif ($type === 'text') {
|
|
echo '<input type="text" class="regular-text" name="' . esc_attr($this->opts_key) . '[' . esc_attr($key) . ']" value="' . esc_attr($val) . '">';
|
|
} elseif ($type === 'textarea') {
|
|
echo '<textarea class="large-text code" rows="6" name="' . esc_attr($this->opts_key) . '[' . esc_attr($key) . ']">' . esc_textarea($val) . '</textarea>';
|
|
}
|
|
}
|
|
|
|
public function settings_page() {
|
|
?>
|
|
<div class="wrap">
|
|
<h1>IP View Limit</h1>
|
|
<form method="post" action="options.php">
|
|
<?php
|
|
settings_fields($this->opts_key);
|
|
do_settings_sections($this->opts_key);
|
|
submit_button();
|
|
?>
|
|
</form>
|
|
<p><small>Archivo de debug: <code><?php echo esc_html(basename($this->log_file)); ?></code> (carpeta del plugin)</small></p>
|
|
</div>
|
|
<?php
|
|
}
|
|
|
|
/* ===================== CORE HELPERS ===================== */
|
|
|
|
private function ensure_tables_exist() {
|
|
global $wpdb;
|
|
$exists_c = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $this->table_counters));
|
|
$exists_p = $wpdb->get_var($wpdb->prepare("SHOW TABLES LIKE %s", $this->table_pages));
|
|
if (!$exists_c || !$exists_p) {
|
|
$this->log('ensure_tables.create_needed', ['counters_exists' => (bool)$exists_c, 'pages_exists' => (bool)$exists_p]);
|
|
$this->activate();
|
|
} else {
|
|
$this->log('ensure_tables.ok', ['counters' => $this->table_counters, 'pages' => $this->table_pages]);
|
|
}
|
|
}
|
|
|
|
private function is_trusted_proxy($ip, $opts) {
|
|
$list_raw = (string)$opts['trusted_proxy_ips'];
|
|
$list = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $list_raw)));
|
|
if (empty($list)) return true;
|
|
return in_array($ip, $list, true);
|
|
}
|
|
|
|
private function ip_is_public($ip) {
|
|
return (bool) filter_var($ip, FILTER_VALIDATE_IP,
|
|
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
|
|
}
|
|
|
|
private function get_client_ip_info() {
|
|
$opts = $this->get_options();
|
|
$server_ip = $_SERVER['REMOTE_ADDR'] ?? '';
|
|
$candidate = '';
|
|
|
|
if (!empty($opts['trust_proxy_headers']) && $server_ip && $this->is_trusted_proxy($server_ip, $opts)) {
|
|
if (!empty($_SERVER['HTTP_CF_CONNECTING_IP']) && $this->ip_is_public($_SERVER['HTTP_CF_CONNECTING_IP'])) {
|
|
$candidate = $_SERVER['HTTP_CF_CONNECTING_IP'];
|
|
}
|
|
if (!$candidate && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
|
$parts = array_map('trim', explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
|
|
foreach ($parts as $p) { if ($this->ip_is_public($p)) { $candidate = $p; break; } }
|
|
}
|
|
if (!$candidate && !empty($_SERVER['HTTP_CLIENT_IP']) && $this->ip_is_public($_SERVER['HTTP_CLIENT_IP'])) {
|
|
$candidate = $_SERVER['HTTP_CLIENT_IP'];
|
|
}
|
|
}
|
|
|
|
if (!$candidate && $this->ip_is_public($server_ip)) $candidate = $server_ip;
|
|
if (!$candidate) $candidate = '0.0.0.0';
|
|
|
|
// **NORMALIZACIÓN**: IPv6 mapeada a IPv4 → usar IPv4 pura
|
|
if (stripos($candidate, '::ffff:') === 0) {
|
|
$v4 = substr($candidate, 7);
|
|
if (filter_var($v4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) $candidate = $v4;
|
|
}
|
|
|
|
$bin = @inet_pton($candidate);
|
|
if ($bin === false) $bin = @inet_pton('0.0.0.0');
|
|
|
|
return ['readable' => $candidate, 'bin' => $bin, 'server' => $server_ip];
|
|
}
|
|
|
|
private function ua_is_bot($opts, $ua) {
|
|
$pattern = (string)$opts['ignore_bots_regex'];
|
|
if ($pattern === '') return false;
|
|
set_error_handler(function(){});
|
|
$match = @preg_match($pattern, (string)$ua) === 1;
|
|
restore_error_handler();
|
|
return $match;
|
|
}
|
|
|
|
private function current_period_start($days) {
|
|
$now = current_time('timestamp', true); // UTC
|
|
$start_day = strtotime(gmdate('Y-m-d 00:00:00', $now));
|
|
$epoch_days = floor($start_day / DAY_IN_SECONDS);
|
|
$bucket_start_days = $epoch_days - ($epoch_days % max(1, $days));
|
|
return gmdate('Y-m-d H:i:s', $bucket_start_days * DAY_IN_SECONDS);
|
|
}
|
|
|
|
private function request_path() {
|
|
$req = isset($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '/';
|
|
$parts = @parse_url($req);
|
|
return isset($parts['path']) ? (string)$parts['path'] : '/';
|
|
}
|
|
|
|
private function is_excluded_url($url, $opts, &$reason = '') {
|
|
$path = $this->request_path();
|
|
|
|
// Exact
|
|
$exact_lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string)$opts['excluded_exact'])));
|
|
foreach ($exact_lines as $line) {
|
|
if ($line === '') continue;
|
|
$cmp = untrailingslashit($line);
|
|
$path_norm = untrailingslashit($path);
|
|
if ($cmp === $path_norm) { $reason = 'exact:' . $line; return true; }
|
|
}
|
|
|
|
// Regex
|
|
$regex_lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string)$opts['excluded_regex'])));
|
|
foreach ($regex_lines as $pattern) {
|
|
if ($pattern === '') continue;
|
|
set_error_handler(function(){});
|
|
$match = @preg_match($pattern, $path);
|
|
restore_error_handler();
|
|
if ($match === 1) { $reason = 'regex:' . $pattern; return true; }
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/* ======= helpers para regex de categorías ======= */
|
|
|
|
private function normalize_regex_or_list($line) {
|
|
$line = trim($line);
|
|
if ($line === '') return null;
|
|
|
|
// Si parece un regex con delimitadores estándar, úsalo tal cual.
|
|
if (preg_match('/^([#\\/~%!@`\\|;:]).*\\1[imsxuADSUXJ]*$/', $line)) {
|
|
return $line;
|
|
}
|
|
|
|
// Si es "token1|token2|..." sin delimitadores, construimos un regex exacto, case-insensitive.
|
|
$parts = array_map('trim', explode('|', $line));
|
|
$tokens = array();
|
|
foreach ($parts as $p) {
|
|
if ($p === '') continue;
|
|
$tokens[] = preg_quote($p, '#');
|
|
}
|
|
if (empty($tokens)) return null;
|
|
return '#^(?:' . implode('|', $tokens) . ')$#i';
|
|
}
|
|
|
|
private function patterns_from_lines($raw) {
|
|
$out = [];
|
|
$lines = array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string)$raw)));
|
|
foreach ($lines as $ln) {
|
|
$norm = $this->normalize_regex_or_list($ln);
|
|
if ($norm) $out[] = $norm;
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
private function value_matches_patterns($value, $patterns) {
|
|
foreach ($patterns as $pat) {
|
|
set_error_handler(function(){});
|
|
$ok = @preg_match($pat, $value) === 1;
|
|
restore_error_handler();
|
|
if ($ok) return $pat;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// EXCLUSIÓN por CATEGORÍAS / TAXONOMÍAS (por NOMBRE o SLUG; incluye ancestros si aplica)
|
|
private function is_excluded_by_category($post_id, $opts, &$reason = '') {
|
|
$patterns = $this->patterns_from_lines($opts['excluded_cats_regex'] ?? '');
|
|
if (empty($patterns)) return false;
|
|
|
|
$post_type = get_post_type($post_id);
|
|
if (!$post_type) return false;
|
|
|
|
// Recorremos TODAS las taxonomías registradas para este post type
|
|
$tax_objects = get_object_taxonomies($post_type, 'objects');
|
|
if (empty($tax_objects)) return false;
|
|
|
|
foreach ($tax_objects as $tax) {
|
|
// Solo públicas o consultables
|
|
if (!$tax->public && !$tax->publicly_queryable) continue;
|
|
|
|
$terms = get_the_terms($post_id, $tax->name);
|
|
if (is_wp_error($terms) || empty($terms)) continue;
|
|
|
|
foreach ($terms as $t) {
|
|
// candidatos: slug y nombre del término
|
|
$candidates = [ (string)$t->slug, (string)$t->name ];
|
|
|
|
// si la taxonomía es jerárquica, añadir ancestros (slug y nombre)
|
|
$is_hier = function_exists('is_taxonomy_hierarchical')
|
|
? is_taxonomy_hierarchical($tax->name)
|
|
: !empty($tax->hierarchical);
|
|
|
|
if ($is_hier) {
|
|
$anc_ids = get_ancestors($t->term_id, $tax->name, 'taxonomy');
|
|
if (!empty($anc_ids)) {
|
|
foreach ($anc_ids as $aid) {
|
|
$anc = get_term($aid, $tax->name);
|
|
if ($anc && !is_wp_error($anc)) {
|
|
$candidates[] = (string)$anc->slug;
|
|
$candidates[] = (string)$anc->name;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// probamos cada candidato contra los patrones
|
|
foreach ($candidates as $val) {
|
|
$matched = $this->value_matches_patterns($val, $patterns);
|
|
if ($matched !== false) {
|
|
$reason = 'taxonomy:' . $tax->name . ' term:' . $val . ' matched:' . $matched;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
|
|
private function should_apply($opts, &$why_not = '') {
|
|
if (empty($opts['enabled'])) { $why_not = 'disabled'; return false; }
|
|
if (!is_singular()) { $why_not = 'not_singular'; return false; }
|
|
if (is_user_logged_in() && empty($opts['apply_to_logged'])) { $why_not = 'logged_ignored'; return false; }
|
|
return true;
|
|
}
|
|
|
|
private function get_post_id() {
|
|
$post = get_queried_object();
|
|
return isset($post->ID) ? intval($post->ID) : 0;
|
|
}
|
|
|
|
private function get_or_create_counter($ip_bin, $period_start) {
|
|
global $wpdb;
|
|
$row = $wpdb->get_row(
|
|
$wpdb->prepare(
|
|
"SELECT * FROM {$this->table_counters} WHERE ip = %s AND period_start = %s LIMIT 1",
|
|
$ip_bin, $period_start
|
|
),
|
|
ARRAY_A
|
|
);
|
|
$this->db_error_snapshot('query.select_counter');
|
|
|
|
if ($row) return $row;
|
|
|
|
$ins = $wpdb->insert($this->table_counters, [
|
|
'ip' => $ip_bin,
|
|
'period_start' => $period_start,
|
|
'page_count' => 0,
|
|
'last_seen' => gmdate('Y-m-d H:i:s')
|
|
], ['%s', '%s', '%d', '%s']);
|
|
$this->db_error_snapshot('query.insert_counter', ['insert_result' => $ins]);
|
|
|
|
if (!$ins) return null;
|
|
|
|
$id = $wpdb->insert_id;
|
|
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$this->table_counters} WHERE id = %d", $id), ARRAY_A);
|
|
$this->db_error_snapshot('query.select_counter_after_insert');
|
|
return $row;
|
|
}
|
|
|
|
private function has_seen_post($counter_id, $post_id) {
|
|
global $wpdb;
|
|
$exists = $wpdb->get_var(
|
|
$wpdb->prepare(
|
|
"SELECT 1 FROM {$this->table_pages} WHERE counter_id = %d AND post_id = %d LIMIT 1",
|
|
$counter_id, $post_id
|
|
)
|
|
);
|
|
$this->db_error_snapshot('query.has_seen_post', ['counter_id' => $counter_id, 'post_id' => $post_id]);
|
|
return !empty($exists);
|
|
}
|
|
|
|
private function add_seen_post($counter_id, $post_id) {
|
|
global $wpdb;
|
|
$ins = $wpdb->insert($this->table_pages, [
|
|
'counter_id' => $counter_id,
|
|
'post_id' => $post_id,
|
|
'first_seen' => gmdate('Y-m-d H:i:s')
|
|
], ['%d', '%d', '%s']);
|
|
$this->db_error_snapshot('query.insert_seen_post', ['insert_result' => $ins, 'counter_id' => $counter_id, 'post_id' => $post_id]);
|
|
return $ins;
|
|
}
|
|
|
|
private function increment_counter($id) {
|
|
global $wpdb;
|
|
$upd = $wpdb->query(
|
|
$wpdb->prepare(
|
|
"UPDATE {$this->table_counters} SET page_count = page_count + 1, last_seen = %s WHERE id = %d",
|
|
gmdate('Y-m-d H:i:s'), $id
|
|
)
|
|
);
|
|
$this->db_error_snapshot('query.increment_counter', ['update_result' => $upd, 'id' => $id]);
|
|
return $upd;
|
|
}
|
|
|
|
private function redirect_limit_reached($opts, $from_url) {
|
|
$redir = $opts['redirect_url'];
|
|
if (empty($redir)) $redir = wp_login_url();
|
|
$target = add_query_arg(['ref' => rawurlencode($from_url)], $redir);
|
|
$this->log('redirect.limit_reached', ['from' => $from_url, 'to' => $target]);
|
|
wp_safe_redirect($target, 302);
|
|
exit;
|
|
}
|
|
|
|
/* ===================== ENFORCER / BAR ===================== */
|
|
|
|
public function enforce_limit() {
|
|
if (is_admin()) return;
|
|
|
|
$opts = $this->get_options();
|
|
$this->ensure_tables_exist();
|
|
|
|
// 1) Ignorar bots
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
if ($this->ua_is_bot($opts, $ua)) {
|
|
$this->log('enforce.skip_bot', ['ua' => $ua]);
|
|
return;
|
|
}
|
|
|
|
// 2) Excluir por URL
|
|
$current_url = $this->current_url();
|
|
$excluded_reason = '';
|
|
if ($this->is_excluded_url($current_url, $opts, $excluded_reason)) {
|
|
$this->log('enforce.skip_excluded', ['path' => $this->request_path(), 'reason' => $excluded_reason]);
|
|
return;
|
|
}
|
|
|
|
// 3) Reglas de aplicación
|
|
$why_not = '';
|
|
if (!$this->should_apply($opts, $why_not)) {
|
|
$this->log('enforce.skip', ['reason' => $why_not]);
|
|
return;
|
|
}
|
|
|
|
// 4) Post y exclusión por CATEGORÍAS (nombre o slug; incluye ancestros)
|
|
$post_id = $this->get_post_id();
|
|
if ($post_id <= 0) {
|
|
$this->log('enforce.skip_no_post', []);
|
|
return;
|
|
}
|
|
|
|
$cat_reason = '';
|
|
if ($this->is_excluded_by_category($post_id, $opts, $cat_reason)) {
|
|
$this->log('enforce.skip_excluded_category', ['post_id' => $post_id, 'reason' => $cat_reason]);
|
|
return;
|
|
}
|
|
|
|
// 5) IP y periodo
|
|
$ip = $this->get_client_ip_info();
|
|
$period_start = $this->current_period_start((int)$opts['reset_days']);
|
|
|
|
$this->log('enforce.start', [
|
|
'url' => $current_url,
|
|
'path' => $this->request_path(),
|
|
'opts_core' => [
|
|
'enabled' => $opts['enabled'],
|
|
'apply_to_logged' => $opts['apply_to_logged'],
|
|
'limit' => $opts['limit'],
|
|
'reset_days' => $opts['reset_days'],
|
|
'redirect_url' => $opts['redirect_url'],
|
|
'show_bar' => $opts['show_bar'],
|
|
'trust_proxy_headers' => $opts['trust_proxy_headers']
|
|
],
|
|
'ip_info' => ['client' => $ip['readable'], 'server' => $ip['server']]
|
|
]);
|
|
|
|
$counter = $this->get_or_create_counter($ip['bin'], $period_start);
|
|
if (!$counter || !isset($counter['id'])) {
|
|
$this->log('enforce.fail_open_no_counter', [
|
|
'ip' => $ip['readable'],
|
|
'period_start' => $period_start,
|
|
'wpdb_error' => $this->wpdb_last_error()
|
|
]);
|
|
return; // fail-open
|
|
}
|
|
|
|
$limit = max(0, (int)$opts['limit']);
|
|
if ($limit === 0) {
|
|
$this->log('enforce.fail_open_zero_limit', []);
|
|
return; // fail-open
|
|
}
|
|
|
|
$count = (int)$counter['page_count'];
|
|
$already_seen = $this->has_seen_post((int)$counter['id'], $post_id);
|
|
|
|
$this->log('enforce.counter_status', [
|
|
'ip' => $ip['readable'],
|
|
'post_id' => $post_id,
|
|
'period_start' => $period_start,
|
|
'count' => $count,
|
|
'limit' => $limit,
|
|
'already_seen' => $already_seen
|
|
]);
|
|
|
|
if (!$already_seen) {
|
|
if ($count < $limit) {
|
|
$ok1 = $this->add_seen_post((int)$counter['id'], $post_id);
|
|
$ok2 = $this->increment_counter((int)$counter['id']);
|
|
$this->log('enforce.increment', ['add_seen_ok' => (bool)$ok1, 'incr_ok' => (bool)$ok2]);
|
|
return;
|
|
} else {
|
|
$this->log('enforce.limit_reached', ['count' => $count, 'limit' => $limit]);
|
|
$this->redirect_limit_reached($opts, $current_url);
|
|
}
|
|
} else {
|
|
$this->log('enforce.already_seen', ['post_id' => $post_id, 'no_increment' => true]);
|
|
return;
|
|
}
|
|
}
|
|
|
|
public function prepare_bar() {
|
|
if (is_admin()) return;
|
|
|
|
$opts = $this->get_options();
|
|
if (empty($opts['show_bar'])) return;
|
|
|
|
// No a bots
|
|
$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
|
|
if ($this->ua_is_bot($opts, $ua)) return;
|
|
|
|
// No en URLs excluidas
|
|
$current_url = $this->current_url();
|
|
$excluded_reason = '';
|
|
if ($this->is_excluded_url($current_url, $opts, $excluded_reason)) return;
|
|
|
|
// Reglas de aplicación
|
|
$why_not = '';
|
|
if (!$this->should_apply($opts, $why_not)) return;
|
|
|
|
$post_id = $this->get_post_id();
|
|
if ($post_id <= 0) return;
|
|
|
|
// No en categorías excluidas
|
|
$cat_reason = '';
|
|
if ($this->is_excluded_by_category($post_id, $opts, $cat_reason)) return;
|
|
|
|
// Datos de conteo
|
|
$ip = $this->get_client_ip_info();
|
|
$period_start = $this->current_period_start((int)$opts['reset_days']);
|
|
$counter = $this->get_or_create_counter($ip['bin'], $period_start);
|
|
if (!$counter || !isset($counter['id'])) return;
|
|
|
|
$limit = max(0, (int)$opts['limit']);
|
|
if ($limit === 0) return;
|
|
|
|
$count = (int)$counter['page_count'];
|
|
$already_seen = $this->has_seen_post((int)$counter['id'], $post_id);
|
|
|
|
$remaining = $limit - $count;
|
|
if (!$already_seen && $count < $limit) $remaining = max(0, $remaining - 1);
|
|
if ($remaining < 0) $remaining = 0;
|
|
|
|
$bar_html = (string)$opts['bar_html'];
|
|
if ($bar_html === '') return;
|
|
|
|
$this->bar_markup = str_replace(
|
|
['%count%', '%limit%'],
|
|
[esc_html((string)$remaining), esc_html((string)$limit)],
|
|
$bar_html
|
|
);
|
|
}
|
|
|
|
public function output_bar_markup() {
|
|
if ($this->bar_printed) return;
|
|
if ($this->bar_markup === '') return;
|
|
echo $this->bar_markup;
|
|
$this->bar_printed = true;
|
|
}
|
|
|
|
public function print_custom_css() {
|
|
$opts = $this->get_options();
|
|
if (!empty($opts['show_bar']) && !empty($opts['bar_css'])) {
|
|
echo "<style id='ipvl-bar-css'>\n" . $opts['bar_css'] . "\n</style>";
|
|
}
|
|
}
|
|
|
|
/* ===================== LOGGING ===================== */
|
|
|
|
private function current_url() {
|
|
$scheme = is_ssl() ? 'https://' : 'http://';
|
|
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : parse_url(home_url(), PHP_URL_HOST);
|
|
$uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
|
return $scheme . $host . $uri;
|
|
}
|
|
|
|
private function wpdb_last_error() {
|
|
global $wpdb;
|
|
return isset($wpdb->last_error) ? $wpdb->last_error : '';
|
|
}
|
|
|
|
private function db_error_snapshot($event, $extra = []) {
|
|
global $wpdb;
|
|
$payload = array_merge([
|
|
'last_error' => $this->wpdb_last_error(),
|
|
'last_query' => isset($wpdb->last_query) ? (string)$wpdb->last_query : null,
|
|
'last_result_count' => (isset($wpdb->last_result) && is_array($wpdb->last_result)) ? count($wpdb->last_result) : null
|
|
], $extra);
|
|
$this->log($event, $payload);
|
|
return $payload['last_error'];
|
|
}
|
|
|
|
private function log($event, array $data = []) {
|
|
$opts = get_option($this->opts_key, []);
|
|
$debug_enabled = isset($opts['debug']) ? (int)$opts['debug'] === 1 : 1;
|
|
if (!$debug_enabled) return;
|
|
|
|
$entry = [
|
|
'ts' => gmdate('c'),
|
|
'event' => $event,
|
|
'req' => [
|
|
'uri' => $_SERVER['REQUEST_URI'] ?? null,
|
|
'method' => $_SERVER['REQUEST_METHOD'] ?? null,
|
|
'referer' => $_SERVER['HTTP_REFERER'] ?? null,
|
|
'ua' => $_SERVER['HTTP_USER_AGENT'] ?? null,
|
|
'remote' => $_SERVER['REMOTE_ADDR'] ?? null,
|
|
],
|
|
'data' => $data,
|
|
];
|
|
|
|
$json = json_encode($entry, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
if ($json === false) $json = '{"ts":"' . gmdate('c') . '","event":"' . esc_attr($event) . '","data":"<json_error>"}';
|
|
|
|
$this->log_write_line($json . PHP_EOL);
|
|
}
|
|
|
|
private function log_write_line($line) {
|
|
if (file_exists($this->log_file)) {
|
|
$size = @filesize($this->log_file);
|
|
if ($size !== false && $size > ($this->log_max_mb * 1024 * 1024)) {
|
|
@rename($this->log_file, $this->log_file . '.' . time() . '.bak');
|
|
}
|
|
}
|
|
$ok = @file_put_contents($this->log_file, $line, FILE_APPEND | LOCK_EX);
|
|
if ($ok === false) {
|
|
error_log('[IPVL] ' . $line);
|
|
}
|
|
}
|
|
}
|
|
|
|
IP_View_Limit_Plugin::instance();
|