'POST', 'callback' => array( __CLASS__, 'recibir' ), 'permission_callback' => '__return_true', // La autenticidad la prueba la firma, no la sesión. ) ); } /** * Dirección que se envía como `webhook_url`: la ruta REST del sitio, solo si es https, puerto 443 y pública. * * @return string Vacía si el sitio no puede recibir avisos. */ public static function url_para_documentos() { $url = rest_url( 'validafirma/v1/avisos' ); $url = apply_filters( 'validafirma_url_de_avisos', $url ); $info = wp_parse_url( (string) $url ); if ( empty( $info['scheme'] ) || 'https' !== $info['scheme'] || empty( $info['host'] ) ) { return ''; } if ( ! empty( $info['port'] ) && 443 !== (int) $info['port'] ) { return ''; } if ( ! empty( $info['user'] ) || ! empty( $info['pass'] ) ) { return ''; } if ( ! self::es_host_publico( $info['host'] ) ) { return ''; } return (string) $url; } /** * ¿El host parece público? (Sin consultar DNS: descarta lo local, lo reservado y los nombres sin punto.) * * @param string $host Nombre de host o IP. * @return bool */ private static function es_host_publico( $host ) { $host = strtolower( trim( $host, '[]' ) ); if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { return (bool) filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } if ( false === strpos( $host, '.' ) ) { return false; } return ! preg_match( '/\.(local|localhost|test|invalid|example|internal|lan|home|localdomain)$/', $host ); } /** * ¿La firma del aviso es válida con alguno de los secretos? * * @param string $cuerpo Cuerpo crudo. * @param string $cabecera Valor de X-Webhook-Signature. * @param string[] $secretos Secretos que valen para el modo del aviso. * @param int|null $ahora Reloj, para pruebas. * @return bool */ public static function firma_valida( $cuerpo, $cabecera, array $secretos, $ahora = null ) { $t = null; $candidatas = array(); foreach ( explode( ',', (string) $cabecera ) as $parte ) { $par = explode( '=', trim( $parte ), 2 ); $clave = $par[0]; $valor = isset( $par[1] ) ? $par[1] : ''; if ( 't' === $clave ) { $t = $valor; } elseif ( 'v1' === $clave ) { $candidatas[] = $valor; } } if ( null === $t || ! ctype_digit( $t ) || ! $candidatas || ! $secretos ) { return false; } $ahora = null === $ahora ? time() : $ahora; if ( abs( $ahora - (int) $t ) > self::TOLERANCIA ) { return false; } foreach ( $secretos as $secreto ) { if ( '' === $secreto ) { continue; } $esperada = hash_hmac( 'sha256', $t . '.' . $cuerpo, $secreto ); foreach ( $candidatas as $candidata ) { if ( hash_equals( $esperada, $candidata ) ) { return true; } } } return false; } /** * Recibe un aviso. * * @param WP_REST_Request $peticion Petición. * @return WP_REST_Response */ public static function recibir( WP_REST_Request $peticion ) { global $wpdb; // 1. Con el sitio desconectado, los avisos se descartan (D-28). if ( ! Validafirma_Conexion::estado() ) { return new WP_REST_Response( array( 'ignorado' => true ), 200 ); } // 2. Cuerpo crudo y firma, con el secreto del modo que dice `es_prueba`. $cuerpo = $peticion->get_body(); $aviso = json_decode( $cuerpo, true ); if ( ! is_array( $aviso ) ) { return new WP_REST_Response( array( 'error' => 'cuerpo_invalido' ), 400 ); } $modo = empty( $aviso['es_prueba'] ) ? 'produccion' : 'prueba'; $firma = (string) $peticion->get_header( 'x_webhook_signature' ); $secretos = Validafirma_Conexion::secretos_de_avisos( $modo ); if ( ! self::firma_valida( $cuerpo, $firma, $secretos ) ) { // Puede ser un secreto rotado: se refresca a lo más una vez por hora (no se procesa este aviso). if ( ! get_transient( 'validafirma_refresco_secreto' ) ) { set_transient( 'validafirma_refresco_secreto', 1, HOUR_IN_SECONDS ); Validafirma_Conexion::refrescar_secretos(); } return new WP_REST_Response( array( 'error' => 'firma_invalida' ), 401 ); } // 3. Deduplicación por entrega. $entrega = (string) $peticion->get_header( 'x_validafirma_delivery' ); if ( '' === $entrega || strlen( $entrega ) > 100 ) { return new WP_REST_Response( array( 'error' => 'sin_entrega' ), 400 ); } $tabla = Validafirma_Instalador::tabla( 'entregas' ); $nueva = $wpdb->query( $wpdb->prepare( 'INSERT IGNORE INTO %i (entrega_id, recibida_en) VALUES (%s, %s)', $tabla, $entrega, Validafirma_Envios::ahora() ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( 0 === (int) $nueva ) { return new WP_REST_Response( array( 'repetido' => true ), 200 ); } try { $resultado = self::procesar( $aviso ); } catch ( Throwable $e ) { // Que el reintento de ValidaFirma pueda procesarla. $wpdb->delete( $tabla, array( 'entrega_id' => $entrega ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return new WP_REST_Response( array( 'error' => 'no_procesado' ), 500 ); } return new WP_REST_Response( $resultado, 200 ); } /** * Aplica un aviso ya verificado. * * @param array $aviso Cuerpo del aviso. * @return array Respuesta. */ public static function procesar( array $aviso ) { $evento = isset( $aviso['evento'] ) ? (string) $aviso['evento'] : ''; $documento_id = isset( $aviso['documento']['id'] ) ? (string) $aviso['documento']['id'] : ''; if ( '' === $documento_id ) { return array( 'ignorado' => true ); } $envio = Validafirma_Envios::por_documento( $documento_id ); if ( ! $envio ) { return array( 'ignorado' => true ); } // Un aviso de prueba no toca un envío de producción, ni al revés (RN-07). $aviso_de_prueba = ! empty( $aviso['es_prueba'] ); if ( $aviso_de_prueba !== $envio['es_prueba'] ) { return array( 'ignorado' => true ); } $estados = array( 'documento.completado' => 'firmado', 'documento.rechazado' => 'rechazado', 'documento.cancelado' => 'cancelado', 'documento.expirado' => 'vencido', 'documento.no_emitido' => 'no_emitido', ); if ( isset( $estados[ $evento ] ) ) { self::aplicar_estado( $envio, $estados[ $evento ] ); return array( 'ok' => true ); } $causas = array( 'firmante.invitacion_no_entregada' => 'invitacion_no_entregada', 'firmante.bloqueado' => 'bloqueado', 'firmante.identidad_no_coincide' => 'identidad_no_coincide', ); if ( isset( $causas[ $evento ] ) && Validafirma_Correos::es_de_formulario( $envio ) ) { Validafirma_Correos::por_causa( $causas[ $evento ], $envio ); } return array( 'ok' => true ); } /** * Lleva un envío a un estado final. Cada transición ocurre una sola vez: la comparten el aviso y la revisión. * * @param array $envio Envío. * @param string $estado firmado | rechazado | vencido | cancelado | no_emitido. * @return bool Verdadero si este llamado hizo la transición. */ public static function aplicar_estado( array $envio, $estado ) { $hecho = Validafirma_Envios::transicionar( $envio['id'], array( 'pendiente', 'enviando' ), $estado, array( 'terminado_en' => Validafirma_Envios::ahora() ) ); if ( ! $hecho ) { return false; } $envio['estado'] = $estado; switch ( $estado ) { case 'firmado': // El PDF se baja en la misma petición del aviso, sin depender de que WP-Cron corra (en un sitio con // pocas visitas puede tardar horas). ValidaFirma espera 30 s: la descarga tiene 20 y, si falla, queda // programada para reintentarse. self::descargar_firmado( $envio['id'], 0, 20 ); break; case 'rechazado': do_action( 'validafirma_documento_rechazado', $envio['id'], Validafirma_Envios::publico( $envio ) ); break; case 'vencido': do_action( 'validafirma_documento_vencido', $envio['id'], Validafirma_Envios::publico( $envio ) ); break; case 'cancelado': do_action( 'validafirma_documento_cancelado', $envio['id'], Validafirma_Envios::publico( $envio ) ); break; case 'no_emitido': do_action( 'validafirma_documento_no_emitido', $envio['id'], Validafirma_Envios::publico( $envio ) ); if ( Validafirma_Correos::es_de_formulario( $envio ) ) { Validafirma_Correos::por_causa( 'no_emitido', $envio ); } break; } return true; } /** * Programa la descarga del PDF firmado como evento único e inmediato. * * @param int $envio_id Id del envío. * @param int $intento Número de intento (para espaciar los reintentos). * @return void */ public static function programar_descarga( $envio_id, $intento ) { $cuando = time() + ( $intento > 0 ? 5 * MINUTE_IN_SECONDS : 0 ); if ( ! wp_next_scheduled( 'validafirma_descargar_firmado', array( (int) $envio_id, (int) $intento ) ) ) { wp_schedule_single_event( $cuando, 'validafirma_descargar_firmado', array( (int) $envio_id, (int) $intento ) ); } if ( 0 === $intento ) { spawn_cron(); } } /** * Descarga el PDF firmado con la llave del modo del envío y lo guarda en el almacén privado (RN-06). * Luego adjunta a la entrada (si el adaptador lo sabe), dispara el gancho y avisa al administrador. * * @param int $envio_id Id del envío. * @param int $intento Número de intento. * @param int $timeout Segundos que puede tardar la descarga. * @return void */ public static function descargar_firmado( $envio_id, $intento = 0, $timeout = 120 ) { $envio = Validafirma_Envios::obtener( $envio_id ); if ( ! $envio || 'firmado' !== $envio['estado'] || ! empty( $envio['archivo_firmado'] ) || empty( $envio['documento_id'] ) ) { return; } $modo = $envio['es_prueba'] ? 'prueba' : 'produccion'; if ( '' === Validafirma_Conexion::llave( $modo ) ) { return; } $temporal = Validafirma_Cliente_API::instancia()->descargar_documento( $modo, $envio['documento_id'], (int) $timeout ); if ( is_wp_error( $temporal ) ) { if ( (int) $intento < 6 ) { self::programar_descarga( $envio_id, (int) $intento + 1 ); } return; // La pasada diaria de reconciliación lo reintenta. } $nombre = Validafirma_Almacen_Privado::mover( $temporal ); if ( is_wp_error( $nombre ) ) { wp_delete_file( $temporal ); return; } // Solo el primero que guarda la copia sigue con los efectos (una vez por documento). global $wpdb; $tabla = Validafirma_Instalador::tabla( 'envios' ); $filas = $wpdb->query( $wpdb->prepare( 'UPDATE %i SET archivo_firmado = %s, actualizado_en = %s WHERE id = %d AND archivo_firmado IS NULL', $tabla, $nombre, Validafirma_Envios::ahora(), (int) $envio_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( 1 !== (int) $filas ) { Validafirma_Almacen_Privado::borrar( $nombre ); return; } $envio = Validafirma_Envios::obtener( $envio_id ); $ruta = Validafirma_Almacen_Privado::ruta( $nombre ); if ( class_exists( 'Validafirma_Registro_Adaptadores' ) && '' !== $envio['origen'] ) { $adaptador = Validafirma_Registro_Adaptadores::adaptador( $envio['origen'] ); if ( $adaptador ) { $adaptador->adjuntar_firmado( Validafirma_Envios::publico( $envio ), $ruta ); } } do_action( 'validafirma_documento_firmado', $envio_id, Validafirma_Envios::publico( $envio ), $ruta ); if ( Validafirma_Correos::es_de_formulario( $envio ) ) { Validafirma_Correos::firmado( $envio ); } } }