*/ public static function defaults(): array { return array( // Estado. 'enabled' => false, // Se activa cuando el admin completa la identidad del responsable. // Identidad del responsable (art. 14 ter b) y c)). 'owner_name' => '', 'owner_legal_id' => '', 'owner_rep' => '', 'owner_rep_id' => '', 'owner_address' => '', 'owner_email' => '', 'owner_phone' => '', 'dpo_name' => '', // Encargado de prevención / delegado, si existiere (art. 14 ter b). 'dpo_email' => '', // Aviso (banner). 'notice_title' => 'Tu privacidad', 'notice_body' => 'Usamos cookies y tecnologías similares. Las necesarias permiten que el sitio funcione. Las demás solo se activan si tú las aceptas, una por una. Puedes aceptar todo, rechazar todo o elegir finalidad por finalidad, y cambiar tu decisión cuando quieras.', 'notice_accept' => 'Aceptar todo', 'notice_reject' => 'Rechazar todo', 'notice_configure' => 'Configurar', 'notice_save' => 'Guardar mis preferencias', 'notice_reopen' => 'Preferencias de privacidad', 'notice_read_more' => 'Leer el aviso completo', 'position' => 'bottom', 'theme' => 'auto', 'accent' => '#0b6e4f', 'reject_style' => 'equal', // equal | secondary. 'equal' es el único sin riesgo legal. 'cookie_wall' => false, // Bloquear el contenido hasta aceptar = consentimiento no libre (art. 12 inciso 6). 'floating_button' => true, 'floating_label' => 'Privacidad', // Finalidades activas. 'purposes' => array( 'preferencias', 'analiticas', 'publicidad', 'embeds' ), 'extra_purposes' => '', // Una finalidad propia por línea: clave|Etiqueta|Descripción. // Carga y bloqueo. 'load_mode' => Script_Blocker::MODE_STRICT, 'block_iframes' => true, 'block_embedded' => true, 'honor_consent_server' => true, // Solo para sitios sin caché de página completa. // Aviso y política. 'policy_page_id' => 0, 'policy_version' => '1.0', // Versión declarada del aviso (art. 12: consentimiento informado). 'policy_auto_version' => false, // Por defecto la versión es manual: solo se sube cuando cambia el fondo del aviso. 'policy_date' => '', // Fecha de la última versión (se completa al guardar). 'policy_generated_id' => 0, // Página cuya política escribió este plugin (se puede reescribir). 'policy_draft_id' => 0, // Último borrador generado cuando la política del sitio no es nuestra. 'policy_last_mode' => '', // nueva | actualizada | borrador | error // Registro y prueba (art. 12 inciso 7: corresponde al responsable probar). 'log_enabled' => true, 'log_ip_hash' => true, // Solo hash con sal del sitio, nunca la IP en claro. 'log_retention_months' => 24, 'consent_months' => 12, // Vigencia de la decisión antes de volver a preguntar. 'purge_on_uninstall' => false, // Por defecto el registro NO se borra al desinstalar (es prueba de cumplimiento). // Derechos del titular (art. 11). 'rights_enabled' => false, 'rights_email' => '', 'rights_page_id' => 0, 'rights_holidays' => '', // Feriados extra, uno por línea (YYYY-MM-DD) para los días hábiles. // Transferencias internacionales (art. 14 ter h) y art. 27). 'transfers' => array(), // Filas: proveedor|país|base|nota. // Registro de actividades de tratamiento (art. 3 letra d) del reglamento). 'ops' => array(), // Última verificación/diagnóstico. 'diag_last' => '', 'diag_data' => array(), // Arranque. 'wp_version' => '', 'installed_at' => '', ); } /** * Configuración completa (defaults + guardado). * * @return array */ public static function all(): array { if ( null === self::$cache ) { $saved = get_option( CUMPLECONSENT_OPT, array() ); if ( ! is_array( $saved ) ) { $saved = array(); } self::$cache = array_merge( self::defaults(), $saved ); } return self::$cache; } /** * Un valor de configuración. * * @param string $key Clave. * @param mixed $default Valor si no existe. * @return mixed */ public static function get( $key, $default = null ) { $all = self::all(); if ( array_key_exists( $key, $all ) ) { return $all[ $key ]; } return $default; } /** * Guarda un conjunto parcial de claves. * * @param array $data Claves a sobrescribir. * @return array Configuración resultante. */ public static function save( array $data ): array { $all = array_merge( self::all(), $data ); update_option( CUMPLECONSENT_OPT, $all ); self::$cache = $all; Policy::flush_version(); return $all; } /** Reinicia el cache (tras guardar desde el admin). */ public static function flush_cache(): void { self::$cache = null; } /** * Saneador de la opción registrada. * * El formulario del panel envía el estado COMPLETO y en él una casilla sin * marcar no viaja: su ausencia significa “apagado”. La API puede escribir solo * lo que cambia, así que allí la ausencia significa “como estaba”. Sin esta * distinción, una escritura parcial por API apagaba en silencio todos los * interruptores que no venían en la petición. * * @param mixed $input Datos crudos. * @return array */ public static function sanitize_setting( $input ): array { $in = is_array( $input ) ? $input : array(); if ( self::is_rest_write() ) { $in = array_merge( self::all(), $in ); } return self::sanitize( $in ); } /** ¿La escritura actual llega por la API (y no por el formulario del panel)? */ public static function is_rest_write(): bool { if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; } return function_exists( 'wp_is_json_request' ) && wp_is_json_request(); } /** * Registra la opción —también en la API— y los efectos de cualquier guardado. * * El registro va en `init` y no en `admin_init` a propósito: las peticiones * REST no pasan por `admin_init`, así que expuesta allí la opción nunca * aparecería en /wp/v2/settings. * * Sobre la exposición en la API: /wp/v2/settings exige `manage_options` —el * mismo permiso que la pantalla de ajustes— y la opción no guarda ninguna * credencial (la sal del registro vive en su propia opción, fuera de esta). * El esquema es deliberadamente abierto: quien manda es sanitize(), el mismo * saneador que usa el formulario del panel, para que la API no acepte nada * que el panel rechace. */ public static function register(): void { register_setting( 'cumpleconsent_settings_group', CUMPLECONSENT_OPT, array( 'type' => 'array', 'description' => __( 'Configuración de CumpleConsent (una sola opción atómica).', 'cumpleconsent' ), 'sanitize_callback' => array( __CLASS__, 'sanitize_setting' ), 'default' => self::defaults(), 'show_in_rest' => array( 'schema' => array( 'type' => 'object', 'additionalProperties' => true, ), ), ) ); add_action( 'update_option_' . CUMPLECONSENT_OPT, array( __CLASS__, 'after_update' ), 10, 2 ); } /** * Efectos posteriores a un guardado de la opción (venga del panel o de la API). * * @param mixed $old_valor Valor anterior. * @param mixed $valor Valor nuevo. */ public static function after_update( $old_valor, $valor ): void { self::$cache = null; Policy::flush_version(); // El aviso y su versión viajan en el HTML: si el sitio sirve una página // cacheada, el visitante no ve el cambio. Plugin::purge_caches(); } /** ¿El aviso está operativo? */ public static function is_ready(): bool { $a = self::all(); return ! empty( $a['enabled'] ) && '' !== trim( (string) $a['owner_name'] ); } /** * Sal del sitio para los HMAC del registro (no se autoloada). */ public static function log_salt(): string { $salt = get_option( CUMPLECONSENT_LOG_SALT_OPT ); if ( ! is_string( $salt ) || '' === $salt ) { $salt = wp_generate_password( 64, true, true ); add_option( CUMPLECONSENT_LOG_SALT_OPT, $salt, '', false ); } return (string) $salt; } /** * Claves que este saneador normaliza. * * Todo lo que no está en esta lista es estado interno de la opción * (resultado del diagnóstico, fechas de instalación y de política) y debe * viajar tal cual: el saneador reconstruye la opción desde los valores * previos, así que lo que no conoce lo pierde. * * @return array */ public static function managed_keys(): array { return array( // Identidad y aviso. 'owner_name', 'owner_rep', 'owner_address', 'dpo_name', 'owner_legal_id', 'owner_rep_id', 'owner_email', 'owner_phone', 'dpo_email', // Textos del aviso. 'notice_title', 'notice_accept', 'notice_reject', 'notice_configure', 'notice_save', 'notice_reopen', 'notice_read_more', 'notice_body', 'floating_label', 'accent', 'position', 'theme', 'reject_style', 'cookie_wall', // Finalidades. 'purposes', 'extra_purposes', // Carga y bloqueo. 'load_mode', 'block_iframes', 'block_embedded', 'honor_consent_server', 'enabled', // Política. 'policy_version', 'policy_auto_version', 'policy_page_id', // Registro y prueba. 'log_enabled', 'log_ip_hash', 'log_retention_months', 'consent_months', 'purge_on_uninstall', // Derechos del titular. 'rights_enabled', 'rights_email', 'rights_page_id', 'rights_holidays', // Transferencias y actividades de tratamiento. 'transfers', 'ops', ); } /** * Saneamiento de la configuración que llega del formulario de administración. * * @param mixed $input Datos crudos. * @return array */ public static function sanitize( $input ): array { $in = is_array( $input ) ? $input : array(); $out = self::all(); // Lo que este saneador no gestiona es estado interno de la opción (el // resultado del diagnóstico, la fecha de instalación, la de la última // versión de la política). Viaja tal cual llega: el saneador reconstruye // la opción desde los valores previos y sólo copia los campos que conoce, // así que sin esta regla cada guardado —el del panel, el de la API, el del // propio `Settings::save()`— descartaba ese estado en silencio. Era la // causa de que el diagnóstico pareciera no ejecutarse nunca: se ejecutaba, // guardaba su resultado y el guardado lo tiraba. $gestionadas = self::managed_keys(); foreach ( $in as $k => $v ) { if ( array_key_exists( $k, $out ) && ! in_array( $k, $gestionadas, true ) ) { $out[ $k ] = $v; } } $text = array( 'owner_name', 'owner_rep', 'owner_address', 'dpo_name', 'notice_title', 'notice_accept', 'notice_reject', 'notice_configure', 'notice_save', 'notice_reopen', 'notice_read_more', 'floating_label', 'policy_version', ); foreach ( $text as $k ) { if ( isset( $in[ $k ] ) ) { $out[ $k ] = sanitize_text_field( (string) $in[ $k ] ); } } $textarea = array( 'notice_body', 'extra_purposes', 'rights_holidays' ); foreach ( $textarea as $k ) { if ( isset( $in[ $k ] ) ) { $out[ $k ] = sanitize_textarea_field( (string) $in[ $k ] ); } } if ( isset( $in['owner_legal_id'] ) ) { $out['owner_legal_id'] = self::sanitize_rut( (string) $in['owner_legal_id'] ); } if ( isset( $in['owner_rep_id'] ) ) { $out['owner_rep_id'] = self::sanitize_rut( (string) $in['owner_rep_id'] ); } $emails = array( 'owner_email', 'dpo_email', 'rights_email' ); foreach ( $emails as $k ) { if ( isset( $in[ $k ] ) ) { $e = sanitize_email( (string) $in[ $k ] ); $out[ $k ] = is_email( $e ) ? $e : ''; } } if ( isset( $in['owner_phone'] ) ) { $out['owner_phone'] = preg_replace( '/[^0-9+()\- ]/', '', (string) $in['owner_phone'] ); } $ints = array( 'policy_page_id' => 0, 'rights_page_id' => 0, 'log_retention_months' => 1, 'consent_months' => 1, ); foreach ( $ints as $k => $min ) { if ( isset( $in[ $k ] ) ) { $v = absint( $in[ $k ] ); $out[ $k ] = $v >= $min ? $v : $min; } } $bools = array( 'enabled', 'cookie_wall', 'floating_button', 'block_iframes', 'block_embedded', 'honor_consent_server', 'policy_auto_version', 'log_enabled', 'log_ip_hash', 'rights_enabled', 'purge_on_uninstall', ); foreach ( $bools as $k ) { $out[ $k ] = ! empty( $in[ $k ] ); } if ( isset( $in['position'] ) && in_array( $in['position'], array( 'bottom', 'bottom-left', 'center', 'top' ), true ) ) { $out['position'] = $in['position']; } if ( isset( $in['theme'] ) && in_array( $in['theme'], array( 'auto', 'light', 'dark' ), true ) ) { $out['theme'] = $in['theme']; } if ( isset( $in['reject_style'] ) && in_array( $in['reject_style'], array( 'equal', 'secondary' ), true ) ) { $out['reject_style'] = $in['reject_style']; } if ( isset( $in['accent'] ) ) { $color = sanitize_hex_color( (string) $in['accent'] ); if ( $color ) { $out['accent'] = $color; } } if ( isset( $in['load_mode'] ) && in_array( $in['load_mode'], array( Script_Blocker::MODE_STRICT, Script_Blocker::MODE_BALANCED ), true ) ) { $out['load_mode'] = $in['load_mode']; } if ( isset( $in['purposes'] ) && is_array( $in['purposes'] ) ) { $valid = array(); foreach ( array_keys( Purposes::all() ) as $key ) { if ( in_array( $key, $in['purposes'], true ) ) { $valid[] = $key; } } $out['purposes'] = $valid; } if ( isset( $in['transfers'] ) && is_array( $in['transfers'] ) ) { $out['transfers'] = Transfers::sanitize_rows( $in['transfers'] ); } if ( isset( $in['ops'] ) && is_array( $in['ops'] ) ) { $out['ops'] = Rat::sanitize_rows( $in['ops'] ); } if ( isset( $in['rights_holidays'] ) ) { $out['rights_holidays'] = self::sanitize_date_list( (string) $out['rights_holidays'] ); } return $out; } /** * Normaliza un RUT chileno (mantiene dígitos y K, sin puntos; con guion). * * @param string $raw Entrada. * @return string */ public static function sanitize_rut( string $raw ): string { $clean = strtoupper( preg_replace( '/[^0-9kK]/', '', $raw ) ); if ( '' === $clean ) { return ''; } if ( strlen( $clean ) < 2 ) { return $clean; } return substr( $clean, 0, -1 ) . '-' . substr( $clean, -1 ); } /** * Lista de fechas YYYY-MM-DD, una por línea, validada y ordenada. * * @param string $raw Entrada multilínea. * @return string */ public static function sanitize_date_list( string $raw ): string { $out = array(); foreach ( preg_split( '/[\r\n,;]+/', $raw ) as $line ) { $line = trim( $line ); if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $line ) ) { $out[] = $line; } } $out = array_values( array_unique( $out ) ); sort( $out ); return implode( "\n", $out ); } }