Plugin Directory

Changeset 3783334


Ignore:
Timestamp:
11/04/2026 07:53:57 AM (12 hours ago)
Author:
dcarrero
Message:

Update to version 2.0.0 from GitHub

Location:
es-football-bypass-for-cloudflare
Files:
1 added
10 edited
1 copied

Legend:

Unmodified
Added
Removed
  • es-football-bypass-for-cloudflare/tags/2.0.0/README.md

    r3707647 r3783334  
    11# ES Football Bypass for Cloudflare
    22
    3 **Versión 1.9.8** | [Changelog](readme.txt)
     3**Versión 2.0.0** | [Changelog](readme.txt)
    44
    55Plugin de WordPress que automatiza el cambio entre modo Proxied y DNS Only en Cloudflare cuando hay bloqueos masivos ordenados durante los partidos de fútbol en España. Consulta el feed de [hayahora.futbol](https://hayahora.futbol/) y activa/desactiva los registros seleccionados para mantener accesible tu sitio legítimo, con un periodo de enfriamiento configurable antes de volver a activar Cloudflare.
     6
     7## Capturas
     8
     9**Operación:** estado de los bloqueos, registros DNS gestionados y acciones manuales.
     10
     11![Página de Operación](.wordpress-org/screenshot-2.png)
     12
     13**¿Hay fútbol ahora?:** respuesta rápida sobre si hay bloqueos activos.
     14
     15![Página ¿Hay fútbol ahora?](.wordpress-org/screenshot-3.png)
     16
     17**Configuración:** credenciales de Cloudflare, comportamiento del bypass y avisos.
     18
     19![Página de Configuración](.wordpress-org/screenshot-1.png)
    620
    721## Instalación rápida
  • es-football-bypass-for-cloudflare/tags/2.0.0/es-football-bypass.php

    r3707647 r3783334  
    44 * Plugin URI: https://github.com/dcarrero/cf-football-bypass
    55 * Description: Operates with Cloudflare to toggle between Proxy (ON/CDN) and DNS Only (OFF) based on IP blocks, with persistent DNS cache and AJAX actions. Separate UI: Operation and Settings.
    6  * Version: 1.9.8
     6 * Version: 2.0.0
    77 * Author: David Carrero Fernandez-Baillo
    88 * Author URI: https://carrero.es
    … …  
    1111 * Text Domain: es-football-bypass-for-cloudflare
    1212 * Domain Path: /languages
    13  * Requires at least: 5.0
     13 * Requires at least: 6.9
    1414 * Requires PHP: 7.4
    1515 * Update URI: https://wordpress.org/plugins/es-football-bypass-for-cloudflare/
    … …  
    118118                add_action( 'wp_ajax_cfbcolorvivo_feed_diagnostics', array( $this, 'ajax_feed_diagnostics' ) );
    119119                add_action( 'init', array( $this, 'maybe_process_external_cron' ) );
     120
     121                // Abilities API (WordPress 6.9+): permite a REST, MCP y agentes operar el bypass.
     122                add_action( 'wp_abilities_api_categories_init', array( $this, 'register_ability_category' ) );
     123                add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
    120124        }
    121125
    … …  
    271275                        return true;
    272276                }
    273                 $local = array( 'localhost', 'localhost.localdomain' );
    274                 if ( in_array( strtolower( $domain ), $local, true ) ) {
    275                         return true;
    276                 }
    277                 if ( preg_match( '/\.(local|test|ddev\.site|lndo\.site|wp\.lan)$/i', $domain ) ) {
    278                         return true;
    279                 }
    280                 return false;
     277                $local    = array( 'localhost', 'localhost.localdomain' );
     278                $is_local = in_array( strtolower( $domain ), $local, true )
     279                        || (bool) preg_match( '/\.(local|test|ddev\.site|lndo\.site|wp\.lan)$/i', $domain );
     280
     281                /**
     282                 * Filters whether the current domain is a local environment.
     283                 *
     284                 * Returning false unlocks the Operation page on a local or preview install,
     285                 * which is what a staging copy or a WordPress Playground demo needs.
     286                 *
     287                 * @param bool   $is_local Whether the domain looks local.
     288                 * @param string $domain   Domain being checked.
     289                 */
     290                return (bool) apply_filters( 'cfbcolorvivo_is_local_domain', $is_local, $domain );
    281291        }
    282292        /** Detect the outgoing IP addresses of this server (cached 1 hour). */
    … …  
    949959
    950960                // Registrar script handle para asociar inline scripts.
    951                 wp_register_script( 'cfbcolorvivo-admin', false, array(), '1.9.8', true );
     961                wp_register_script( 'cfbcolorvivo-admin', false, array(), '2.0.0', true );
    952962                wp_enqueue_script( 'cfbcolorvivo-admin' );
    953963
    … …  
    964974
    965975                // Estilos de la interfaz de administración.
    966                 wp_register_style( 'cfbcolorvivo-admin', false, array(), '1.9.8' );
     976                wp_register_style( 'cfbcolorvivo-admin', false, array(), '2.0.0' );
    967977                wp_enqueue_style( 'cfbcolorvivo-admin' );
    968978                wp_add_inline_style( 'cfbcolorvivo-admin', $this->get_admin_css() );
    … …  
    10041014                .cfbcolorvivo-count #cfbcolorvivo-sel-count{font-weight:700;color:#1d2327}
    10051015                .cfbcolorvivo-dirty{display:inline-block;margin-left:8px;padding:2px 8px;background:#fcf9e8;border-left:3px solid #dba617;color:#674d00}
     1016                .cfbcolorvivo-dirty[hidden]{display:none}
    10061017                .cfbcolorvivo-actions{margin:12px 0 0}
    10071018                .cfbcolorvivo-actionrow{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
    … …  
    26952706                }
    26962707
     2708                if ( ! empty( $settings['selected_records'] ) ) {
     2709                        // Los PUT no tocan la caché: la recargamos para que refleje el estado que acabamos de aplicar.
     2710                        if ( $updated > 0 ) {
     2711                                $after = $this->fetch_dns_records();
     2712                                if ( ! empty( $after ) ) {
     2713                                        $this->persist_dns_cache( $after );
     2714                                }
     2715                        }
     2716                        // $settings se leyó antes de refrescar la caché: guardarlo tal cual la sobrescribiría con datos viejos.
     2717                        $settings = $this->get_settings();
     2718                }
     2719
    26972720                $settings['last_check']          = $now_mysql;
    26982721                $settings['last_status_general'] = ( 'SÍ' === $general ) ? 'SI' : 'NO';
    … …  
    28842907                $home = home_url( '/' );
    28852908                $host = wp_parse_url( $home, PHP_URL_HOST );
    2886                 if ( $host ) {
    2887                         return $host;
    2888                 }
    2889                 // Fallback to HTTP_HOST only if home_url parsing fails.
    2890                 return isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
     2909                if ( ! $host ) {
     2910                        // Fallback to HTTP_HOST only if home_url parsing fails.
     2911                        $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
     2912                }
     2913
     2914                /**
     2915                 * Filters the domain the plugin considers to be this site.
     2916                 *
     2917                 * Useful behind a reverse proxy, or on a staging copy that must be checked
     2918                 * against the production domain.
     2919                 *
     2920                 * @param string $host Domain resolved from home_url().
     2921                 */
     2922                return (string) apply_filters( 'cfbcolorvivo_site_domain', $host );
    28912923        }
    28922924        /**
    … …  
    29492981                return null;
    29502982        }
     2983        /**
     2984         * Read lastUpdate from a raw feed body without building the whole array.
     2985         *
     2986         * The feed decodes to about 50 MB, so it should not be decoded just to compare a
     2987         * timestamp. lastUpdate is the first key of the feed; if that ever changes this
     2988         * falls back to a full decode, released before returning.
     2989         *
     2990         * @param string $body Raw JSON body.
     2991         * @return string|null lastUpdate value, or null if the body has none.
     2992         */
     2993        private function read_feed_last_update( $body ) {
     2994                if ( ! is_string( $body ) || '' === $body ) {
     2995                        return null;
     2996                }
     2997                if ( preg_match( '/\A\s*\{\s*"lastUpdate"\s*:\s*"([^"\\\\]+)"/', substr( $body, 0, 256 ), $matches ) ) {
     2998                        return $matches[1];
     2999                }
     3000                $json = json_decode( $body, true );
     3001                return ( is_array( $json ) && ! empty( $json['lastUpdate'] ) && is_string( $json['lastUpdate'] ) ) ? $json['lastUpdate'] : null;
     3002        }
     3003
    29513004        /** Fetch and cache the football block status JSON from remote or local. */
    29523005        private function fetch_status_json() {
    … …  
    29603013                        $local_body = $wp_fs->get_contents( $local_path );
    29613014                        if ( false !== $local_body ) {
    2962                                 $json_local = json_decode( $local_body, true );
    2963                                 if ( is_array( $json_local ) && ! empty( $json_local['lastUpdate'] ) && is_string( $json_local['lastUpdate'] ) ) {
    2964                                         $last_local = $json_local['lastUpdate'];
    2965                                 }
     3015                                $last_local = $this->read_feed_last_update( $local_body );
    29663016                        }
    29673017                }
    … …  
    29693019                $remote_ok   = false;
    29703020                $remote_body = null;
     3021                $remote_json = null;
    29713022                $last_remote = null;
    29723023                $remote_code = 0;
    … …  
    29903041                                        'timeout'     => 25,
    29913042                                        'redirection' => 5,
    2992                                         'user-agent'  => 'ESFB/1.9.8; ' . home_url( '/' ),
     3043                                        'user-agent'  => 'ESFB/2.0.0; ' . home_url( '/' ),
    29933044                                )
    29943045                        );
    … …  
    29993050                                if ( 200 === $remote_code ) {
    30003051                                        $remote_body = wp_remote_retrieve_body( $resp );
    3001                                         $tmp        = json_decode( $remote_body, true );
    3002                                         if ( is_array( $tmp ) ) {
     3052                                        $remote_json = json_decode( $remote_body, true );
     3053                                        if ( is_array( $remote_json ) ) {
    30033054                                                $remote_ok = true;
    3004                                                 if ( ! empty( $tmp['lastUpdate'] ) && is_string( $tmp['lastUpdate'] ) ) {
    3005                                                         $last_remote = $tmp['lastUpdate'];
     3055                                                if ( ! empty( $remote_json['lastUpdate'] ) && is_string( $remote_json['lastUpdate'] ) ) {
     3056                                                        $last_remote = $remote_json['lastUpdate'];
    30063057                                                }
    30073058                                        } else {
    … …  
    30663117                }
    30673118
    3068                 $json = json_decode( $body, true );
     3119                // Reutilizamos el remoto ya decodificado si es el que vamos a usar; si no, se libera
     3120                // antes de decodificar el local para no tener nunca dos copias en memoria (issue #8).
     3121                if ( is_array( $remote_json ) && $body === $remote_body ) {
     3122                        $json = $remote_json;
     3123                } else {
     3124                        $remote_json = null;
     3125                        $json        = json_decode( $body, true );
     3126                        // Copia local corrupta con cabecera válida (p. ej. escritura truncada): se repara con el remoto.
     3127                        if ( ! is_array( $json ) && $remote_ok ) {
     3128                                $json = json_decode( $remote_body, true );
     3129                                if ( $wp_fs && is_array( $json ) ) {
     3130                                        $wp_fs->put_contents( $local_path, $remote_body, FS_CHMOD_FILE );
     3131                                        $this->log( '[CFB] Local data.json corrupto; restaurado desde el remoto.' );
     3132                                }
     3133                        }
     3134                }
     3135                unset( $remote_json, $remote_body, $local_body, $body );
     3136
    30693137                if ( ! is_array( $json ) ) {
    30703138                        $this->log( '[CFB] JSON decode failed' );
    … …  
    30943162                }
    30953163
    3096                 $extracted     = $this->extract_ip_block_map( $json );
     3164                $extracted = $this->extract_ip_block_map( $json );
     3165                unset( $json );
     3166
    30973167                $map           = $extracted['ip_map'] ?? array();
    30983168                $isps_blocked  = $extracted['isps_blocked'] ?? array();
    … …  
    33753445                        $contents = file_get_contents( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
    33763446                        if ( is_string( $contents ) ) {
    3377                                 $decoded = json_decode( $contents, true );
    3378                                 if ( is_array( $decoded ) && ! empty( $decoded['lastUpdate'] ) && is_string( $decoded['lastUpdate'] ) ) {
    3379                                         $local_last_update = $decoded['lastUpdate'];
    3380                                 }
     3447                                $local_last_update = (string) $this->read_feed_last_update( $contents );
    33813448                        }
    33823449                }
    … …  
    35123579                $home    = home_url( '/' );
    35133580                $mgr_url = admin_url( 'admin.php?page=cfbcolorvivo-main' );
    3514                 $version = '1.9.8';
     3581                $version = '2.0.0';
    35153582
    35163583                if ( $now_active ) {
    … …  
    35453612                }
    35463613                $this->log( '[CFB] Email notificación ' . ( $sent ? 'enviada' : 'FALLÓ' ) . ' a ' . $to . ' (estado=' . ( $now_active ? 'bypass activo' : 'bypass inactivo' ) . ')' );
     3614        }
     3615
     3616        /* ================== Abilities API (WordPress 6.9+) ================== */
     3617
     3618        /**
     3619         * Register the ability category for this plugin.
     3620         *
     3621         * Requires WordPress 6.9 or newer, which is the plugin minimum since 2.0.0.
     3622         */
     3623        public function register_ability_category() {
     3624                wp_register_ability_category(
     3625                        'es-football-bypass',
     3626                        array(
     3627                                'label'       => __( 'ES Football Bypass', 'es-football-bypass-for-cloudflare' ),
     3628                                'description' => __( 'Check La Liga IP block status and control the Cloudflare proxy on the managed DNS records.', 'es-football-bypass-for-cloudflare' ),
     3629                        )
     3630                );
     3631        }
     3632
     3633        /**
     3634         * Permission check shared by every ability.
     3635         *
     3636         * Abilities run outside the admin screens (REST, MCP, AI agents), so they enforce
     3637         * the same capability the admin pages require.
     3638         *
     3639         * @return bool True when the current user may operate the plugin.
     3640         */
     3641        public function abilities_permission_check() {
     3642                return current_user_can( 'manage_options' );
     3643        }
     3644
     3645        /**
     3646         * Register the plugin abilities so REST and MCP clients can drive the bypass.
     3647         */
     3648        public function register_abilities() {
     3649                $record_schema = array(
     3650                        'type'       => 'object',
     3651                        'properties' => array(
     3652                                'id'      => array(
     3653                                        'type'        => 'string',
     3654                                        'description' => __( 'Cloudflare DNS record ID.', 'es-football-bypass-for-cloudflare' ),
     3655                                ),
     3656                                'name'    => array( 'type' => 'string' ),
     3657                                'type'    => array( 'type' => 'string' ),
     3658                                'content' => array( 'type' => 'string' ),
     3659                                'proxied' => array(
     3660                                        'type'        => array( 'boolean', 'null' ),
     3661                                        'description' => __( 'True when the record goes through the Cloudflare CDN, null when the record cannot be proxied.', 'es-football-bypass-for-cloudflare' ),
     3662                                ),
     3663                                'managed' => array(
     3664                                        'type'        => 'boolean',
     3665                                        'description' => __( 'True when the plugin is allowed to switch this record.', 'es-football-bypass-for-cloudflare' ),
     3666                                ),
     3667                        ),
     3668                );
     3669
     3670                wp_register_ability(
     3671                        'es-football-bypass/get-status',
     3672                        array(
     3673                                'label'               => __( 'Get bypass status', 'es-football-bypass-for-cloudflare' ),
     3674                                'description'         => __( 'Returns whether La Liga IP blocks are active right now, whether this site is among the blocked IPs, and whether the Cloudflare bypass is currently engaged.', 'es-football-bypass-for-cloudflare' ),
     3675                                'category'            => 'es-football-bypass',
     3676                                'output_schema'       => array(
     3677                                        'type'       => 'object',
     3678                                        'properties' => array(
     3679                                                'blocks_active'      => array(
     3680                                                        'type'        => 'boolean',
     3681                                                        'description' => __( 'Blocks are active on some IPs right now.', 'es-football-bypass-for-cloudflare' ),
     3682                                                ),
     3683                                                'domain_blocked'     => array(
     3684                                                        'type'        => 'boolean',
     3685                                                        'description' => __( 'This site resolves to at least one blocked IP.', 'es-football-bypass-for-cloudflare' ),
     3686                                                ),
     3687                                                'bypass_active'      => array(
     3688                                                        'type'        => 'boolean',
     3689                                                        'description' => __( 'The plugin currently has the managed records on DNS Only.', 'es-football-bypass-for-cloudflare' ),
     3690                                                ),
     3691                                                'domain'             => array( 'type' => 'string' ),
     3692                                                'domain_ips'         => array(
     3693                                                        'type'  => 'array',
     3694                                                        'items' => array( 'type' => 'string' ),
     3695                                                ),
     3696                                                'blocked_domain_ips' => array(
     3697                                                        'type'  => 'array',
     3698                                                        'items' => array( 'type' => 'string' ),
     3699                                                ),
     3700                                                'managed_records'    => array( 'type' => 'integer' ),
     3701                                                'last_check'         => array( 'type' => 'string' ),
     3702                                                'feed_last_update'   => array( 'type' => 'string' ),
     3703                                        ),
     3704                                ),
     3705                                'execute_callback'    => array( $this, 'ability_get_status' ),
     3706                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3707                                'meta'                => array(
     3708                                        'public'       => true,
     3709                                        'show_in_rest' => true,
     3710                                        'annotations'  => array(
     3711                                                'readonly'    => true,
     3712                                                'destructive' => false,
     3713                                                'idempotent'  => true,
     3714                                        ),
     3715                                ),
     3716                        )
     3717                );
     3718
     3719                wp_register_ability(
     3720                        'es-football-bypass/list-dns-records',
     3721                        array(
     3722                                'label'               => __( 'List DNS records', 'es-football-bypass-for-cloudflare' ),
     3723                                'description'         => __( 'Lists the cached Cloudflare DNS records with their current proxy state and whether the plugin manages each one. Optionally filtered by record type.', 'es-football-bypass-for-cloudflare' ),
     3724                                'category'            => 'es-football-bypass',
     3725                                'input_schema'        => array(
     3726                                        'type'       => 'object',
     3727                                        'default'    => array(),
     3728                                        'properties' => array(
     3729                                                'type'         => array(
     3730                                                        'type'        => 'string',
     3731                                                        'enum'        => array( 'A', 'AAAA', 'CNAME' ),
     3732                                                        'description' => __( 'Return only records of this type.', 'es-football-bypass-for-cloudflare' ),
     3733                                                ),
     3734                                                'managed_only' => array(
     3735                                                        'type'        => 'boolean',
     3736                                                        'description' => __( 'Return only the records the plugin manages.', 'es-football-bypass-for-cloudflare' ),
     3737                                                ),
     3738                                        ),
     3739                                ),
     3740                                'output_schema'       => array(
     3741                                        'type'       => 'object',
     3742                                        'properties' => array(
     3743                                                'records' => array(
     3744                                                        'type'  => 'array',
     3745                                                        'items' => $record_schema,
     3746                                                ),
     3747                                                'total'   => array( 'type' => 'integer' ),
     3748                                        ),
     3749                                ),
     3750                                'execute_callback'    => array( $this, 'ability_list_dns_records' ),
     3751                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3752                                'meta'                => array(
     3753                                        'public'       => true,
     3754                                        'show_in_rest' => true,
     3755                                        'annotations'  => array(
     3756                                                'readonly'    => true,
     3757                                                'destructive' => false,
     3758                                                'idempotent'  => true,
     3759                                        ),
     3760                                ),
     3761                        )
     3762                );
     3763
     3764                wp_register_ability(
     3765                        'es-football-bypass/set-managed-records',
     3766                        array(
     3767                                'label'               => __( 'Set managed DNS records', 'es-football-bypass-for-cloudflare' ),
     3768                                'description'         => __( 'Replaces the list of DNS records the plugin is allowed to switch. Record IDs that do not exist in the Cloudflare zone are ignored. Pass an empty array to stop the plugin from touching DNS at all.', 'es-football-bypass-for-cloudflare' ),
     3769                                'category'            => 'es-football-bypass',
     3770                                'input_schema'        => array(
     3771                                        'type'       => 'object',
     3772                                        'properties' => array(
     3773                                                'record_ids' => array(
     3774                                                        'type'        => 'array',
     3775                                                        'items'       => array( 'type' => 'string' ),
     3776                                                        'description' => __( 'Cloudflare DNS record IDs to manage.', 'es-football-bypass-for-cloudflare' ),
     3777                                                ),
     3778                                        ),
     3779                                        'required'   => array( 'record_ids' ),
     3780                                ),
     3781                                'output_schema'       => array(
     3782                                        'type'       => 'object',
     3783                                        'properties' => array(
     3784                                                'managed_records' => array( 'type' => 'integer' ),
     3785                                                'ignored_ids'     => array(
     3786                                                        'type'  => 'array',
     3787                                                        'items' => array( 'type' => 'string' ),
     3788                                                ),
     3789                                        ),
     3790                                ),
     3791                                'execute_callback'    => array( $this, 'ability_set_managed_records' ),
     3792                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3793                                'meta'                => array(
     3794                                        'public'       => true,
     3795                                        'show_in_rest' => true,
     3796                                        'annotations'  => array(
     3797                                                'readonly'    => false,
     3798                                                'destructive' => false,
     3799                                                'idempotent'  => true,
     3800                                        ),
     3801                                ),
     3802                        )
     3803                );
     3804
     3805                wp_register_ability(
     3806                        'es-football-bypass/run-check',
     3807                        array(
     3808                                'label'               => __( 'Run block check now', 'es-football-bypass-for-cloudflare' ),
     3809                                'description'         => __( 'Runs the same check the cron job runs: fetches the hayahora.futbol feed and applies the bypass policy, which may switch the managed records between Proxied and DNS Only.', 'es-football-bypass-for-cloudflare' ),
     3810                                'category'            => 'es-football-bypass',
     3811                                'output_schema'       => array(
     3812                                        'type'       => 'object',
     3813                                        'properties' => array(
     3814                                                'blocks_active'  => array( 'type' => 'boolean' ),
     3815                                                'domain_blocked' => array( 'type' => 'boolean' ),
     3816                                                'bypass_active'  => array( 'type' => 'boolean' ),
     3817                                                'last_check'     => array( 'type' => 'string' ),
     3818                                        ),
     3819                                ),
     3820                                'execute_callback'    => array( $this, 'ability_run_check' ),
     3821                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3822                                'meta'                => array(
     3823                                        'public'       => true,
     3824                                        'show_in_rest' => true,
     3825                                        'annotations'  => array(
     3826                                                'readonly'    => false,
     3827                                                'destructive' => false,
     3828                                                'idempotent'  => false,
     3829                                        ),
     3830                                ),
     3831                        )
     3832                );
     3833
     3834                wp_register_ability(
     3835                        'es-football-bypass/set-proxy',
     3836                        array(
     3837                                'label'               => __( 'Force proxy on or off', 'es-football-bypass-for-cloudflare' ),
     3838                                'description'         => __( 'Forces the managed DNS records to Proxied (CDN) or DNS Only, exactly like the manual buttons on the Operation page. Changes live DNS at Cloudflare.', 'es-football-bypass-for-cloudflare' ),
     3839                                'category'            => 'es-football-bypass',
     3840                                'input_schema'        => array(
     3841                                        'type'       => 'object',
     3842                                        'properties' => array(
     3843                                                'mode' => array(
     3844                                                        'type'        => 'string',
     3845                                                        'enum'        => array( 'on', 'off' ),
     3846                                                        'description' => __( '"on" restores Proxied (CDN); "off" switches to DNS Only.', 'es-football-bypass-for-cloudflare' ),
     3847                                                ),
     3848                                        ),
     3849                                        'required'   => array( 'mode' ),
     3850                                ),
     3851                                'output_schema'       => array(
     3852                                        'type'       => 'object',
     3853                                        'properties' => array(
     3854                                                'mode'      => array( 'type' => 'string' ),
     3855                                                'changed'   => array( 'type' => 'integer' ),
     3856                                                'unchanged' => array( 'type' => 'integer' ),
     3857                                                'failed'    => array( 'type' => 'integer' ),
     3858                                                'report'    => array(
     3859                                                        'type'  => 'array',
     3860                                                        'items' => array( 'type' => 'string' ),
     3861                                                ),
     3862                                        ),
     3863                                ),
     3864                                'execute_callback'    => array( $this, 'ability_set_proxy' ),
     3865                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3866                                'meta'                => array(
     3867                                        'public'       => true,
     3868                                        'show_in_rest' => true,
     3869                                        'annotations'  => array(
     3870                                                'readonly'    => false,
     3871                                                'destructive' => true,
     3872                                                'idempotent'  => true,
     3873                                        ),
     3874                                ),
     3875                        )
     3876                );
     3877        }
     3878
     3879        /**
     3880         * Ability callback: current block and bypass status.
     3881         *
     3882         * @return array Status payload matching the registered output schema.
     3883         */
     3884        public function ability_get_status() {
     3885                $s    = $this->get_settings();
     3886                $calc = $this->compute_statuses_from_json( false );
     3887
     3888                return array(
     3889                        'blocks_active'      => ( 'SÍ' === $calc['general'] ),
     3890                        'domain_blocked'     => ( 'SÍ' === $calc['domain'] ),
     3891                        'bypass_active'      => ! empty( $s['bypass_active'] ),
     3892                        'domain'             => $this->get_site_domain(),
     3893                        'domain_ips'         => array_values( (array) $calc['domain_ips'] ),
     3894                        'blocked_domain_ips' => array_values( (array) $calc['blocked_domain_ips'] ),
     3895                        'managed_records'    => count( (array) ( $s['selected_records'] ?? array() ) ),
     3896                        'last_check'         => (string) ( $s['last_check'] ?? '' ),
     3897                        'feed_last_update'   => (string) ( $calc['last_update'] ?? '' ),
     3898                );
     3899        }
     3900
     3901        /**
     3902         * Ability callback: list the cached DNS records.
     3903         *
     3904         * @param array $input Optional 'type' and 'managed_only' filters.
     3905         * @return array Records payload matching the registered output schema.
     3906         */
     3907        public function ability_list_dns_records( $input = array() ) {
     3908                $s        = $this->get_settings();
     3909                $selected = (array) ( $s['selected_records'] ?? array() );
     3910                $filter   = isset( $input['type'] ) ? (string) $input['type'] : '';
     3911                $only     = ! empty( $input['managed_only'] );
     3912
     3913                $out = array();
     3914                foreach ( (array) ( $s['dns_records_cache'] ?? array() ) as $r ) {
     3915                        $id      = $r['id'] ?? '';
     3916                        $managed = in_array( $id, $selected, true );
     3917                        if ( $filter && ( $r['type'] ?? '' ) !== $filter ) {
     3918                                continue;
     3919                        }
     3920                        if ( $only && ! $managed ) {
     3921                                continue;
     3922                        }
     3923                        $out[] = array(
     3924                                'id'      => (string) $id,
     3925                                'name'    => (string) ( $r['name'] ?? '' ),
     3926                                'type'    => (string) ( $r['type'] ?? '' ),
     3927                                'content' => (string) ( $r['content'] ?? '' ),
     3928                                'proxied' => array_key_exists( 'proxied', $r ) ? $r['proxied'] : null,
     3929                                'managed' => $managed,
     3930                        );
     3931                }
     3932
     3933                return array(
     3934                        'records' => $out,
     3935                        'total'   => count( $out ),
     3936                );
     3937        }
     3938
     3939        /**
     3940         * Ability callback: replace the managed record selection.
     3941         *
     3942         * @param array $input Payload with the 'record_ids' array.
     3943         * @return array Result payload matching the registered output schema.
     3944         */
     3945        public function ability_set_managed_records( $input = array() ) {
     3946                $s      = $this->get_settings();
     3947                $wanted = array_values( array_unique( array_filter( array_map( 'sanitize_text_field', (array) ( $input['record_ids'] ?? array() ) ) ) ) );
     3948                $known  = array();
     3949                foreach ( (array) ( $s['dns_records_cache'] ?? array() ) as $r ) {
     3950                        if ( ! empty( $r['id'] ) ) {
     3951                                $known[] = $r['id'];
     3952                        }
     3953                }
     3954                $valid   = empty( $known ) ? $wanted : array_values( array_intersect( $wanted, $known ) );
     3955                $ignored = array_values( array_diff( $wanted, $valid ) );
     3956
     3957                $s['selected_records'] = $valid;
     3958                $this->save_settings( $s );
     3959                $this->log_event(
     3960                        'manual',
     3961                        'Selección de registros DNS guardada (Abilities API)',
     3962                        array(
     3963                                'usuario'       => $this->current_user_label(),
     3964                                'seleccionados' => count( $valid ),
     3965                                'ignorados'     => count( $ignored ),
     3966                        )
     3967                );
     3968
     3969                return array(
     3970                        'managed_records' => count( $valid ),
     3971                        'ignored_ids'     => $ignored,
     3972                );
     3973        }
     3974
     3975        /**
     3976         * Ability callback: run the block check and apply the policy.
     3977         *
     3978         * @return array Result payload matching the registered output schema.
     3979         */
     3980        public function ability_run_check() {
     3981                $this->check_football_and_manage_cloudflare();
     3982                $s = $this->get_settings();
     3983                $this->log_event(
     3984                        'manual',
     3985                        'Comprobación ejecutada (Abilities API)',
     3986                        array(
     3987                                'usuario' => $this->current_user_label(),
     3988                                'general' => $s['last_status_general'] ?? 'NO',
     3989                                'dominio' => $s['last_status_domain'] ?? 'NO',
     3990                        )
     3991                );
     3992
     3993                return array(
     3994                        'blocks_active'  => ( 'SI' === ( $s['last_status_general'] ?? 'NO' ) ),
     3995                        'domain_blocked' => ( 'SI' === ( $s['last_status_domain'] ?? 'NO' ) ),
     3996                        'bypass_active'  => ! empty( $s['bypass_active'] ),
     3997                        'last_check'     => (string) ( $s['last_check'] ?? '' ),
     3998                );
     3999        }
     4000
     4001        /**
     4002         * Ability callback: force the managed records to Proxied or DNS Only.
     4003         *
     4004         * @param array $input Payload with the 'mode' value ("on" or "off").
     4005         * @return array|WP_Error Result payload, or WP_Error when nothing can be done.
     4006         */
     4007        public function ability_set_proxy( $input = array() ) {
     4008                $mode    = isset( $input['mode'] ) ? (string) $input['mode'] : '';
     4009                $proxied = ( 'on' === $mode );
     4010
     4011                $s   = $this->get_settings();
     4012                $sel = (array) ( $s['selected_records'] ?? array() );
     4013                if ( empty( $sel ) ) {
     4014                        return new WP_Error(
     4015                                'cfbcolorvivo_no_records',
     4016                                __( 'No DNS records are managed by the plugin. Set them first with the set-managed-records ability.', 'es-football-bypass-for-cloudflare' )
     4017                        );
     4018                }
     4019                if ( empty( $s['cloudflare_zone_id'] ) || empty( $s['cloudflare_api_key'] ) ) {
     4020                        return new WP_Error(
     4021                                'cfbcolorvivo_not_configured',
     4022                                __( 'Cloudflare credentials are not configured.', 'es-football-bypass-for-cloudflare' )
     4023                        );
     4024                }
     4025
     4026                $log    = array();
     4027                $report = $this->apply_proxy_to_records( $sel, $proxied, $log );
     4028
     4029                $this->log_event(
     4030                        'manual',
     4031                        'Forzar Proxy ' . ( $proxied ? 'ON' : 'OFF' ) . ' (Abilities API)',
     4032                        array(
     4033                                'usuario'    => $this->current_user_label(),
     4034                                'procesados' => count( $sel ),
     4035                                'ok'         => $report['ok'],
     4036                                'errores'    => $report['failed'],
     4037                        )
     4038                );
     4039
     4040                return array(
     4041                        'mode'      => $proxied ? 'on' : 'off',
     4042                        'changed'   => $report['ok'],
     4043                        'unchanged' => $report['skipped'],
     4044                        'failed'    => $report['failed'],
     4045                        'report'    => $report['lines'],
     4046                );
    35474047        }
    35484048
    … …  
    39264426        }
    39274427
     4428        /**
     4429         * Apply a proxy state to a set of DNS records, refreshing the cache before and after.
     4430         *
     4431         * Shared by the "Force Proxy ON/OFF" AJAX actions and the Abilities API, so both
     4432         * follow exactly the same path against Cloudflare.
     4433         *
     4434         * @param array $ids     Cloudflare DNS record IDs to update.
     4435         * @param bool  $proxied True for Proxied (CDN), false for DNS Only.
     4436         * @param array $log     Trace log, appended by reference.
     4437         * @return array {
     4438         *     @type int   $ok      Records actually changed.
     4439         *     @type int   $skipped Records already in the requested state.
     4440         *     @type int   $failed  Records that could not be updated.
     4441         *     @type array $lines   Human-readable report, one line per record.
     4442         * }
     4443         */
     4444        private function apply_proxy_to_records( array $ids, $proxied, array &$log ) {
     4445                $state = $proxied ? 'ON' : 'OFF';
     4446
     4447                $before = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
     4448                if ( ! empty( $before ) ) {
     4449                        $this->persist_dns_cache( $before );
     4450                }
     4451
     4452                $ok      = 0;
     4453                $failed  = 0;
     4454                $skipped = 0;
     4455                $lines   = array();
     4456                foreach ( $ids as $rid ) {
     4457                        $res = $this->update_record_proxy_status( $rid, $proxied, true );
     4458                        if ( is_array( $res ) && ! empty( $res['success'] ) ) {
     4459                                if ( ! empty( $res['skipped'] ) ) {
     4460                                        ++$skipped;
     4461                                        $lines[] = 'SKIP: ' . ( $res['record']['name'] ?? $rid ) . ' (ya ' . $state . ')';
     4462                                } else {
     4463                                        ++$ok;
     4464                                        $lines[] = 'OK: ' . ( $res['record']['name'] ?? $rid ) . ' -> ' . $state;
     4465                                }
     4466                        } else {
     4467                                ++$failed;
     4468                                $lines[] = 'ERR: ' . ( $res['record']['name'] ?? $rid ) . ' ' . ( is_array( $res ) && ! empty( $res['error'] ) ? $res['error'] : '' );
     4469                        }
     4470                }
     4471
     4472                $after = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
     4473                if ( ! empty( $after ) ) {
     4474                        $this->persist_dns_cache( $after );
     4475                }
     4476
     4477                return array(
     4478                        'ok'      => $ok,
     4479                        'skipped' => $skipped,
     4480                        'failed'  => $failed,
     4481                        'lines'   => $lines,
     4482                );
     4483        }
     4484
    39284485        /** AJAX handler: force proxy OFF (DNS Only) on selected records. */
    39294486        public function ajax_force_deactivate() {
    … …  
    39504507                }
    39514508
    3952                 $before = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    3953                 if ( ! empty( $before ) ) {
    3954                         $this->persist_dns_cache( $before );
    3955                 }
    3956                 $ok    = 0;
    3957                 $fail  = 0;
    3958                 $lines = array();
    3959                 foreach ( $sel as $rid ) {
    3960                         $res = $this->update_record_proxy_status( $rid, false, true );
    3961                         if ( is_array( $res ) && ! empty( $res['success'] ) ) {
    3962                                 if ( ! empty( $res['skipped'] ) ) {
    3963                                         $lines[] = 'SKIP: ' . ( $res['record']['name'] ?? $rid ) . ' (ya OFF)';
    3964                                 } else {
    3965                                         ++$ok;
    3966                                         $lines[] = 'OK: ' . ( $res['record']['name'] ?? $rid ) . ' -> OFF'; }
    3967                         } else {
    3968                                 ++$fail;
    3969                                 $lines[] = 'ERR: ' . ( ( $res['record']['name'] ?? $rid ) ) . ' ' . ( is_array( $res ) && ! empty( $res['error'] ) ? $res['error'] : '' );
    3970                         }
    3971                 }
    3972                 $after = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    3973                 if ( ! empty( $after ) ) {
    3974                         $this->persist_dns_cache( $after );
    3975                 }
     4509                $report = $this->apply_proxy_to_records( $sel, false, $log );
     4510                $ok     = $report['ok'];
     4511                $fail   = $report['failed'];
     4512                $lines  = $report['lines'];
    39764513
    39774514                $s2 = $this->get_settings();
    … …  
    40244561                }
    40254562
    4026                 $before = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    4027                 if ( ! empty( $before ) ) {
    4028                         $this->persist_dns_cache( $before );
    4029                 }
    4030                 $ok    = 0;
    4031                 $fail  = 0;
    4032                 $lines = array();
    4033                 foreach ( $sel as $rid ) {
    4034                         $res = $this->update_record_proxy_status( $rid, true, true );
    4035                         if ( is_array( $res ) && ! empty( $res['success'] ) ) {
    4036                                 if ( ! empty( $res['skipped'] ) ) {
    4037                                         $lines[] = 'SKIP: ' . ( $res['record']['name'] ?? $rid ) . ' (ya ON)';
    4038                                 } else {
    4039                                         ++$ok;
    4040                                         $lines[] = 'OK: ' . ( $res['record']['name'] ?? $rid ) . ' -> ON'; }
    4041                         } else {
    4042                                 ++$fail;
    4043                                 $lines[] = 'ERR: ' . ( ( $res['record']['name'] ?? $rid ) ) . ' ' . ( is_array( $res ) && ! empty( $res['error'] ) ? $res['error'] : '' );
    4044                         }
    4045                 }
    4046                 $after = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    4047                 if ( ! empty( $after ) ) {
    4048                         $this->persist_dns_cache( $after );
    4049                 }
     4563                $report = $this->apply_proxy_to_records( $sel, true, $log );
     4564                $ok     = $report['ok'];
     4565                $fail   = $report['failed'];
     4566                $lines  = $report['lines'];
    40504567
    40514568                $s2 = $this->get_settings();
  • es-football-bypass-for-cloudflare/tags/2.0.0/readme.txt

    r3707647 r3783334  
    22Contributors: dcarrero
    33Tags: cloudflare, dns, football, bypass, ip-blocking
    4 Requires at least: 5.0
     4Requires at least: 6.9
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.9.8
     7Stable tag: 2.0.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    178178In the Operation tab, click "WP-Cron Diagnostics" to see the next execution and the result of the last check. You can also review the integrated logs.
    179179
     180== Abilities API ==
     181
     182Since 2.0.0 the plugin registers five abilities through the WordPress Abilities API (WordPress 6.9 and newer), under the `es-football-bypass` category. This lets the REST API, MCP servers and AI agents read the block status and operate the bypass without going through the admin screens.
     183
     184Every ability requires the `manage_options` capability, declares JSON Schemas for its input and output, and carries readonly/destructive/idempotent annotations so clients know what is safe to call.
     185
     186= HTTP methods over REST =
     187WordPress derives the HTTP method from each ability's annotations, so REST clients must use the right verb (MCP clients call abilities by name and are unaffected):
     188
     189* `get-status` and `list-dns-records` are `readonly`, so they are served over **GET**, with input as query parameters (`?input[type]=A`).
     190* `set-managed-records` and `run-check` are served over **POST**, with input in the JSON body (`{"input": {...}}`).
     191* `set-proxy` is annotated `destructive`, so it is served over **DELETE**. Pass its input as query parameters (`?input[mode]=off`): a JSON body is not read on DELETE requests.
     192
     193= es-football-bypass/get-status =
     194Read-only. Returns whether La Liga IP blocks are active right now, whether this site resolves to a blocked IP, whether the bypass is engaged, the resolved IPs, how many records are managed, and the last check and feed timestamps.
     195
     196= es-football-bypass/list-dns-records =
     197Read-only. Returns the cached DNS records with their proxy state and whether the plugin manages each one. Optional input: `type` (`A`, `AAAA` or `CNAME`) and `managed_only`.
     198
     199= es-football-bypass/set-managed-records =
     200Replaces the list of records the plugin is allowed to switch. Input: `record_ids` (array of Cloudflare record IDs). IDs that do not exist in the zone are returned in `ignored_ids`. Pass an empty array to stop the plugin from touching DNS at all.
     201
     202= es-football-bypass/run-check =
     203Runs the same check as the cron job: fetches the hayahora.futbol feed and applies the bypass policy, which may switch the managed records.
     204
     205= es-football-bypass/set-proxy =
     206Forces the managed records to Proxied (CDN) or DNS Only. Input: `mode` (`on` or `off`). Annotated as destructive, since it changes live DNS at Cloudflare. Returns an error when no records are managed or Cloudflare credentials are missing.
     207
    180208== Logs and Auditing ==
    181209
    … …  
    191219== Screenshots ==
    192220
    193 1. Settings page: select Cloudflare authentication type (Global API Key, User Token or Account Token), enter credentials, configure check interval, cooldown after disabling Cloudflare, the "Force Proxy OFF during football" override, log retention, and the external cron token.
    194 2. Operation page: live block status from hayahora.futbol, cached DNS records with current Proxied state per record, manual controls (save selection, reload from Cloudflare, force Proxy ON/OFF, manual check, WP-Cron diagnostics), and a sidebar with related links.
     2211. Settings page: Simple or Advanced interface mode, Cloudflare authentication type and credentials, server outbound IP, bypass behaviour (check interval, cooldown, "Force Proxy OFF during football" override, minimum ISPs with blocks), email notifications and uninstall options.
     2222. Operation page: live block status from hayahora.futbol and the DNS records the plugin manages, with type filters, quick selection buttons and the current proxy state per record, plus manual actions (check for blocks, force Proxy OFF or ON).
     2233. "Is there football now?" page: a quick yes or no answer on whether La Liga IP blocks are active, with the time of the last feed update and the affected ISPs.
    195224
    196225== Changelog ==
     226
     227= 2.0.0 =
     228* NEW: Abilities API support (WordPress 6.9+). The plugin now registers five abilities under the "ES Football Bypass" category, so the REST API, MCP servers and AI agents can drive the bypass without going through the admin screens: `get-status`, `list-dns-records`, `set-managed-records`, `run-check` and `set-proxy`
     229* NEW: `es-football-bypass/get-status` — whether La Liga IP blocks are active, whether this site resolves to a blocked IP, and whether the bypass is currently engaged
     230* NEW: `es-football-bypass/list-dns-records` — the cached DNS records with their proxy state and whether the plugin manages each one, filterable by record type or to managed records only
     231* NEW: `es-football-bypass/set-managed-records` — replaces the list of records the plugin may switch; IDs missing from the Cloudflare zone are reported back as ignored
     232* NEW: `es-football-bypass/run-check` — runs the same check as the cron job and applies the bypass policy
     233* NEW: `es-football-bypass/set-proxy` — forces the managed records to Proxied (CDN) or DNS Only. Annotated as destructive so clients ask for confirmation, and it returns a clear error when no records are managed or Cloudflare is not configured
     234* All abilities require the `manage_options` capability, declare JSON Schemas for input and output, and carry readonly/destructive/idempotent annotations so agents know what is safe to call
     235* NEW: Filters `cfbcolorvivo_site_domain` and `cfbcolorvivo_is_local_domain`, so a staging copy, a reverse-proxied install or a WordPress Playground preview can unlock the Operation page and be checked against the production domain
     236* FIX: The "Unsaved changes" warning was visible on page load before touching anything — the stylesheet was overriding the `hidden` attribute
     237* FIX: PHP memory exhaustion while processing the status feed. The feed (about 5 MB, 50 MB once decoded) was decoded up to three times in a row and every copy was kept in memory until the end; a fourth decode on the Operation page only read a timestamp. It is now decoded once, so peak memory falls to roughly a third. Thanks to joserikko for the report
     238* FIX: After an automatic check the cached DNS records kept the proxy state from before the change, so the Operation page and `list-dns-records` could report the opposite of what was live at Cloudflare until the next refresh
     239* FIX: Uninstalling now removes the logs folder completely; a hidden `.htaccess` file was being left behind
     240* COMPAT: Minimum WordPress version raised to 6.9, which is where the Abilities API landed. Sites on older versions stay on 1.9.8, which contains the DNS record selection fix
     241* INTERNAL: The "Force Proxy ON/OFF" logic is now shared between the admin AJAX actions and the abilities, so both follow exactly the same path against Cloudflare
    197242
    198243= 1.9.8 =
    … …  
    338383== Upgrade Notice ==
    339384
     385= 2.0.0 =
     386Adds Abilities API support (WordPress 6.9+): five abilities let the REST API, MCP servers and AI agents read the block status and control the Cloudflare proxy. Minimum WordPress version is now 6.9.
     387
    340388= 1.9.8 =
    341389Important fix: unchecking DNS records did nothing, so manual actions were applied to every record in the zone. Adds a "Save selection" button, record type filters (Only A / A + AAAA) and clearer feedback. Review your selected records after updating.
    … …  
    388436== Server Requirements ==
    389437
    390 - WordPress 5.0 or higher
     438- WordPress 6.9 or higher
    391439- PHP 7.4 or higher
    392440- PHP Extensions: curl, json
  • es-football-bypass-for-cloudflare/tags/2.0.0/uninstall.php

    r3602202 r3783334  
    6363                        }
    6464                }
     65                // glob() no lista los ficheros ocultos: sin esto el .htaccess impide borrar el directorio.
     66                wp_delete_file( $cfbcolorvivo_dir . '/.htaccess' );
    6567                $wp_filesystem->rmdir( $cfbcolorvivo_dir );
    6668        }
    … …  
    8284                        }
    8385                }
     86                // glob() no lista los ficheros ocultos: sin esto el .htaccess impide borrar el directorio.
     87                wp_delete_file( $cfbcolorvivo_dir . '/.htaccess' );
    8488                $wp_filesystem->rmdir( $cfbcolorvivo_dir );
    8589        }
  • es-football-bypass-for-cloudflare/trunk/README.md

    r3707647 r3783334  
    11# ES Football Bypass for Cloudflare
    22
    3 **Versión 1.9.8** | [Changelog](readme.txt)
     3**Versión 2.0.0** | [Changelog](readme.txt)
    44
    55Plugin de WordPress que automatiza el cambio entre modo Proxied y DNS Only en Cloudflare cuando hay bloqueos masivos ordenados durante los partidos de fútbol en España. Consulta el feed de [hayahora.futbol](https://hayahora.futbol/) y activa/desactiva los registros seleccionados para mantener accesible tu sitio legítimo, con un periodo de enfriamiento configurable antes de volver a activar Cloudflare.
     6
     7## Capturas
     8
     9**Operación:** estado de los bloqueos, registros DNS gestionados y acciones manuales.
     10
     11![Página de Operación](.wordpress-org/screenshot-2.png)
     12
     13**¿Hay fútbol ahora?:** respuesta rápida sobre si hay bloqueos activos.
     14
     15![Página ¿Hay fútbol ahora?](.wordpress-org/screenshot-3.png)
     16
     17**Configuración:** credenciales de Cloudflare, comportamiento del bypass y avisos.
     18
     19![Página de Configuración](.wordpress-org/screenshot-1.png)
    620
    721## Instalación rápida
  • es-football-bypass-for-cloudflare/trunk/es-football-bypass.php

    r3707647 r3783334  
    44 * Plugin URI: https://github.com/dcarrero/cf-football-bypass
    55 * Description: Operates with Cloudflare to toggle between Proxy (ON/CDN) and DNS Only (OFF) based on IP blocks, with persistent DNS cache and AJAX actions. Separate UI: Operation and Settings.
    6  * Version: 1.9.8
     6 * Version: 2.0.0
    77 * Author: David Carrero Fernandez-Baillo
    88 * Author URI: https://carrero.es
    … …  
    1111 * Text Domain: es-football-bypass-for-cloudflare
    1212 * Domain Path: /languages
    13  * Requires at least: 5.0
     13 * Requires at least: 6.9
    1414 * Requires PHP: 7.4
    1515 * Update URI: https://wordpress.org/plugins/es-football-bypass-for-cloudflare/
    … …  
    118118                add_action( 'wp_ajax_cfbcolorvivo_feed_diagnostics', array( $this, 'ajax_feed_diagnostics' ) );
    119119                add_action( 'init', array( $this, 'maybe_process_external_cron' ) );
     120
     121                // Abilities API (WordPress 6.9+): permite a REST, MCP y agentes operar el bypass.
     122                add_action( 'wp_abilities_api_categories_init', array( $this, 'register_ability_category' ) );
     123                add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
    120124        }
    121125
    … …  
    271275                        return true;
    272276                }
    273                 $local = array( 'localhost', 'localhost.localdomain' );
    274                 if ( in_array( strtolower( $domain ), $local, true ) ) {
    275                         return true;
    276                 }
    277                 if ( preg_match( '/\.(local|test|ddev\.site|lndo\.site|wp\.lan)$/i', $domain ) ) {
    278                         return true;
    279                 }
    280                 return false;
     277                $local    = array( 'localhost', 'localhost.localdomain' );
     278                $is_local = in_array( strtolower( $domain ), $local, true )
     279                        || (bool) preg_match( '/\.(local|test|ddev\.site|lndo\.site|wp\.lan)$/i', $domain );
     280
     281                /**
     282                 * Filters whether the current domain is a local environment.
     283                 *
     284                 * Returning false unlocks the Operation page on a local or preview install,
     285                 * which is what a staging copy or a WordPress Playground demo needs.
     286                 *
     287                 * @param bool   $is_local Whether the domain looks local.
     288                 * @param string $domain   Domain being checked.
     289                 */
     290                return (bool) apply_filters( 'cfbcolorvivo_is_local_domain', $is_local, $domain );
    281291        }
    282292        /** Detect the outgoing IP addresses of this server (cached 1 hour). */
    … …  
    949959
    950960                // Registrar script handle para asociar inline scripts.
    951                 wp_register_script( 'cfbcolorvivo-admin', false, array(), '1.9.8', true );
     961                wp_register_script( 'cfbcolorvivo-admin', false, array(), '2.0.0', true );
    952962                wp_enqueue_script( 'cfbcolorvivo-admin' );
    953963
    … …  
    964974
    965975                // Estilos de la interfaz de administración.
    966                 wp_register_style( 'cfbcolorvivo-admin', false, array(), '1.9.8' );
     976                wp_register_style( 'cfbcolorvivo-admin', false, array(), '2.0.0' );
    967977                wp_enqueue_style( 'cfbcolorvivo-admin' );
    968978                wp_add_inline_style( 'cfbcolorvivo-admin', $this->get_admin_css() );
    … …  
    10041014                .cfbcolorvivo-count #cfbcolorvivo-sel-count{font-weight:700;color:#1d2327}
    10051015                .cfbcolorvivo-dirty{display:inline-block;margin-left:8px;padding:2px 8px;background:#fcf9e8;border-left:3px solid #dba617;color:#674d00}
     1016                .cfbcolorvivo-dirty[hidden]{display:none}
    10061017                .cfbcolorvivo-actions{margin:12px 0 0}
    10071018                .cfbcolorvivo-actionrow{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
    … …  
    26952706                }
    26962707
     2708                if ( ! empty( $settings['selected_records'] ) ) {
     2709                        // Los PUT no tocan la caché: la recargamos para que refleje el estado que acabamos de aplicar.
     2710                        if ( $updated > 0 ) {
     2711                                $after = $this->fetch_dns_records();
     2712                                if ( ! empty( $after ) ) {
     2713                                        $this->persist_dns_cache( $after );
     2714                                }
     2715                        }
     2716                        // $settings se leyó antes de refrescar la caché: guardarlo tal cual la sobrescribiría con datos viejos.
     2717                        $settings = $this->get_settings();
     2718                }
     2719
    26972720                $settings['last_check']          = $now_mysql;
    26982721                $settings['last_status_general'] = ( 'SÍ' === $general ) ? 'SI' : 'NO';
    … …  
    28842907                $home = home_url( '/' );
    28852908                $host = wp_parse_url( $home, PHP_URL_HOST );
    2886                 if ( $host ) {
    2887                         return $host;
    2888                 }
    2889                 // Fallback to HTTP_HOST only if home_url parsing fails.
    2890                 return isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
     2909                if ( ! $host ) {
     2910                        // Fallback to HTTP_HOST only if home_url parsing fails.
     2911                        $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : '';
     2912                }
     2913
     2914                /**
     2915                 * Filters the domain the plugin considers to be this site.
     2916                 *
     2917                 * Useful behind a reverse proxy, or on a staging copy that must be checked
     2918                 * against the production domain.
     2919                 *
     2920                 * @param string $host Domain resolved from home_url().
     2921                 */
     2922                return (string) apply_filters( 'cfbcolorvivo_site_domain', $host );
    28912923        }
    28922924        /**
    … …  
    29492981                return null;
    29502982        }
     2983        /**
     2984         * Read lastUpdate from a raw feed body without building the whole array.
     2985         *
     2986         * The feed decodes to about 50 MB, so it should not be decoded just to compare a
     2987         * timestamp. lastUpdate is the first key of the feed; if that ever changes this
     2988         * falls back to a full decode, released before returning.
     2989         *
     2990         * @param string $body Raw JSON body.
     2991         * @return string|null lastUpdate value, or null if the body has none.
     2992         */
     2993        private function read_feed_last_update( $body ) {
     2994                if ( ! is_string( $body ) || '' === $body ) {
     2995                        return null;
     2996                }
     2997                if ( preg_match( '/\A\s*\{\s*"lastUpdate"\s*:\s*"([^"\\\\]+)"/', substr( $body, 0, 256 ), $matches ) ) {
     2998                        return $matches[1];
     2999                }
     3000                $json = json_decode( $body, true );
     3001                return ( is_array( $json ) && ! empty( $json['lastUpdate'] ) && is_string( $json['lastUpdate'] ) ) ? $json['lastUpdate'] : null;
     3002        }
     3003
    29513004        /** Fetch and cache the football block status JSON from remote or local. */
    29523005        private function fetch_status_json() {
    … …  
    29603013                        $local_body = $wp_fs->get_contents( $local_path );
    29613014                        if ( false !== $local_body ) {
    2962                                 $json_local = json_decode( $local_body, true );
    2963                                 if ( is_array( $json_local ) && ! empty( $json_local['lastUpdate'] ) && is_string( $json_local['lastUpdate'] ) ) {
    2964                                         $last_local = $json_local['lastUpdate'];
    2965                                 }
     3015                                $last_local = $this->read_feed_last_update( $local_body );
    29663016                        }
    29673017                }
    … …  
    29693019                $remote_ok   = false;
    29703020                $remote_body = null;
     3021                $remote_json = null;
    29713022                $last_remote = null;
    29723023                $remote_code = 0;
    … …  
    29903041                                        'timeout'     => 25,
    29913042                                        'redirection' => 5,
    2992                                         'user-agent'  => 'ESFB/1.9.8; ' . home_url( '/' ),
     3043                                        'user-agent'  => 'ESFB/2.0.0; ' . home_url( '/' ),
    29933044                                )
    29943045                        );
    … …  
    29993050                                if ( 200 === $remote_code ) {
    30003051                                        $remote_body = wp_remote_retrieve_body( $resp );
    3001                                         $tmp        = json_decode( $remote_body, true );
    3002                                         if ( is_array( $tmp ) ) {
     3052                                        $remote_json = json_decode( $remote_body, true );
     3053                                        if ( is_array( $remote_json ) ) {
    30033054                                                $remote_ok = true;
    3004                                                 if ( ! empty( $tmp['lastUpdate'] ) && is_string( $tmp['lastUpdate'] ) ) {
    3005                                                         $last_remote = $tmp['lastUpdate'];
     3055                                                if ( ! empty( $remote_json['lastUpdate'] ) && is_string( $remote_json['lastUpdate'] ) ) {
     3056                                                        $last_remote = $remote_json['lastUpdate'];
    30063057                                                }
    30073058                                        } else {
    … …  
    30663117                }
    30673118
    3068                 $json = json_decode( $body, true );
     3119                // Reutilizamos el remoto ya decodificado si es el que vamos a usar; si no, se libera
     3120                // antes de decodificar el local para no tener nunca dos copias en memoria (issue #8).
     3121                if ( is_array( $remote_json ) && $body === $remote_body ) {
     3122                        $json = $remote_json;
     3123                } else {
     3124                        $remote_json = null;
     3125                        $json        = json_decode( $body, true );
     3126                        // Copia local corrupta con cabecera válida (p. ej. escritura truncada): se repara con el remoto.
     3127                        if ( ! is_array( $json ) && $remote_ok ) {
     3128                                $json = json_decode( $remote_body, true );
     3129                                if ( $wp_fs && is_array( $json ) ) {
     3130                                        $wp_fs->put_contents( $local_path, $remote_body, FS_CHMOD_FILE );
     3131                                        $this->log( '[CFB] Local data.json corrupto; restaurado desde el remoto.' );
     3132                                }
     3133                        }
     3134                }
     3135                unset( $remote_json, $remote_body, $local_body, $body );
     3136
    30693137                if ( ! is_array( $json ) ) {
    30703138                        $this->log( '[CFB] JSON decode failed' );
    … …  
    30943162                }
    30953163
    3096                 $extracted     = $this->extract_ip_block_map( $json );
     3164                $extracted = $this->extract_ip_block_map( $json );
     3165                unset( $json );
     3166
    30973167                $map           = $extracted['ip_map'] ?? array();
    30983168                $isps_blocked  = $extracted['isps_blocked'] ?? array();
    … …  
    33753445                        $contents = file_get_contents( $local_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
    33763446                        if ( is_string( $contents ) ) {
    3377                                 $decoded = json_decode( $contents, true );
    3378                                 if ( is_array( $decoded ) && ! empty( $decoded['lastUpdate'] ) && is_string( $decoded['lastUpdate'] ) ) {
    3379                                         $local_last_update = $decoded['lastUpdate'];
    3380                                 }
     3447                                $local_last_update = (string) $this->read_feed_last_update( $contents );
    33813448                        }
    33823449                }
    … …  
    35123579                $home    = home_url( '/' );
    35133580                $mgr_url = admin_url( 'admin.php?page=cfbcolorvivo-main' );
    3514                 $version = '1.9.8';
     3581                $version = '2.0.0';
    35153582
    35163583                if ( $now_active ) {
    … …  
    35453612                }
    35463613                $this->log( '[CFB] Email notificación ' . ( $sent ? 'enviada' : 'FALLÓ' ) . ' a ' . $to . ' (estado=' . ( $now_active ? 'bypass activo' : 'bypass inactivo' ) . ')' );
     3614        }
     3615
     3616        /* ================== Abilities API (WordPress 6.9+) ================== */
     3617
     3618        /**
     3619         * Register the ability category for this plugin.
     3620         *
     3621         * Requires WordPress 6.9 or newer, which is the plugin minimum since 2.0.0.
     3622         */
     3623        public function register_ability_category() {
     3624                wp_register_ability_category(
     3625                        'es-football-bypass',
     3626                        array(
     3627                                'label'       => __( 'ES Football Bypass', 'es-football-bypass-for-cloudflare' ),
     3628                                'description' => __( 'Check La Liga IP block status and control the Cloudflare proxy on the managed DNS records.', 'es-football-bypass-for-cloudflare' ),
     3629                        )
     3630                );
     3631        }
     3632
     3633        /**
     3634         * Permission check shared by every ability.
     3635         *
     3636         * Abilities run outside the admin screens (REST, MCP, AI agents), so they enforce
     3637         * the same capability the admin pages require.
     3638         *
     3639         * @return bool True when the current user may operate the plugin.
     3640         */
     3641        public function abilities_permission_check() {
     3642                return current_user_can( 'manage_options' );
     3643        }
     3644
     3645        /**
     3646         * Register the plugin abilities so REST and MCP clients can drive the bypass.
     3647         */
     3648        public function register_abilities() {
     3649                $record_schema = array(
     3650                        'type'       => 'object',
     3651                        'properties' => array(
     3652                                'id'      => array(
     3653                                        'type'        => 'string',
     3654                                        'description' => __( 'Cloudflare DNS record ID.', 'es-football-bypass-for-cloudflare' ),
     3655                                ),
     3656                                'name'    => array( 'type' => 'string' ),
     3657                                'type'    => array( 'type' => 'string' ),
     3658                                'content' => array( 'type' => 'string' ),
     3659                                'proxied' => array(
     3660                                        'type'        => array( 'boolean', 'null' ),
     3661                                        'description' => __( 'True when the record goes through the Cloudflare CDN, null when the record cannot be proxied.', 'es-football-bypass-for-cloudflare' ),
     3662                                ),
     3663                                'managed' => array(
     3664                                        'type'        => 'boolean',
     3665                                        'description' => __( 'True when the plugin is allowed to switch this record.', 'es-football-bypass-for-cloudflare' ),
     3666                                ),
     3667                        ),
     3668                );
     3669
     3670                wp_register_ability(
     3671                        'es-football-bypass/get-status',
     3672                        array(
     3673                                'label'               => __( 'Get bypass status', 'es-football-bypass-for-cloudflare' ),
     3674                                'description'         => __( 'Returns whether La Liga IP blocks are active right now, whether this site is among the blocked IPs, and whether the Cloudflare bypass is currently engaged.', 'es-football-bypass-for-cloudflare' ),
     3675                                'category'            => 'es-football-bypass',
     3676                                'output_schema'       => array(
     3677                                        'type'       => 'object',
     3678                                        'properties' => array(
     3679                                                'blocks_active'      => array(
     3680                                                        'type'        => 'boolean',
     3681                                                        'description' => __( 'Blocks are active on some IPs right now.', 'es-football-bypass-for-cloudflare' ),
     3682                                                ),
     3683                                                'domain_blocked'     => array(
     3684                                                        'type'        => 'boolean',
     3685                                                        'description' => __( 'This site resolves to at least one blocked IP.', 'es-football-bypass-for-cloudflare' ),
     3686                                                ),
     3687                                                'bypass_active'      => array(
     3688                                                        'type'        => 'boolean',
     3689                                                        'description' => __( 'The plugin currently has the managed records on DNS Only.', 'es-football-bypass-for-cloudflare' ),
     3690                                                ),
     3691                                                'domain'             => array( 'type' => 'string' ),
     3692                                                'domain_ips'         => array(
     3693                                                        'type'  => 'array',
     3694                                                        'items' => array( 'type' => 'string' ),
     3695                                                ),
     3696                                                'blocked_domain_ips' => array(
     3697                                                        'type'  => 'array',
     3698                                                        'items' => array( 'type' => 'string' ),
     3699                                                ),
     3700                                                'managed_records'    => array( 'type' => 'integer' ),
     3701                                                'last_check'         => array( 'type' => 'string' ),
     3702                                                'feed_last_update'   => array( 'type' => 'string' ),
     3703                                        ),
     3704                                ),
     3705                                'execute_callback'    => array( $this, 'ability_get_status' ),
     3706                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3707                                'meta'                => array(
     3708                                        'public'       => true,
     3709                                        'show_in_rest' => true,
     3710                                        'annotations'  => array(
     3711                                                'readonly'    => true,
     3712                                                'destructive' => false,
     3713                                                'idempotent'  => true,
     3714                                        ),
     3715                                ),
     3716                        )
     3717                );
     3718
     3719                wp_register_ability(
     3720                        'es-football-bypass/list-dns-records',
     3721                        array(
     3722                                'label'               => __( 'List DNS records', 'es-football-bypass-for-cloudflare' ),
     3723                                'description'         => __( 'Lists the cached Cloudflare DNS records with their current proxy state and whether the plugin manages each one. Optionally filtered by record type.', 'es-football-bypass-for-cloudflare' ),
     3724                                'category'            => 'es-football-bypass',
     3725                                'input_schema'        => array(
     3726                                        'type'       => 'object',
     3727                                        'default'    => array(),
     3728                                        'properties' => array(
     3729                                                'type'         => array(
     3730                                                        'type'        => 'string',
     3731                                                        'enum'        => array( 'A', 'AAAA', 'CNAME' ),
     3732                                                        'description' => __( 'Return only records of this type.', 'es-football-bypass-for-cloudflare' ),
     3733                                                ),
     3734                                                'managed_only' => array(
     3735                                                        'type'        => 'boolean',
     3736                                                        'description' => __( 'Return only the records the plugin manages.', 'es-football-bypass-for-cloudflare' ),
     3737                                                ),
     3738                                        ),
     3739                                ),
     3740                                'output_schema'       => array(
     3741                                        'type'       => 'object',
     3742                                        'properties' => array(
     3743                                                'records' => array(
     3744                                                        'type'  => 'array',
     3745                                                        'items' => $record_schema,
     3746                                                ),
     3747                                                'total'   => array( 'type' => 'integer' ),
     3748                                        ),
     3749                                ),
     3750                                'execute_callback'    => array( $this, 'ability_list_dns_records' ),
     3751                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3752                                'meta'                => array(
     3753                                        'public'       => true,
     3754                                        'show_in_rest' => true,
     3755                                        'annotations'  => array(
     3756                                                'readonly'    => true,
     3757                                                'destructive' => false,
     3758                                                'idempotent'  => true,
     3759                                        ),
     3760                                ),
     3761                        )
     3762                );
     3763
     3764                wp_register_ability(
     3765                        'es-football-bypass/set-managed-records',
     3766                        array(
     3767                                'label'               => __( 'Set managed DNS records', 'es-football-bypass-for-cloudflare' ),
     3768                                'description'         => __( 'Replaces the list of DNS records the plugin is allowed to switch. Record IDs that do not exist in the Cloudflare zone are ignored. Pass an empty array to stop the plugin from touching DNS at all.', 'es-football-bypass-for-cloudflare' ),
     3769                                'category'            => 'es-football-bypass',
     3770                                'input_schema'        => array(
     3771                                        'type'       => 'object',
     3772                                        'properties' => array(
     3773                                                'record_ids' => array(
     3774                                                        'type'        => 'array',
     3775                                                        'items'       => array( 'type' => 'string' ),
     3776                                                        'description' => __( 'Cloudflare DNS record IDs to manage.', 'es-football-bypass-for-cloudflare' ),
     3777                                                ),
     3778                                        ),
     3779                                        'required'   => array( 'record_ids' ),
     3780                                ),
     3781                                'output_schema'       => array(
     3782                                        'type'       => 'object',
     3783                                        'properties' => array(
     3784                                                'managed_records' => array( 'type' => 'integer' ),
     3785                                                'ignored_ids'     => array(
     3786                                                        'type'  => 'array',
     3787                                                        'items' => array( 'type' => 'string' ),
     3788                                                ),
     3789                                        ),
     3790                                ),
     3791                                'execute_callback'    => array( $this, 'ability_set_managed_records' ),
     3792                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3793                                'meta'                => array(
     3794                                        'public'       => true,
     3795                                        'show_in_rest' => true,
     3796                                        'annotations'  => array(
     3797                                                'readonly'    => false,
     3798                                                'destructive' => false,
     3799                                                'idempotent'  => true,
     3800                                        ),
     3801                                ),
     3802                        )
     3803                );
     3804
     3805                wp_register_ability(
     3806                        'es-football-bypass/run-check',
     3807                        array(
     3808                                'label'               => __( 'Run block check now', 'es-football-bypass-for-cloudflare' ),
     3809                                'description'         => __( 'Runs the same check the cron job runs: fetches the hayahora.futbol feed and applies the bypass policy, which may switch the managed records between Proxied and DNS Only.', 'es-football-bypass-for-cloudflare' ),
     3810                                'category'            => 'es-football-bypass',
     3811                                'output_schema'       => array(
     3812                                        'type'       => 'object',
     3813                                        'properties' => array(
     3814                                                'blocks_active'  => array( 'type' => 'boolean' ),
     3815                                                'domain_blocked' => array( 'type' => 'boolean' ),
     3816                                                'bypass_active'  => array( 'type' => 'boolean' ),
     3817                                                'last_check'     => array( 'type' => 'string' ),
     3818                                        ),
     3819                                ),
     3820                                'execute_callback'    => array( $this, 'ability_run_check' ),
     3821                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3822                                'meta'                => array(
     3823                                        'public'       => true,
     3824                                        'show_in_rest' => true,
     3825                                        'annotations'  => array(
     3826                                                'readonly'    => false,
     3827                                                'destructive' => false,
     3828                                                'idempotent'  => false,
     3829                                        ),
     3830                                ),
     3831                        )
     3832                );
     3833
     3834                wp_register_ability(
     3835                        'es-football-bypass/set-proxy',
     3836                        array(
     3837                                'label'               => __( 'Force proxy on or off', 'es-football-bypass-for-cloudflare' ),
     3838                                'description'         => __( 'Forces the managed DNS records to Proxied (CDN) or DNS Only, exactly like the manual buttons on the Operation page. Changes live DNS at Cloudflare.', 'es-football-bypass-for-cloudflare' ),
     3839                                'category'            => 'es-football-bypass',
     3840                                'input_schema'        => array(
     3841                                        'type'       => 'object',
     3842                                        'properties' => array(
     3843                                                'mode' => array(
     3844                                                        'type'        => 'string',
     3845                                                        'enum'        => array( 'on', 'off' ),
     3846                                                        'description' => __( '"on" restores Proxied (CDN); "off" switches to DNS Only.', 'es-football-bypass-for-cloudflare' ),
     3847                                                ),
     3848                                        ),
     3849                                        'required'   => array( 'mode' ),
     3850                                ),
     3851                                'output_schema'       => array(
     3852                                        'type'       => 'object',
     3853                                        'properties' => array(
     3854                                                'mode'      => array( 'type' => 'string' ),
     3855                                                'changed'   => array( 'type' => 'integer' ),
     3856                                                'unchanged' => array( 'type' => 'integer' ),
     3857                                                'failed'    => array( 'type' => 'integer' ),
     3858                                                'report'    => array(
     3859                                                        'type'  => 'array',
     3860                                                        'items' => array( 'type' => 'string' ),
     3861                                                ),
     3862                                        ),
     3863                                ),
     3864                                'execute_callback'    => array( $this, 'ability_set_proxy' ),
     3865                                'permission_callback' => array( $this, 'abilities_permission_check' ),
     3866                                'meta'                => array(
     3867                                        'public'       => true,
     3868                                        'show_in_rest' => true,
     3869                                        'annotations'  => array(
     3870                                                'readonly'    => false,
     3871                                                'destructive' => true,
     3872                                                'idempotent'  => true,
     3873                                        ),
     3874                                ),
     3875                        )
     3876                );
     3877        }
     3878
     3879        /**
     3880         * Ability callback: current block and bypass status.
     3881         *
     3882         * @return array Status payload matching the registered output schema.
     3883         */
     3884        public function ability_get_status() {
     3885                $s    = $this->get_settings();
     3886                $calc = $this->compute_statuses_from_json( false );
     3887
     3888                return array(
     3889                        'blocks_active'      => ( 'SÍ' === $calc['general'] ),
     3890                        'domain_blocked'     => ( 'SÍ' === $calc['domain'] ),
     3891                        'bypass_active'      => ! empty( $s['bypass_active'] ),
     3892                        'domain'             => $this->get_site_domain(),
     3893                        'domain_ips'         => array_values( (array) $calc['domain_ips'] ),
     3894                        'blocked_domain_ips' => array_values( (array) $calc['blocked_domain_ips'] ),
     3895                        'managed_records'    => count( (array) ( $s['selected_records'] ?? array() ) ),
     3896                        'last_check'         => (string) ( $s['last_check'] ?? '' ),
     3897                        'feed_last_update'   => (string) ( $calc['last_update'] ?? '' ),
     3898                );
     3899        }
     3900
     3901        /**
     3902         * Ability callback: list the cached DNS records.
     3903         *
     3904         * @param array $input Optional 'type' and 'managed_only' filters.
     3905         * @return array Records payload matching the registered output schema.
     3906         */
     3907        public function ability_list_dns_records( $input = array() ) {
     3908                $s        = $this->get_settings();
     3909                $selected = (array) ( $s['selected_records'] ?? array() );
     3910                $filter   = isset( $input['type'] ) ? (string) $input['type'] : '';
     3911                $only     = ! empty( $input['managed_only'] );
     3912
     3913                $out = array();
     3914                foreach ( (array) ( $s['dns_records_cache'] ?? array() ) as $r ) {
     3915                        $id      = $r['id'] ?? '';
     3916                        $managed = in_array( $id, $selected, true );
     3917                        if ( $filter && ( $r['type'] ?? '' ) !== $filter ) {
     3918                                continue;
     3919                        }
     3920                        if ( $only && ! $managed ) {
     3921                                continue;
     3922                        }
     3923                        $out[] = array(
     3924                                'id'      => (string) $id,
     3925                                'name'    => (string) ( $r['name'] ?? '' ),
     3926                                'type'    => (string) ( $r['type'] ?? '' ),
     3927                                'content' => (string) ( $r['content'] ?? '' ),
     3928                                'proxied' => array_key_exists( 'proxied', $r ) ? $r['proxied'] : null,
     3929                                'managed' => $managed,
     3930                        );
     3931                }
     3932
     3933                return array(
     3934                        'records' => $out,
     3935                        'total'   => count( $out ),
     3936                );
     3937        }
     3938
     3939        /**
     3940         * Ability callback: replace the managed record selection.
     3941         *
     3942         * @param array $input Payload with the 'record_ids' array.
     3943         * @return array Result payload matching the registered output schema.
     3944         */
     3945        public function ability_set_managed_records( $input = array() ) {
     3946                $s      = $this->get_settings();
     3947                $wanted = array_values( array_unique( array_filter( array_map( 'sanitize_text_field', (array) ( $input['record_ids'] ?? array() ) ) ) ) );
     3948                $known  = array();
     3949                foreach ( (array) ( $s['dns_records_cache'] ?? array() ) as $r ) {
     3950                        if ( ! empty( $r['id'] ) ) {
     3951                                $known[] = $r['id'];
     3952                        }
     3953                }
     3954                $valid   = empty( $known ) ? $wanted : array_values( array_intersect( $wanted, $known ) );
     3955                $ignored = array_values( array_diff( $wanted, $valid ) );
     3956
     3957                $s['selected_records'] = $valid;
     3958                $this->save_settings( $s );
     3959                $this->log_event(
     3960                        'manual',
     3961                        'Selección de registros DNS guardada (Abilities API)',
     3962                        array(
     3963                                'usuario'       => $this->current_user_label(),
     3964                                'seleccionados' => count( $valid ),
     3965                                'ignorados'     => count( $ignored ),
     3966                        )
     3967                );
     3968
     3969                return array(
     3970                        'managed_records' => count( $valid ),
     3971                        'ignored_ids'     => $ignored,
     3972                );
     3973        }
     3974
     3975        /**
     3976         * Ability callback: run the block check and apply the policy.
     3977         *
     3978         * @return array Result payload matching the registered output schema.
     3979         */
     3980        public function ability_run_check() {
     3981                $this->check_football_and_manage_cloudflare();
     3982                $s = $this->get_settings();
     3983                $this->log_event(
     3984                        'manual',
     3985                        'Comprobación ejecutada (Abilities API)',
     3986                        array(
     3987                                'usuario' => $this->current_user_label(),
     3988                                'general' => $s['last_status_general'] ?? 'NO',
     3989                                'dominio' => $s['last_status_domain'] ?? 'NO',
     3990                        )
     3991                );
     3992
     3993                return array(
     3994                        'blocks_active'  => ( 'SI' === ( $s['last_status_general'] ?? 'NO' ) ),
     3995                        'domain_blocked' => ( 'SI' === ( $s['last_status_domain'] ?? 'NO' ) ),
     3996                        'bypass_active'  => ! empty( $s['bypass_active'] ),
     3997                        'last_check'     => (string) ( $s['last_check'] ?? '' ),
     3998                );
     3999        }
     4000
     4001        /**
     4002         * Ability callback: force the managed records to Proxied or DNS Only.
     4003         *
     4004         * @param array $input Payload with the 'mode' value ("on" or "off").
     4005         * @return array|WP_Error Result payload, or WP_Error when nothing can be done.
     4006         */
     4007        public function ability_set_proxy( $input = array() ) {
     4008                $mode    = isset( $input['mode'] ) ? (string) $input['mode'] : '';
     4009                $proxied = ( 'on' === $mode );
     4010
     4011                $s   = $this->get_settings();
     4012                $sel = (array) ( $s['selected_records'] ?? array() );
     4013                if ( empty( $sel ) ) {
     4014                        return new WP_Error(
     4015                                'cfbcolorvivo_no_records',
     4016                                __( 'No DNS records are managed by the plugin. Set them first with the set-managed-records ability.', 'es-football-bypass-for-cloudflare' )
     4017                        );
     4018                }
     4019                if ( empty( $s['cloudflare_zone_id'] ) || empty( $s['cloudflare_api_key'] ) ) {
     4020                        return new WP_Error(
     4021                                'cfbcolorvivo_not_configured',
     4022                                __( 'Cloudflare credentials are not configured.', 'es-football-bypass-for-cloudflare' )
     4023                        );
     4024                }
     4025
     4026                $log    = array();
     4027                $report = $this->apply_proxy_to_records( $sel, $proxied, $log );
     4028
     4029                $this->log_event(
     4030                        'manual',
     4031                        'Forzar Proxy ' . ( $proxied ? 'ON' : 'OFF' ) . ' (Abilities API)',
     4032                        array(
     4033                                'usuario'    => $this->current_user_label(),
     4034                                'procesados' => count( $sel ),
     4035                                'ok'         => $report['ok'],
     4036                                'errores'    => $report['failed'],
     4037                        )
     4038                );
     4039
     4040                return array(
     4041                        'mode'      => $proxied ? 'on' : 'off',
     4042                        'changed'   => $report['ok'],
     4043                        'unchanged' => $report['skipped'],
     4044                        'failed'    => $report['failed'],
     4045                        'report'    => $report['lines'],
     4046                );
    35474047        }
    35484048
    … …  
    39264426        }
    39274427
     4428        /**
     4429         * Apply a proxy state to a set of DNS records, refreshing the cache before and after.
     4430         *
     4431         * Shared by the "Force Proxy ON/OFF" AJAX actions and the Abilities API, so both
     4432         * follow exactly the same path against Cloudflare.
     4433         *
     4434         * @param array $ids     Cloudflare DNS record IDs to update.
     4435         * @param bool  $proxied True for Proxied (CDN), false for DNS Only.
     4436         * @param array $log     Trace log, appended by reference.
     4437         * @return array {
     4438         *     @type int   $ok      Records actually changed.
     4439         *     @type int   $skipped Records already in the requested state.
     4440         *     @type int   $failed  Records that could not be updated.
     4441         *     @type array $lines   Human-readable report, one line per record.
     4442         * }
     4443         */
     4444        private function apply_proxy_to_records( array $ids, $proxied, array &$log ) {
     4445                $state = $proxied ? 'ON' : 'OFF';
     4446
     4447                $before = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
     4448                if ( ! empty( $before ) ) {
     4449                        $this->persist_dns_cache( $before );
     4450                }
     4451
     4452                $ok      = 0;
     4453                $failed  = 0;
     4454                $skipped = 0;
     4455                $lines   = array();
     4456                foreach ( $ids as $rid ) {
     4457                        $res = $this->update_record_proxy_status( $rid, $proxied, true );
     4458                        if ( is_array( $res ) && ! empty( $res['success'] ) ) {
     4459                                if ( ! empty( $res['skipped'] ) ) {
     4460                                        ++$skipped;
     4461                                        $lines[] = 'SKIP: ' . ( $res['record']['name'] ?? $rid ) . ' (ya ' . $state . ')';
     4462                                } else {
     4463                                        ++$ok;
     4464                                        $lines[] = 'OK: ' . ( $res['record']['name'] ?? $rid ) . ' -> ' . $state;
     4465                                }
     4466                        } else {
     4467                                ++$failed;
     4468                                $lines[] = 'ERR: ' . ( $res['record']['name'] ?? $rid ) . ' ' . ( is_array( $res ) && ! empty( $res['error'] ) ? $res['error'] : '' );
     4469                        }
     4470                }
     4471
     4472                $after = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
     4473                if ( ! empty( $after ) ) {
     4474                        $this->persist_dns_cache( $after );
     4475                }
     4476
     4477                return array(
     4478                        'ok'      => $ok,
     4479                        'skipped' => $skipped,
     4480                        'failed'  => $failed,
     4481                        'lines'   => $lines,
     4482                );
     4483        }
     4484
    39284485        /** AJAX handler: force proxy OFF (DNS Only) on selected records. */
    39294486        public function ajax_force_deactivate() {
    … …  
    39504507                }
    39514508
    3952                 $before = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    3953                 if ( ! empty( $before ) ) {
    3954                         $this->persist_dns_cache( $before );
    3955                 }
    3956                 $ok    = 0;
    3957                 $fail  = 0;
    3958                 $lines = array();
    3959                 foreach ( $sel as $rid ) {
    3960                         $res = $this->update_record_proxy_status( $rid, false, true );
    3961                         if ( is_array( $res ) && ! empty( $res['success'] ) ) {
    3962                                 if ( ! empty( $res['skipped'] ) ) {
    3963                                         $lines[] = 'SKIP: ' . ( $res['record']['name'] ?? $rid ) . ' (ya OFF)';
    3964                                 } else {
    3965                                         ++$ok;
    3966                                         $lines[] = 'OK: ' . ( $res['record']['name'] ?? $rid ) . ' -> OFF'; }
    3967                         } else {
    3968                                 ++$fail;
    3969                                 $lines[] = 'ERR: ' . ( ( $res['record']['name'] ?? $rid ) ) . ' ' . ( is_array( $res ) && ! empty( $res['error'] ) ? $res['error'] : '' );
    3970                         }
    3971                 }
    3972                 $after = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    3973                 if ( ! empty( $after ) ) {
    3974                         $this->persist_dns_cache( $after );
    3975                 }
     4509                $report = $this->apply_proxy_to_records( $sel, false, $log );
     4510                $ok     = $report['ok'];
     4511                $fail   = $report['failed'];
     4512                $lines  = $report['lines'];
    39764513
    39774514                $s2 = $this->get_settings();
    … …  
    40244561                }
    40254562
    4026                 $before = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    4027                 if ( ! empty( $before ) ) {
    4028                         $this->persist_dns_cache( $before );
    4029                 }
    4030                 $ok    = 0;
    4031                 $fail  = 0;
    4032                 $lines = array();
    4033                 foreach ( $sel as $rid ) {
    4034                         $res = $this->update_record_proxy_status( $rid, true, true );
    4035                         if ( is_array( $res ) && ! empty( $res['success'] ) ) {
    4036                                 if ( ! empty( $res['skipped'] ) ) {
    4037                                         $lines[] = 'SKIP: ' . ( $res['record']['name'] ?? $rid ) . ' (ya ON)';
    4038                                 } else {
    4039                                         ++$ok;
    4040                                         $lines[] = 'OK: ' . ( $res['record']['name'] ?? $rid ) . ' -> ON'; }
    4041                         } else {
    4042                                 ++$fail;
    4043                                 $lines[] = 'ERR: ' . ( ( $res['record']['name'] ?? $rid ) ) . ' ' . ( is_array( $res ) && ! empty( $res['error'] ) ? $res['error'] : '' );
    4044                         }
    4045                 }
    4046                 $after = $this->fetch_dns_records( array( 'A', 'AAAA', 'CNAME' ), $log );
    4047                 if ( ! empty( $after ) ) {
    4048                         $this->persist_dns_cache( $after );
    4049                 }
     4563                $report = $this->apply_proxy_to_records( $sel, true, $log );
     4564                $ok     = $report['ok'];
     4565                $fail   = $report['failed'];
     4566                $lines  = $report['lines'];
    40504567
    40514568                $s2 = $this->get_settings();
  • es-football-bypass-for-cloudflare/trunk/readme.txt

    r3707647 r3783334  
    22Contributors: dcarrero
    33Tags: cloudflare, dns, football, bypass, ip-blocking
    4 Requires at least: 5.0
     4Requires at least: 6.9
    55Tested up to: 7.1
    66Requires PHP: 7.4
    7 Stable tag: 1.9.8
     7Stable tag: 2.0.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
    … …  
    178178In the Operation tab, click "WP-Cron Diagnostics" to see the next execution and the result of the last check. You can also review the integrated logs.
    179179
     180== Abilities API ==
     181
     182Since 2.0.0 the plugin registers five abilities through the WordPress Abilities API (WordPress 6.9 and newer), under the `es-football-bypass` category. This lets the REST API, MCP servers and AI agents read the block status and operate the bypass without going through the admin screens.
     183
     184Every ability requires the `manage_options` capability, declares JSON Schemas for its input and output, and carries readonly/destructive/idempotent annotations so clients know what is safe to call.
     185
     186= HTTP methods over REST =
     187WordPress derives the HTTP method from each ability's annotations, so REST clients must use the right verb (MCP clients call abilities by name and are unaffected):
     188
     189* `get-status` and `list-dns-records` are `readonly`, so they are served over **GET**, with input as query parameters (`?input[type]=A`).
     190* `set-managed-records` and `run-check` are served over **POST**, with input in the JSON body (`{"input": {...}}`).
     191* `set-proxy` is annotated `destructive`, so it is served over **DELETE**. Pass its input as query parameters (`?input[mode]=off`): a JSON body is not read on DELETE requests.
     192
     193= es-football-bypass/get-status =
     194Read-only. Returns whether La Liga IP blocks are active right now, whether this site resolves to a blocked IP, whether the bypass is engaged, the resolved IPs, how many records are managed, and the last check and feed timestamps.
     195
     196= es-football-bypass/list-dns-records =
     197Read-only. Returns the cached DNS records with their proxy state and whether the plugin manages each one. Optional input: `type` (`A`, `AAAA` or `CNAME`) and `managed_only`.
     198
     199= es-football-bypass/set-managed-records =
     200Replaces the list of records the plugin is allowed to switch. Input: `record_ids` (array of Cloudflare record IDs). IDs that do not exist in the zone are returned in `ignored_ids`. Pass an empty array to stop the plugin from touching DNS at all.
     201
     202= es-football-bypass/run-check =
     203Runs the same check as the cron job: fetches the hayahora.futbol feed and applies the bypass policy, which may switch the managed records.
     204
     205= es-football-bypass/set-proxy =
     206Forces the managed records to Proxied (CDN) or DNS Only. Input: `mode` (`on` or `off`). Annotated as destructive, since it changes live DNS at Cloudflare. Returns an error when no records are managed or Cloudflare credentials are missing.
     207
    180208== Logs and Auditing ==
    181209
    … …  
    191219== Screenshots ==
    192220
    193 1. Settings page: select Cloudflare authentication type (Global API Key, User Token or Account Token), enter credentials, configure check interval, cooldown after disabling Cloudflare, the "Force Proxy OFF during football" override, log retention, and the external cron token.
    194 2. Operation page: live block status from hayahora.futbol, cached DNS records with current Proxied state per record, manual controls (save selection, reload from Cloudflare, force Proxy ON/OFF, manual check, WP-Cron diagnostics), and a sidebar with related links.
     2211. Settings page: Simple or Advanced interface mode, Cloudflare authentication type and credentials, server outbound IP, bypass behaviour (check interval, cooldown, "Force Proxy OFF during football" override, minimum ISPs with blocks), email notifications and uninstall options.
     2222. Operation page: live block status from hayahora.futbol and the DNS records the plugin manages, with type filters, quick selection buttons and the current proxy state per record, plus manual actions (check for blocks, force Proxy OFF or ON).
     2233. "Is there football now?" page: a quick yes or no answer on whether La Liga IP blocks are active, with the time of the last feed update and the affected ISPs.
    195224
    196225== Changelog ==
     226
     227= 2.0.0 =
     228* NEW: Abilities API support (WordPress 6.9+). The plugin now registers five abilities under the "ES Football Bypass" category, so the REST API, MCP servers and AI agents can drive the bypass without going through the admin screens: `get-status`, `list-dns-records`, `set-managed-records`, `run-check` and `set-proxy`
     229* NEW: `es-football-bypass/get-status` — whether La Liga IP blocks are active, whether this site resolves to a blocked IP, and whether the bypass is currently engaged
     230* NEW: `es-football-bypass/list-dns-records` — the cached DNS records with their proxy state and whether the plugin manages each one, filterable by record type or to managed records only
     231* NEW: `es-football-bypass/set-managed-records` — replaces the list of records the plugin may switch; IDs missing from the Cloudflare zone are reported back as ignored
     232* NEW: `es-football-bypass/run-check` — runs the same check as the cron job and applies the bypass policy
     233* NEW: `es-football-bypass/set-proxy` — forces the managed records to Proxied (CDN) or DNS Only. Annotated as destructive so clients ask for confirmation, and it returns a clear error when no records are managed or Cloudflare is not configured
     234* All abilities require the `manage_options` capability, declare JSON Schemas for input and output, and carry readonly/destructive/idempotent annotations so agents know what is safe to call
     235* NEW: Filters `cfbcolorvivo_site_domain` and `cfbcolorvivo_is_local_domain`, so a staging copy, a reverse-proxied install or a WordPress Playground preview can unlock the Operation page and be checked against the production domain
     236* FIX: The "Unsaved changes" warning was visible on page load before touching anything — the stylesheet was overriding the `hidden` attribute
     237* FIX: PHP memory exhaustion while processing the status feed. The feed (about 5 MB, 50 MB once decoded) was decoded up to three times in a row and every copy was kept in memory until the end; a fourth decode on the Operation page only read a timestamp. It is now decoded once, so peak memory falls to roughly a third. Thanks to joserikko for the report
     238* FIX: After an automatic check the cached DNS records kept the proxy state from before the change, so the Operation page and `list-dns-records` could report the opposite of what was live at Cloudflare until the next refresh
     239* FIX: Uninstalling now removes the logs folder completely; a hidden `.htaccess` file was being left behind
     240* COMPAT: Minimum WordPress version raised to 6.9, which is where the Abilities API landed. Sites on older versions stay on 1.9.8, which contains the DNS record selection fix
     241* INTERNAL: The "Force Proxy ON/OFF" logic is now shared between the admin AJAX actions and the abilities, so both follow exactly the same path against Cloudflare
    197242
    198243= 1.9.8 =
    … …  
    338383== Upgrade Notice ==
    339384
     385= 2.0.0 =
     386Adds Abilities API support (WordPress 6.9+): five abilities let the REST API, MCP servers and AI agents read the block status and control the Cloudflare proxy. Minimum WordPress version is now 6.9.
     387
    340388= 1.9.8 =
    341389Important fix: unchecking DNS records did nothing, so manual actions were applied to every record in the zone. Adds a "Save selection" button, record type filters (Only A / A + AAAA) and clearer feedback. Review your selected records after updating.
    … …  
    388436== Server Requirements ==
    389437
    390 - WordPress 5.0 or higher
     438- WordPress 6.9 or higher
    391439- PHP 7.4 or higher
    392440- PHP Extensions: curl, json
  • es-football-bypass-for-cloudflare/trunk/uninstall.php

    r3602202 r3783334  
    6363                        }
    6464                }
     65                // glob() no lista los ficheros ocultos: sin esto el .htaccess impide borrar el directorio.
     66                wp_delete_file( $cfbcolorvivo_dir . '/.htaccess' );
    6567                $wp_filesystem->rmdir( $cfbcolorvivo_dir );
    6668        }
    … …  
    8284                        }
    8385                }
     86                // glob() no lista los ficheros ocultos: sin esto el .htaccess impide borrar el directorio.
     87                wp_delete_file( $cfbcolorvivo_dir . '/.htaccess' );
    8488                $wp_filesystem->rmdir( $cfbcolorvivo_dir );
    8589        }
Note: See TracChangeset for help on using the changeset viewer.